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.
- 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 +12 -9
- 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/cli/proxy.rb
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
class Dash::Cli::Proxy < Dash::Cli::Base
|
|
2
|
+
include Inspection
|
|
3
|
+
|
|
2
4
|
desc "boot", "Boot proxy on servers"
|
|
5
|
+
option :skip_loadbalancer, type: :boolean, default: false, desc: "Boot the per-host proxies only, leaving the load balancer alone (dash scale set uses it for a joining member)"
|
|
3
6
|
def boot
|
|
4
7
|
modify(lock: true, server_lock: true) do
|
|
5
8
|
on(network_hosts) do |host|
|
|
@@ -70,7 +73,9 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
70
73
|
run_hook "post-proxy-reboot", hosts: host
|
|
71
74
|
end
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
# A member joining with `dash scale set` holds the server lock on itself only, so the
|
|
77
|
+
# shared load balancer - which a drifted config would reboot here - is not its to touch.
|
|
78
|
+
if boots_loadbalancer?
|
|
74
79
|
lb_drifted = Concurrent::Array.new
|
|
75
80
|
lb_stale = Concurrent::Array.new
|
|
76
81
|
|
|
@@ -443,12 +448,8 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
443
448
|
end
|
|
444
449
|
when "deploy"
|
|
445
450
|
if DASH.config.proxy.load_balancing?
|
|
446
|
-
targets = DASH.loadbalancer_config.target_hosts
|
|
447
|
-
|
|
448
451
|
on(DASH.config.proxy.effective_loadbalancer) do |host|
|
|
449
|
-
Dash::Cli::Proxy::
|
|
450
|
-
info "Deploying to loadbalancer on #{host} with targets: #{targets.join(', ')}"
|
|
451
|
-
execute *DASH.loadbalancer.deploy(targets: targets)
|
|
452
|
+
Dash::Cli::Proxy::LoadbalancerDeploy.new(host, self).run
|
|
452
453
|
end
|
|
453
454
|
else
|
|
454
455
|
puts "Load balancing is not configured"
|
|
@@ -667,10 +668,14 @@ class Dash::Cli::Proxy < Dash::Cli::Base
|
|
|
667
668
|
# network there (zoolutions/dash#140).
|
|
668
669
|
def network_hosts
|
|
669
670
|
hosts = DASH.hosts
|
|
670
|
-
hosts |= [ DASH.config.proxy.effective_loadbalancer ] if
|
|
671
|
+
hosts |= [ DASH.config.proxy.effective_loadbalancer ] if boots_loadbalancer?
|
|
671
672
|
hosts
|
|
672
673
|
end
|
|
673
674
|
|
|
675
|
+
def boots_loadbalancer?
|
|
676
|
+
DASH.config.proxy.load_balancing? && !options[:skip_loadbalancer]
|
|
677
|
+
end
|
|
678
|
+
|
|
674
679
|
# The host that owns TLS, and so the certificate store: the loadbalancer
|
|
675
680
|
# host when load balancing (TLS terminates at the edge), else the primary
|
|
676
681
|
# host - the same host `loadbalancer: true` would resolve to.
|
data/lib/dash/cli/prune.rb
CHANGED
|
@@ -16,8 +16,8 @@ class Dash::Cli::Prune < Dash::Cli::Base
|
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
desc "containers", "Prune all stopped containers, except the last n per role (default 5)"
|
|
20
|
-
option :retain, type: :numeric, default: nil, desc: "Number of containers to retain per role"
|
|
19
|
+
desc "containers", "Prune all stopped containers, except the last n per role replica (default 5)"
|
|
20
|
+
option :retain, type: :numeric, default: nil, desc: "Number of containers to retain per role replica"
|
|
21
21
|
def containers
|
|
22
22
|
retain = options.fetch(:retain, DASH.config.retain_containers)
|
|
23
23
|
raise "retain must be at least 1" if retain < 1
|
|
@@ -27,7 +27,9 @@ class Dash::Cli::Prune < Dash::Cli::Base
|
|
|
27
27
|
# One round trip per host, whatever it runs: a host with no app roles still
|
|
28
28
|
# records that the sweep reached it.
|
|
29
29
|
execute *DASH.auditor.record_then("Pruned containers",
|
|
30
|
-
*DASH.roles_on(host).
|
|
30
|
+
*DASH.roles_on(host).flat_map { |role|
|
|
31
|
+
role.replica_numbers.map { |replica| DASH.prune.app_containers(retain: retain, role: role, replica: replica) }
|
|
32
|
+
})
|
|
31
33
|
end
|
|
32
34
|
end
|
|
33
35
|
end
|
data/lib/dash/cli/report.rb
CHANGED
|
@@ -13,8 +13,11 @@ class Dash::Cli::Report < Dash::Cli::Base
|
|
|
13
13
|
|
|
14
14
|
desc "show", "Print the last saved deploy report"
|
|
15
15
|
option :last, type: :numeric, banner: "N", desc: "Print a trend table over the last N reports instead"
|
|
16
|
+
option :json, type: :boolean, default: false, desc: "Print the saved reports as JSON, most recent first (at most #{Dash::Diagnostics::Reports::MAX})"
|
|
16
17
|
def show
|
|
17
|
-
if
|
|
18
|
+
if options[:json]
|
|
19
|
+
puts_json(ssh: false) { Dash::Diagnostics::Reports.new(last: options[:last] || 1, directory: reports_directory).to_h }
|
|
20
|
+
elsif (last = options[:last])
|
|
18
21
|
return say "--last takes a positive number of reports, got #{last}", :red unless count?(last)
|
|
19
22
|
|
|
20
23
|
print_trend saved.recent(last.to_i)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# How `dash scale set` reaches a container count on a scaled role, across hosts. Pure: it is
|
|
2
|
+
# handed the role's baseline hosts, its started members (in the order they would leave
|
|
3
|
+
# last-first), the target and both bounds, and says how many
|
|
4
|
+
# members join, which leave, and how many containers each host ends with.
|
|
5
|
+
#
|
|
6
|
+
# Hosts are kept as few as the count allows, never below scale min or the baseline (so
|
|
7
|
+
# scale min joins members too). Every
|
|
8
|
+
# host gets replicas min; the rest fills the baseline first, then members in order - so a
|
|
9
|
+
# scale-out fills the hosts it has before a member joins, and a scale-in empties members
|
|
10
|
+
# down to min, then lets them leave, before the baseline gives anything up.
|
|
11
|
+
class Dash::Cli::Scale::HostPlan
|
|
12
|
+
attr_reader :joins, :leaving, :counts, :join_counts
|
|
13
|
+
|
|
14
|
+
# [ slots to add, slots to remove ] to take a host from `slots` to `count` containers:
|
|
15
|
+
# the lowest free slots in, the highest out.
|
|
16
|
+
def self.slot_changes(slots, count)
|
|
17
|
+
slots = slots.sort
|
|
18
|
+
|
|
19
|
+
if count > slots.size
|
|
20
|
+
[ ((1..count + slots.size).to_a - slots).first(count - slots.size), [] ]
|
|
21
|
+
else
|
|
22
|
+
[ [], slots.reverse.first(slots.size - count) ]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(baseline:, members:, target:, replicas_min:, replicas_max:, hosts_min:, hosts_max:)
|
|
27
|
+
@min, @max = replicas_min, replicas_max
|
|
28
|
+
current = baseline + members
|
|
29
|
+
hosts = host_count(baseline.size, target, hosts_min)
|
|
30
|
+
|
|
31
|
+
raise ArgumentError, "#{target} containers need #{hosts} hosts, scale max is #{hosts_max}" if hosts > hosts_max
|
|
32
|
+
if hosts * @min > target || hosts * @max < target
|
|
33
|
+
raise ArgumentError, "#{target} containers cannot be split over #{hosts} hosts with #{@min} to #{@max} per host"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@joins = [ hosts - current.size, 0 ].max
|
|
37
|
+
@leaving = members.last([ current.size - hosts, 0 ].max)
|
|
38
|
+
|
|
39
|
+
kept = current - @leaving
|
|
40
|
+
spread = spread(target, kept.size + @joins)
|
|
41
|
+
@counts = kept.zip(spread).to_h
|
|
42
|
+
@join_counts = spread.drop(kept.size)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
# As few hosts as the count fits on, never below the baseline or scale min - which is
|
|
47
|
+
# hosts kept running, so it joins members as well as keeping them.
|
|
48
|
+
def host_count(baseline, target, hosts_min)
|
|
49
|
+
[ (target.to_f / @max).ceil, hosts_min, baseline ].max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Each host min, then the rest to the earliest hosts up to max.
|
|
53
|
+
def spread(target, hosts)
|
|
54
|
+
counts = Array.new(hosts, @min)
|
|
55
|
+
remaining = target - (hosts * @min)
|
|
56
|
+
|
|
57
|
+
counts.map do |count|
|
|
58
|
+
extra = [ remaining, @max - count ].min
|
|
59
|
+
remaining -= extra
|
|
60
|
+
count + extra
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# Brings one pool member into a scaled role: powers a stopped member on (or creates one
|
|
2
|
+
# from the template), waits for SSH, then runs what a deploy would run on a new host -
|
|
3
|
+
# bootstrap (created members only), registry login, the stale-container sweep, the proxy
|
|
4
|
+
# for a proxied role and the app boot at the version the role already runs - narrowed to
|
|
5
|
+
# that host and role. A proxied member joins the load balancer last, once it is healthy.
|
|
6
|
+
#
|
|
7
|
+
# The sweep comes before the boot on purpose: a member powered off outside dash resumes
|
|
8
|
+
# its old container under `restart: unless-stopped`, and a worker would consume jobs with
|
|
9
|
+
# old code until the boot replaced it.
|
|
10
|
+
#
|
|
11
|
+
# Anything failing after the power-on powers the member off again (or destroys it), unless
|
|
12
|
+
# --keep-on-failure, so a failed join never leaves a running host with nothing on it.
|
|
13
|
+
class Dash::Cli::Scale::Join
|
|
14
|
+
SSH_RETRY_INTERVAL = 5
|
|
15
|
+
|
|
16
|
+
attr_reader :role, :cli, :version, :count
|
|
17
|
+
|
|
18
|
+
def initialize(role, cli, version:, count:, member: nil, keep_on_failure: false)
|
|
19
|
+
@role = role
|
|
20
|
+
@cli = cli
|
|
21
|
+
@member = member
|
|
22
|
+
@version = version
|
|
23
|
+
@count = count
|
|
24
|
+
@keep_on_failure = keep_on_failure
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The member's host, once it serves.
|
|
28
|
+
def run
|
|
29
|
+
deadline = monotonic_now + role.scale.boot_timeout
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
member = power_on
|
|
33
|
+
host = joined_host(member)
|
|
34
|
+
wait_for_ssh(host, deadline)
|
|
35
|
+
cli.run_hook "pre-scale-out", role: role.name, hosts: host, replicas: count.to_s
|
|
36
|
+
|
|
37
|
+
cli.narrowed_to(host, role) do
|
|
38
|
+
cli.invoke_narrowed "dash:cli:server:bootstrap", Dash::Cli::Server if created?
|
|
39
|
+
cli.invoke_narrowed "dash:cli:registry:login", Dash::Cli::Registry, skip_local: true
|
|
40
|
+
cli.invoke_narrowed "dash:cli:app:stale_containers", Dash::Cli::App, stop: true
|
|
41
|
+
cli.invoke_narrowed "dash:cli:proxy:boot", Dash::Cli::Proxy, skip_loadbalancer: true if role.running_proxy?
|
|
42
|
+
cli.invoke_narrowed "dash:cli:app:boot", Dash::Cli::App, version: version
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if role.running_proxy?
|
|
46
|
+
cli.deploy_loadbalancer
|
|
47
|
+
@in_loadbalancer = host
|
|
48
|
+
end
|
|
49
|
+
cli.run_hook "post-scale-out", role: role.name, hosts: host, replicas: count.to_s
|
|
50
|
+
cli.report "Joined #{member.id} (#{host}) to #{role}"
|
|
51
|
+
host
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
# The created server, or the member started; nil when a create failed before the
|
|
54
|
+
# provider returned one, and then there is nothing to power off.
|
|
55
|
+
member = @created || @member
|
|
56
|
+
|
|
57
|
+
unless @keep_on_failure
|
|
58
|
+
# Out of the load balancer before it goes, or it would forward to a stopped host.
|
|
59
|
+
cli.deploy_loadbalancer(except: @in_loadbalancer) if @in_loadbalancer
|
|
60
|
+
power_off(member) if member
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
raise e
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
def created?
|
|
69
|
+
@member.nil?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def power_on
|
|
73
|
+
if created?
|
|
74
|
+
cli.report "Creating a #{role} member...", :magenta
|
|
75
|
+
@created = provider.create(labels: pool.labels_for(role), template: role.scale.template, address: role.scale.address)
|
|
76
|
+
else
|
|
77
|
+
cli.report "Powering on #{@member.id} (#{@member.host}) for #{role}...", :magenta
|
|
78
|
+
provider.start(@member)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
provider.wait_until(@created || @member, state: "started", timeout: role.scale.boot_timeout)
|
|
82
|
+
@created || @member
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A created server may not have its address until it runs, so the host is read back
|
|
86
|
+
# from the provider - which also puts the member into role.hosts for the narrowing.
|
|
87
|
+
def joined_host(member)
|
|
88
|
+
pool.refresh!
|
|
89
|
+
role.active_members.find { |candidate| candidate.id == member.id }&.host ||
|
|
90
|
+
raise(Dash::Autoscale::ProviderError, "#{member.id} is started but #{provider.name} does not list it among #{role}'s members")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The bounded handshake of Dash::Cli::Base#on (zoolutions/dash#152), retried until the
|
|
94
|
+
# member's boot_timeout runs out.
|
|
95
|
+
def wait_for_ssh(host, deadline)
|
|
96
|
+
cli.report "Waiting for #{host} to answer SSH...", :magenta
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
cli.on_hosts([ host ]) { execute :true }
|
|
100
|
+
rescue SSHKit::Runner::ExecuteError => e
|
|
101
|
+
if monotonic_now >= deadline
|
|
102
|
+
raise Dash::Cli::BootError, "#{host} did not answer SSH within #{role} scale/boot_timeout (#{role.scale.boot_timeout}s): #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
sleep SSH_RETRY_INTERVAL
|
|
106
|
+
retry
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def power_off(member)
|
|
111
|
+
cli.report "Join failed, powering #{member.id} off again (--keep-on-failure keeps it)", :red
|
|
112
|
+
Dash::Cli::Scale::PowerOff.new(role, member, provider).run(destroy: created?)
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
cli.report "Could not power #{member.id} off: #{e.message}", :red
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def pool
|
|
118
|
+
DASH.config.pool
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def provider
|
|
122
|
+
pool.provider
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def monotonic_now
|
|
126
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Takes one pool member out of a scaled role. A proxied member leaves the load balancer
|
|
2
|
+
# first - the redeploy without it drains its in-flight requests - and its containers stop
|
|
3
|
+
# after. A worker member is drained slot by slot (drain signal, drain wait, stop) the way a
|
|
4
|
+
# replica scale-in is. Then the provider powers it off, or deletes it for `members: create`.
|
|
5
|
+
# A baseline host never leaves: dash scale does not stop what deploy.yml lists.
|
|
6
|
+
#
|
|
7
|
+
# `reachable: false` replaces a member that stopped answering SSH (the autoscale controller,
|
|
8
|
+
# after `scale.boot_timeout`): nothing is run on it - no drain, no `docker stop` - but it
|
|
9
|
+
# still leaves the load balancer and the provider still powers it off (or deletes it).
|
|
10
|
+
class Dash::Cli::Scale::Leave
|
|
11
|
+
attr_reader :role, :member, :cli, :count
|
|
12
|
+
|
|
13
|
+
# `running` is { replica => version } on the member.
|
|
14
|
+
def initialize(role, member, cli, running:, count:, reachable: true)
|
|
15
|
+
@role = role
|
|
16
|
+
@member = member
|
|
17
|
+
@cli = cli
|
|
18
|
+
@running = running
|
|
19
|
+
@count = count
|
|
20
|
+
@reachable = reachable
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
host = member.host
|
|
25
|
+
raise ArgumentError, "#{host} is a baseline host of #{role}, dash scale never stops it" if role.baseline_hosts.include?(host)
|
|
26
|
+
|
|
27
|
+
cli.run_hook "pre-scale-in", role: role.name, hosts: host, replicas: count.to_s
|
|
28
|
+
cli.report "Removing #{member.id} (#{host}) from #{role}#{", unreachable" unless @reachable}...", :magenta
|
|
29
|
+
|
|
30
|
+
cli.deploy_loadbalancer(except: (host unless shared_with_another_proxied_role?(host))) if role.running_proxy?
|
|
31
|
+
stop_containers(host) if @reachable
|
|
32
|
+
Dash::Cli::Scale::PowerOff.new(role, member, DASH.config.pool.provider).run(destroy: role.scale.create?)
|
|
33
|
+
DASH.config.pool.refresh!
|
|
34
|
+
|
|
35
|
+
cli.run_hook "post-scale-in", role: role.name, hosts: host, replicas: count.to_s
|
|
36
|
+
cli.report "Removed #{member.id} (#{host}) from #{role}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
# The load balancer has one target list for every proxied role of the app.
|
|
41
|
+
def shared_with_another_proxied_role?(host)
|
|
42
|
+
DASH.config.roles.any? { |other| other != role && other.running_proxy? && other.hosts.include?(host) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stop_containers(host)
|
|
46
|
+
role, running = self.role, @running
|
|
47
|
+
|
|
48
|
+
cli.on_hosts([ host ]) do
|
|
49
|
+
if running.empty?
|
|
50
|
+
# An orphan: nothing of the role to stop or drain.
|
|
51
|
+
elsif role.running_proxy?
|
|
52
|
+
running.each { |replica, version| execute *DASH.app(role: role, host: host, replica: replica).stop(version: version) }
|
|
53
|
+
else
|
|
54
|
+
Dash::Cli::Scale::ReplicaLeave.new(host, role, running, self, running: running.keys).run
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Where `dash scale set` adds or removes containers to reach a total. Pure: it is handed the
|
|
2
|
+
# slots running on each host and returns the slots to add and remove per host.
|
|
3
|
+
#
|
|
4
|
+
# Adds go to the host with the fewest replicas, in its lowest free slot; removals come from
|
|
5
|
+
# the host with the most, highest slot first. Ties go to the earlier host for adds and the
|
|
6
|
+
# later host for removals, so repeated scaling out and in keeps the same hosts fuller.
|
|
7
|
+
class Dash::Cli::Scale::Placement
|
|
8
|
+
attr_reader :additions, :removals
|
|
9
|
+
|
|
10
|
+
def initialize(running:, target:, max:)
|
|
11
|
+
@slots = running.transform_values { |slots| slots.sort }
|
|
12
|
+
@max = max
|
|
13
|
+
@additions = Hash.new { |hash, host| hash[host] = [] }
|
|
14
|
+
@removals = Hash.new { |hash, host| hash[host] = [] }
|
|
15
|
+
|
|
16
|
+
plan(target)
|
|
17
|
+
|
|
18
|
+
@additions = @additions.to_h
|
|
19
|
+
@removals = @removals.to_h
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
# A host above max (max was lowered since it scaled) sheds its surplus first: the next
|
|
24
|
+
# deploy would clamp it anyway, and the count asked for would quietly not hold.
|
|
25
|
+
def plan(target)
|
|
26
|
+
@slots.each_key do |host|
|
|
27
|
+
remove_from(host) while @slots[host].size > @max
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
total = @slots.values.sum(&:size)
|
|
31
|
+
|
|
32
|
+
(target - total).times { add_one } if target > total
|
|
33
|
+
(total - target).times { remove_one } if target < total
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def add_one
|
|
37
|
+
host = @slots.keys.select { |candidate| @slots[candidate].size < @max }.min_by { |candidate| @slots[candidate].size }
|
|
38
|
+
slot = ((1..@max).to_a - @slots[host]).first
|
|
39
|
+
|
|
40
|
+
@slots[host] = (@slots[host] + [ slot ]).sort
|
|
41
|
+
@additions[host] << slot
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def remove_one
|
|
45
|
+
remove_from @slots.keys.reverse.max_by { |candidate| @slots[candidate].size }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def remove_from(host)
|
|
49
|
+
slot = @slots[host].max
|
|
50
|
+
|
|
51
|
+
@slots[host] = @slots[host] - [ slot ]
|
|
52
|
+
@removals[host] << slot
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Powers a pool member off through the provider and waits for it to be stopped, then
|
|
2
|
+
# deletes it when the role creates its members. The provider's soft stop gets the role's
|
|
3
|
+
# stop budget plus room for the operating system to shut down.
|
|
4
|
+
class Dash::Cli::Scale::PowerOff
|
|
5
|
+
SHUTDOWN_ALLOWANCE = 30
|
|
6
|
+
DEFAULT_STOP_TIMEOUT = 30
|
|
7
|
+
|
|
8
|
+
def initialize(role, member, provider)
|
|
9
|
+
@role = role
|
|
10
|
+
@member = member
|
|
11
|
+
@provider = provider
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(destroy:)
|
|
15
|
+
timeout = (@role.stop_timeout || DEFAULT_STOP_TIMEOUT) + SHUTDOWN_ALLOWANCE
|
|
16
|
+
|
|
17
|
+
# A member a failed join left stopped refuses a second stop (UpCloud answers
|
|
18
|
+
# SERVER_STATE_ILLEGAL), which would skip the destroy.
|
|
19
|
+
unless @provider.state(@member) == "stopped"
|
|
20
|
+
@provider.stop(@member, timeout: timeout)
|
|
21
|
+
@provider.wait_until(@member, state: "stopped", timeout: timeout + SHUTDOWN_ALLOWANCE)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@provider.destroy(@member) if destroy
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Adds replica slots of one role on one host, at the version the host already runs (the
|
|
2
|
+
# caller sets it on the config). A web replica joins the proxy pool only once healthy:
|
|
3
|
+
# dash-proxy waits for every target before it swaps. On failure the new containers are
|
|
4
|
+
# stopped and the replicas already running are left alone.
|
|
5
|
+
class Dash::Cli::Scale::ReplicaJoin
|
|
6
|
+
SHORT_CONTAINER_ID_LENGTH = Dash::Cli::App::Boot::SHORT_CONTAINER_ID_LENGTH
|
|
7
|
+
|
|
8
|
+
attr_reader :host, :role, :replicas, :sshkit
|
|
9
|
+
delegate :execute, :capture_with_info, :upload!, :error, to: :sshkit
|
|
10
|
+
|
|
11
|
+
# `running` is the slots already running on the host, which stay in the pool.
|
|
12
|
+
def initialize(host, role, replicas, sshkit, running:)
|
|
13
|
+
@host = host
|
|
14
|
+
@role = role
|
|
15
|
+
@replicas = replicas
|
|
16
|
+
@sshkit = sshkit
|
|
17
|
+
@running = running
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
execute *DASH.auditor(role: role).record_then("Scaled out to replicas #{replicas.join(", ")} at #{DASH.config.version}", app.ensure_env_directory)
|
|
22
|
+
upload! role.secrets_io(host), role.secrets_path, mode: "0600"
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
replicas.each { |replica| start(replica) }
|
|
26
|
+
role.running_proxy? ? join_proxy : wait_for_ready
|
|
27
|
+
rescue => e
|
|
28
|
+
error "Failed to scale out #{role} on #{host}"
|
|
29
|
+
replicas.each { |replica| execute *app(replica).stop(version: DASH.config.version), raise_on_non_zero_exit: false }
|
|
30
|
+
raise e
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
# A scale-in stops a replica and leaves its container, so the slot's name can still be
|
|
36
|
+
# taken by a stopped container of this version. `docker rm` refuses a running one, and
|
|
37
|
+
# the slot is not running - that is why it is being added.
|
|
38
|
+
def start(replica)
|
|
39
|
+
execute *app(replica).remove_container(version: DASH.config.version), raise_on_non_zero_exit: false
|
|
40
|
+
|
|
41
|
+
hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
|
|
42
|
+
capture_with_info(*app(replica).run(hostname: hostname))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The whole pool, old replicas and new: a deploy replaces the service's target list, so a
|
|
46
|
+
# missing id would drop a running replica from it.
|
|
47
|
+
def join_proxy
|
|
48
|
+
pool = (@running + replicas).sort
|
|
49
|
+
targets = Dash::Cli::App::RunningTargets.new(sshkit, role: role, host: host).container_ids(replicas: pool)
|
|
50
|
+
|
|
51
|
+
if targets.size != pool.size
|
|
52
|
+
raise Dash::Cli::BootError, "Found #{targets.size} of the #{pool.size} replicas of #{role} on #{host} to pool, not changing the proxy"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
execute *app.deploy(targets: targets.map { |id| id[0, SHORT_CONTAINER_ID_LENGTH] })
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def wait_for_ready
|
|
59
|
+
replicas.each do |replica|
|
|
60
|
+
Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role) do |mode, seconds_left = nil|
|
|
61
|
+
if mode == :confirm
|
|
62
|
+
capture_with_info(*app(replica).status(version: DASH.config.version))
|
|
63
|
+
else
|
|
64
|
+
capture_with_info *app(replica).wait_for_ready(version: DASH.config.version, timeout: seconds_left),
|
|
65
|
+
interaction_handler: Dash::Cli::Healthcheck::ProgressReporter.new
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def app(replica = 1)
|
|
72
|
+
DASH.app(role: role, host: host, replica: replica)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Removes replica slots of one role on one host, gracefully. A web replica leaves the proxy
|
|
2
|
+
# pool first - the redeploy without it drains its in-flight requests - and only then stops.
|
|
3
|
+
# A worker replica is sent its drain signal and given its drain wait before `docker stop`,
|
|
4
|
+
# whose own timeout is the role's stop budget. A role with a Sidekiq signal ends the wait as
|
|
5
|
+
# soon as Sidekiq reports nothing busy in the leaving containers.
|
|
6
|
+
class Dash::Cli::Scale::ReplicaLeave
|
|
7
|
+
SHORT_CONTAINER_ID_LENGTH = Dash::Cli::App::Boot::SHORT_CONTAINER_ID_LENGTH
|
|
8
|
+
BUSY_POLL_INTERVAL = 2
|
|
9
|
+
|
|
10
|
+
attr_reader :host, :role, :leaving, :sshkit
|
|
11
|
+
delegate :execute, :capture_with_info, :info, to: :sshkit
|
|
12
|
+
|
|
13
|
+
# What can say how many jobs a container is still working on: the role's signal, when its
|
|
14
|
+
# source can (only Sidekiq). Nil for every other role.
|
|
15
|
+
def self.busy_reader_for(role)
|
|
16
|
+
return unless role.scaled? && role.scale.signal?
|
|
17
|
+
|
|
18
|
+
Dash::Autoscale::Signal.for(role, Dash::Autoscale::Connections.shared).then { |reader| reader if reader.busy? }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# `leaving` is { replica => running version }; `running` is every slot running on the host.
|
|
22
|
+
def initialize(host, role, leaving, sshkit, running:, busy_reader: self.class.busy_reader_for(role))
|
|
23
|
+
@host = host
|
|
24
|
+
@role = role
|
|
25
|
+
@leaving = leaving
|
|
26
|
+
@sshkit = sshkit
|
|
27
|
+
@running = running
|
|
28
|
+
@busy_reader = busy_reader
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
execute *DASH.auditor(role: role).record("Scaled in replicas #{leaving.keys.join(", ")}"), verbosity: :debug
|
|
33
|
+
|
|
34
|
+
if role.running_proxy?
|
|
35
|
+
leave_proxy
|
|
36
|
+
else
|
|
37
|
+
drain
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A stop that fails must fail the scale-in: the replica would still run while the
|
|
41
|
+
# command reported the count reached.
|
|
42
|
+
leaving.each do |replica, version|
|
|
43
|
+
execute *app(replica).stop(version: version)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
def leave_proxy
|
|
49
|
+
staying = @running - leaving.keys
|
|
50
|
+
targets = Dash::Cli::App::RunningTargets.new(sshkit, role: role, host: host).container_ids(replicas: staying)
|
|
51
|
+
execute *app.deploy(targets: targets.map { |id| id[0, SHORT_CONTAINER_ID_LENGTH] })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The signal is optional; the wait is not tied to it, so `drain: { wait: N }` alone
|
|
55
|
+
# still gives a replica N seconds before `docker stop`. The hostnames are read first:
|
|
56
|
+
# a container told to stop may not answer an inspect for long.
|
|
57
|
+
def drain
|
|
58
|
+
hostnames = leaving_hostnames if @busy_reader && role.drain_wait.positive?
|
|
59
|
+
|
|
60
|
+
if role.drain_signal
|
|
61
|
+
leaving.each do |replica, version|
|
|
62
|
+
execute *app(replica).signal(role.drain_signal, version: version), raise_on_non_zero_exit: false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
await_drain(hostnames)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The full drain wait, or - with the hostnames to ask about - until nothing is busy in
|
|
70
|
+
# them, polling every BUSY_POLL_INTERVAL seconds. A busy count that cannot be read waits
|
|
71
|
+
# out the rest of the drain.
|
|
72
|
+
def await_drain(hostnames)
|
|
73
|
+
return unless role.drain_wait.positive?
|
|
74
|
+
|
|
75
|
+
info "Waiting up to #{role.drain_wait}s for #{role} replicas #{leaving.keys.join(", ")} on #{host} to drain..."
|
|
76
|
+
return sleep(role.drain_wait) if hostnames.blank?
|
|
77
|
+
|
|
78
|
+
deadline = monotonic + role.drain_wait
|
|
79
|
+
loop do
|
|
80
|
+
busy = busy_count(hostnames)
|
|
81
|
+
return info("#{role} replicas #{leaving.keys.join(", ")} on #{host} have nothing busy") if busy&.zero?
|
|
82
|
+
|
|
83
|
+
remaining = deadline - monotonic
|
|
84
|
+
return unless remaining.positive?
|
|
85
|
+
|
|
86
|
+
sleep(busy.nil? ? remaining : [ BUSY_POLL_INTERVAL, remaining ].min)
|
|
87
|
+
return if busy.nil?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Nil unless every leaving container's hostname is read: one missing would drop out of
|
|
92
|
+
# what the busy count covers.
|
|
93
|
+
def leaving_hostnames
|
|
94
|
+
hostnames = leaving.map { |replica, version| capture_with_info(*app(replica).container_hostname(version: version)).strip }
|
|
95
|
+
return hostnames if hostnames.none?(&:empty?)
|
|
96
|
+
|
|
97
|
+
info "Could not read the hostname of every #{role} replica #{leaving.keys.join(", ")} on #{host}, so the drain waits its full #{role.drain_wait}s"
|
|
98
|
+
nil
|
|
99
|
+
rescue SSHKit::Command::Failed => e
|
|
100
|
+
info "Could not read the hostnames of #{role} replicas #{leaving.keys.join(", ")} on #{host} (#{e.message}), so the drain waits its full #{role.drain_wait}s"
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Like a signal sample, a failed read never fails the scale-in: it only loses the early end.
|
|
105
|
+
def busy_count(hostnames)
|
|
106
|
+
@busy_reader.busy(hostnames).tap do |busy|
|
|
107
|
+
info "Sidekiq reports no process in #{role} replicas #{leaving.keys.join(", ")} on #{host}, so the drain waits out drain.wait" if busy.nil?
|
|
108
|
+
end
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
info "Could not read what #{role} replicas #{leaving.keys.join(", ")} on #{host} are working on (#{e.class}: #{e.message}), so the drain waits out drain.wait"
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def monotonic
|
|
115
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def app(replica = 1)
|
|
119
|
+
DASH.app(role: role, host: host, replica: replica)
|
|
120
|
+
end
|
|
121
|
+
end
|