dash 4.1.2 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dash/autoscale/connections/builder.rb +51 -0
  3. data/lib/dash/autoscale/connections.rb +102 -0
  4. data/lib/dash/autoscale/controller.rb +296 -0
  5. data/lib/dash/autoscale/cron.rb +75 -0
  6. data/lib/dash/autoscale/decision.rb +89 -0
  7. data/lib/dash/autoscale/duration.rb +19 -0
  8. data/lib/dash/autoscale/evaluation.rb +52 -0
  9. data/lib/dash/autoscale/guard.rb +48 -0
  10. data/lib/dash/autoscale/lease.rb +55 -0
  11. data/lib/dash/autoscale/lease_held.rb +2 -0
  12. data/lib/dash/autoscale/lease_lost.rb +2 -0
  13. data/lib/dash/autoscale/member.rb +28 -0
  14. data/lib/dash/autoscale/metrics.rb +70 -0
  15. data/lib/dash/autoscale/metrics_server.rb +71 -0
  16. data/lib/dash/autoscale/observation.rb +41 -0
  17. data/lib/dash/autoscale/pause.rb +34 -0
  18. data/lib/dash/autoscale/policy.rb +198 -0
  19. data/lib/dash/autoscale/pool.rb +103 -0
  20. data/lib/dash/autoscale/prometheus_client.rb +76 -0
  21. data/lib/dash/autoscale/provider/base.rb +43 -0
  22. data/lib/dash/autoscale/provider/exec.rb +94 -0
  23. data/lib/dash/autoscale/provider/upcloud.rb +140 -0
  24. data/lib/dash/autoscale/provider.rb +9 -0
  25. data/lib/dash/autoscale/provider_error.rb +4 -0
  26. data/lib/dash/autoscale/read_only_query.rb +38 -0
  27. data/lib/dash/autoscale/reader.rb +17 -0
  28. data/lib/dash/autoscale/recorder.rb +13 -0
  29. data/lib/dash/autoscale/role_state.rb +110 -0
  30. data/lib/dash/autoscale/sample.rb +13 -0
  31. data/lib/dash/autoscale/signal/pgbus.rb +19 -0
  32. data/lib/dash/autoscale/signal/prometheus.rb +8 -0
  33. data/lib/dash/autoscale/signal/sidekiq.rb +49 -0
  34. data/lib/dash/autoscale/signal.rb +31 -0
  35. data/lib/dash/autoscale/simulation.rb +200 -0
  36. data/lib/dash/autoscale/state_store.rb +132 -0
  37. data/lib/dash/autoscale/timestamp.rb +15 -0
  38. data/lib/dash/autoscale/tunnel.rb +48 -0
  39. data/lib/dash/cli/accessory.rb +16 -0
  40. data/lib/dash/cli/app/boot.rb +69 -44
  41. data/lib/dash/cli/app/boot_state.rb +70 -0
  42. data/lib/dash/cli/app/rollout_boot.rb +28 -9
  43. data/lib/dash/cli/app/running_targets.rb +39 -0
  44. data/lib/dash/cli/app/slot_version.rb +29 -0
  45. data/lib/dash/cli/app.rb +89 -32
  46. data/lib/dash/cli/autoscale.rb +312 -0
  47. data/lib/dash/cli/base.rb +15 -3
  48. data/lib/dash/cli/lock.rb +6 -1
  49. data/lib/dash/cli/main.rb +77 -8
  50. data/lib/dash/cli/proxy/inspection.rb +52 -0
  51. data/lib/dash/cli/proxy/loadbalancer_deploy.rb +20 -0
  52. data/lib/dash/cli/proxy/loadbalancer_reboot.rb +1 -1
  53. data/lib/dash/cli/proxy/reboot.rb +4 -9
  54. data/lib/dash/cli/proxy.rb +12 -7
  55. data/lib/dash/cli/prune.rb +5 -3
  56. data/lib/dash/cli/report.rb +4 -1
  57. data/lib/dash/cli/scale/host_plan.rb +63 -0
  58. data/lib/dash/cli/scale/join.rb +128 -0
  59. data/lib/dash/cli/scale/leave.rb +58 -0
  60. data/lib/dash/cli/scale/placement.rb +54 -0
  61. data/lib/dash/cli/scale/power_off.rb +26 -0
  62. data/lib/dash/cli/scale/replica_join.rb +74 -0
  63. data/lib/dash/cli/scale/replica_leave.rb +121 -0
  64. data/lib/dash/cli/scale.rb +276 -0
  65. data/lib/dash/cli/server.rb +41 -0
  66. data/lib/dash/cli/stats_table.rb +34 -0
  67. data/lib/dash/cli/templates/sample_hooks/post-scale-in.sample +3 -0
  68. data/lib/dash/cli/templates/sample_hooks/post-scale-out.sample +3 -0
  69. data/lib/dash/cli/templates/sample_hooks/pre-scale-in.sample +6 -0
  70. data/lib/dash/cli/templates/sample_hooks/pre-scale-out.sample +6 -0
  71. data/lib/dash/commander.rb +17 -3
  72. data/lib/dash/commands/accessory/proxy.rb +1 -1
  73. data/lib/dash/commands/accessory.rb +21 -2
  74. data/lib/dash/commands/app/containers.rb +28 -3
  75. data/lib/dash/commands/app/logging.rb +13 -2
  76. data/lib/dash/commands/app/proxy.rb +4 -4
  77. data/lib/dash/commands/app.rb +90 -10
  78. data/lib/dash/commands/auditor.rb +2 -2
  79. data/lib/dash/commands/autoscale.rb +133 -0
  80. data/lib/dash/commands/base.rb +4 -0
  81. data/lib/dash/commands/proxy/services.rb +10 -0
  82. data/lib/dash/commands/prune.rb +10 -2
  83. data/lib/dash/commands/server.rb +16 -0
  84. data/lib/dash/configuration/accessory.rb +2 -2
  85. data/lib/dash/configuration/autoscale.rb +123 -0
  86. data/lib/dash/configuration/docs/autoscale.yml +141 -0
  87. data/lib/dash/configuration/docs/configuration.yml +8 -2
  88. data/lib/dash/configuration/docs/proxy.yml +12 -9
  89. data/lib/dash/configuration/docs/role.yml +203 -0
  90. data/lib/dash/configuration/loadbalancer.rb +3 -2
  91. data/lib/dash/configuration/proxy/run.rb +1 -1
  92. data/lib/dash/configuration/proxy.rb +19 -7
  93. data/lib/dash/configuration/role/drain.rb +49 -0
  94. data/lib/dash/configuration/role/replicas.rb +72 -0
  95. data/lib/dash/configuration/role/scale/guard.rb +40 -0
  96. data/lib/dash/configuration/role/scale/signal.rb +93 -0
  97. data/lib/dash/configuration/role/scale/window.rb +37 -0
  98. data/lib/dash/configuration/role/scale.rb +199 -0
  99. data/lib/dash/configuration/role.rb +123 -6
  100. data/lib/dash/configuration/validator/autoscale.rb +126 -0
  101. data/lib/dash/configuration/validator/proxy.rb +13 -0
  102. data/lib/dash/configuration/validator/role.rb +26 -5
  103. data/lib/dash/configuration.rb +126 -15
  104. data/lib/dash/diagnostics/audit.rb +28 -0
  105. data/lib/dash/diagnostics/autoscale_decisions.rb +18 -0
  106. data/lib/dash/diagnostics/autoscale_explain.rb +30 -0
  107. data/lib/dash/diagnostics/autoscale_signal.rb +82 -0
  108. data/lib/dash/diagnostics/base.rb +53 -0
  109. data/lib/dash/diagnostics/config.rb +32 -0
  110. data/lib/dash/diagnostics/container_stats.rb +67 -0
  111. data/lib/dash/diagnostics/containers.rb +20 -0
  112. data/lib/dash/diagnostics/controller_status.rb +44 -0
  113. data/lib/dash/diagnostics/docker_ps.rb +40 -0
  114. data/lib/dash/{cli → diagnostics}/doctor/config_checks.rb +3 -3
  115. data/lib/dash/diagnostics/doctor/controller_checks.rb +46 -0
  116. data/lib/dash/{cli → diagnostics}/doctor/endpoint_checks.rb +12 -4
  117. data/lib/dash/{cli → diagnostics}/doctor/host_checks.rb +5 -4
  118. data/lib/dash/diagnostics/doctor/pool_checks.rb +65 -0
  119. data/lib/dash/diagnostics/doctor/signal_checks.rb +54 -0
  120. data/lib/dash/diagnostics/doctor.rb +182 -0
  121. data/lib/dash/diagnostics/drift.rb +168 -0
  122. data/lib/dash/diagnostics/host_stats.rb +59 -0
  123. data/lib/dash/diagnostics/lines.rb +14 -0
  124. data/lib/dash/diagnostics/lock.rb +27 -0
  125. data/lib/dash/diagnostics/logs.rb +76 -0
  126. data/lib/dash/diagnostics/pool.rb +41 -0
  127. data/lib/dash/diagnostics/proxy_services.rb +36 -0
  128. data/lib/dash/diagnostics/redactor.rb +48 -0
  129. data/lib/dash/diagnostics/reports.rb +17 -0
  130. data/lib/dash/diagnostics/scale.rb +51 -0
  131. data/lib/dash/diagnostics/units.rb +31 -0
  132. data/lib/dash/mcp/base_tool.rb +43 -0
  133. data/lib/dash/mcp/runner.rb +28 -0
  134. data/lib/dash/mcp/server.rb +40 -0
  135. data/lib/dash/mcp/session.rb +51 -0
  136. data/lib/dash/mcp/tools/audit_tool.rb +11 -0
  137. data/lib/dash/mcp/tools/autoscale_decisions_tool.rb +22 -0
  138. data/lib/dash/mcp/tools/autoscale_explain_tool.rb +56 -0
  139. data/lib/dash/mcp/tools/config_tool.rb +10 -0
  140. data/lib/dash/mcp/tools/container_stats_tool.rb +15 -0
  141. data/lib/dash/mcp/tools/containers_tool.rb +10 -0
  142. data/lib/dash/mcp/tools/controller_status_tool.rb +18 -0
  143. data/lib/dash/mcp/tools/deploy_reports_tool.rb +10 -0
  144. data/lib/dash/mcp/tools/doctor_tool.rb +13 -0
  145. data/lib/dash/mcp/tools/drift_tool.rb +16 -0
  146. data/lib/dash/mcp/tools/host_stats_tool.rb +14 -0
  147. data/lib/dash/mcp/tools/lock_status_tool.rb +15 -0
  148. data/lib/dash/mcp/tools/logs_tool.rb +41 -0
  149. data/lib/dash/mcp/tools/pool_members_tool.rb +15 -0
  150. data/lib/dash/mcp/tools/proxy_services_tool.rb +10 -0
  151. data/lib/dash/mcp/tools/scale_status_tool.rb +10 -0
  152. data/lib/dash/mcp/tools/signal_sample_tool.rb +19 -0
  153. data/lib/dash/mcp/transport.rb +14 -0
  154. data/lib/dash/mcp.rb +56 -0
  155. data/lib/dash/sshkit_with_ext.rb +4 -4
  156. data/lib/dash/utils.rb +7 -0
  157. data/lib/dash/version.rb +1 -1
  158. data/lib/dash.rb +2 -0
  159. metadata +119 -5
  160. data/lib/dash/cli/doctor.rb +0 -113
@@ -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
- if DASH.config.proxy.load_balancing?
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::LoadbalancerClaim.new(host, self).claim_service
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 DASH.config.proxy.load_balancing?
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.
@@ -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).map { |role| DASH.prune.app_containers(retain: retain, role: role) })
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
@@ -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 (last = options[:last])
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