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
@@ -18,77 +18,84 @@ class Dash::Cli::App::Boot
18
18
  @cli = cli
19
19
  end
20
20
 
21
+ # Every replica of the role on this host is swapped together: the old containers keep
22
+ # serving until all the new ones are ready, and a failure stops every new one.
21
23
  def run
22
24
  DASH.timings.phase("#{role} #{host}", depth: 1) do |timing|
23
25
  @timing = timing
24
26
 
25
- old_version = old_version_renamed_if_clashing
27
+ @state = capture_boot_state
28
+ announce_replicas if role.replicas.scalable?
29
+ old_versions = old_versions_renamed_if_clashing
26
30
 
27
31
  wait_at_barrier if queuer?
28
32
 
29
33
  begin
30
- start_new_version
34
+ start_new_versions
31
35
  rescue => e
32
36
  close_barrier if gatekeeper?
33
- stop_new_version
37
+ stop_new_versions
34
38
  raise
35
39
  end
36
40
 
37
41
  release_barrier if gatekeeper?
38
42
 
39
- if old_version
40
- stop_old_version(old_version)
41
- end
43
+ stop_old_versions(old_versions)
42
44
  end
43
45
  end
44
46
 
45
47
  private
46
- # Both answers come back from one round trip, which means the running version is read
47
- # before any rename happens. When the clashing container IS the running one, the
48
+ attr_reader :state
49
+
50
+ # Every answer comes back from one round trip, which means the running versions are
51
+ # read before any rename happens. When a clashing container IS the running one, the
48
52
  # version to stop later is the name it was renamed to - the name that was read now
49
53
  # belongs to the container this boot is about to start.
50
- def old_version_renamed_if_clashing
51
- clashing_container_id, old_version = capture_boot_state
54
+ def old_versions_renamed_if_clashing
55
+ old_versions = state.replicas.to_h { |replica| [ replica, state.running_version(replica) ] }
52
56
 
53
- if clashing_container_id.present?
57
+ state.replicas.select { |replica| state.clashing?(replica) }.each do |replica|
54
58
  renamed_version = "#{version}_replaced_#{SecureRandom.hex(8)}"
55
- info "Renaming container #{version} to #{renamed_version} as already deployed on #{host}"
59
+ info "Renaming container #{app(replica).container_name(version)} to #{renamed_version} as already deployed on #{host}"
56
60
  execute *auditor.record_then("Renaming container #{version} to #{renamed_version}",
57
- app.rename_container(version: version, new_version: renamed_version))
61
+ app(replica).rename_container(version: version, new_version: renamed_version))
58
62
 
59
- old_version = renamed_version if old_version == version
63
+ old_versions[replica] = renamed_version if old_versions[replica] == version
60
64
  end
61
65
 
62
- old_version
66
+ old_versions.merge(state.surplus_replicas.to_h { |replica| [ replica, state.running_version(replica) ] }).compact
63
67
  end
64
68
 
65
- def capture_boot_state
66
- clashing, running = Dash::Commands::App.split_state(capture_with_info(*app.boot_state(version), raise_on_non_zero_exit: false))
67
-
68
- [ clashing.strip.presence, running.strip.presence ]
69
+ # A slot that crashed between min and the runtime count is not running, so it is not
70
+ # counted - say so, rather than letting the count drop silently.
71
+ def announce_replicas
72
+ info "Booting #{state.count} #{"replica".pluralize(state.count)} of #{role} on #{host} (#{state.running_count} of #{role.replicas.max} slots running, min #{role.replicas.min})"
69
73
  end
70
74
 
71
- def start_new_version
72
- hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
75
+ def capture_boot_state
76
+ Dash::Cli::App::BootState.new(role, capture_with_info(*app.boot_states(version), raise_on_non_zero_exit: false))
77
+ end
73
78
 
79
+ def start_new_versions
74
80
  execute *auditor.record_then("Booted app version #{version}", app.ensure_env_directory)
75
81
  upload! role.secrets_io(host), role.secrets_path, mode: "0600"
76
82
 
77
- # `docker run --detach` prints the id of the container it just started, so the
78
- # proxy target comes out of the run itself — asking docker for it again was a round
79
- # trip spent re-reading something the host had already said.
80
- container_id = capture_with_info(*app.run(hostname: hostname)).strip
83
+ container_ids = state.replicas.map { |replica| run_replica(replica) }
81
84
 
82
85
  if running_proxy?
83
- endpoint = container_id[0, SHORT_CONTAINER_ID_LENGTH]
84
- raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
86
+ endpoints = container_ids.map { |container_id| container_id[0, SHORT_CONTAINER_ID_LENGTH] }
87
+ raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoints.any?(&:empty?)
85
88
 
86
89
  run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
87
90
  info "Deploying #{role} on #{host} via dash-proxy (waiting up to #{DASH.config.deploy_timeout}s for it to become healthy)..."
88
- timing_healthy { execute *app.deploy(target: endpoint) }
91
+ timing_healthy { execute *app.deploy(targets: endpoints) }
89
92
  run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
90
93
  else
91
- timing_healthy { Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role, &method(:readiness_status)) }
94
+ timing_healthy do
95
+ state.replicas.each do |replica|
96
+ Dash::Cli::Healthcheck::Poller.wait_for_healthy(role: role) { |*args| readiness_status(replica, *args) }
97
+ end
98
+ end
92
99
  end
93
100
  rescue => e
94
101
  error "Failed to boot #{role} on #{host}"
@@ -96,6 +103,14 @@ class Dash::Cli::App::Boot
96
103
  raise e
97
104
  end
98
105
 
106
+ # `docker run --detach` prints the id of the container it just started, so the proxy
107
+ # target comes out of the run itself — asking docker for it again was a round trip
108
+ # spent re-reading something the host had already said.
109
+ def run_replica(replica)
110
+ hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
111
+ capture_with_info(*app(replica).run(hostname: hostname)).strip
112
+ end
113
+
99
114
  # A role behind the proxy lets `dash-proxy deploy` block on the host until the
100
115
  # container is healthy; a role without one now does the same, waiting in a shell loop
101
116
  # on the host that streams its progress back rather than being polled from here once
@@ -104,11 +119,11 @@ class Dash::Cli::App::Boot
104
119
  #
105
120
  # Neither capture suppresses a non-zero exit: a status that cannot be read is a broken
106
121
  # command, and it has always failed the boot on the spot rather than being waited out.
107
- def readiness_status(mode, seconds_left = nil)
122
+ def readiness_status(replica, mode, seconds_left = nil)
108
123
  if mode == :confirm
109
- capture_with_info(*app.status(version: version))
124
+ capture_with_info(*app(replica).status(version: version))
110
125
  else
111
- capture_with_info *app.wait_for_ready(version: version, timeout: seconds_left),
126
+ capture_with_info *app(replica).wait_for_ready(version: version, timeout: seconds_left),
112
127
  interaction_handler: Dash::Cli::Healthcheck::ProgressReporter.new
113
128
  end
114
129
  end
@@ -116,22 +131,30 @@ class Dash::Cli::App::Boot
116
131
  # Every failed boot gets the container log, and the health probe history when the
117
132
  # container declares a healthcheck — non-primary roles have no dash-proxy report to fall back on.
118
133
  def dump_diagnostics
119
- error capture_with_info(*app.logs(container_id: app.container_id_for_version(version)))
134
+ state.replicas.each do |replica|
135
+ error capture_with_info(*app(replica).logs(container_id: app(replica).container_id_for_version(version)))
120
136
 
121
- health_log = capture_with_info(*app.container_health_log(version: version)).strip
122
- error health_log unless health_log.empty? || health_log == "null"
137
+ health_log = capture_with_info(*app(replica).container_health_log(version: version)).strip
138
+ error health_log unless health_log.empty? || health_log == "null"
139
+ end
123
140
  rescue SSHKit::Command::Failed
124
141
  error "Could not fetch logs for #{version}"
125
142
  end
126
143
 
127
- def stop_new_version
128
- execute *app.stop(version: version), raise_on_non_zero_exit: false
144
+ def stop_new_versions
145
+ state.replicas.each do |replica|
146
+ execute *app(replica).stop(version: version), raise_on_non_zero_exit: false
147
+ end
129
148
  end
130
149
 
131
- def stop_old_version(old_version)
132
- run_stop_hook "pre-app-stop", old_version
133
- execute *app.stop(version: old_version), raise_on_non_zero_exit: false
134
- run_stop_hook "post-app-stop", old_version
150
+ def stop_old_versions(old_versions)
151
+ return if old_versions.empty?
152
+
153
+ old_versions.each do |replica, old_version|
154
+ run_stop_hook "pre-app-stop", old_version
155
+ execute *app(replica).stop(version: old_version), raise_on_non_zero_exit: false
156
+ run_stop_hook "post-app-stop", old_version
157
+ end
135
158
 
136
159
  execute *app.clean_up_assets if assets?
137
160
  execute *app.clean_up_error_pages if DASH.config.error_pages_path
@@ -172,15 +195,17 @@ class Dash::Cli::App::Boot
172
195
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
196
  yield
174
197
  ensure
175
- @timing.detail = format("healthy after %.1fs", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
198
+ replicas = ", #{state.count} replicas" if role.replicas.scalable?
199
+ @timing.detail = format("healthy after %.1fs%s", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, replicas)
176
200
  end
177
201
 
178
202
  def barrier_role?
179
203
  role == DASH.primary_role
180
204
  end
181
205
 
182
- def app
183
- @app ||= DASH.app(role: role, host: host)
206
+ def app(replica = 1)
207
+ @apps ||= {}
208
+ @apps[replica] ||= DASH.app(role: role, host: host, replica: replica)
184
209
  end
185
210
 
186
211
  def auditor
@@ -0,0 +1,70 @@
1
+ # What one host said about a role before a boot starts anything, parsed out of the single
2
+ # Commands::App#boot_states capture: which slots already hold the version being deployed,
3
+ # which version each slot runs now, and so how many replicas to boot.
4
+ #
5
+ # The replica count is never stored. It is the number of slots with a running container,
6
+ # kept within the role's bounds, so `dash scale set` changes it and a deploy reads it back.
7
+ class Dash::Cli::App::BootState
8
+ attr_reader :role
9
+
10
+ def initialize(role, output)
11
+ @role = role
12
+
13
+ clash, running, names, *clashes = Dash::Commands::App.split_states(output).map(&:strip)
14
+
15
+ # The whole line, never a substring: a version may contain the marker's characters.
16
+ if names&.lines&.any? { |line| line.strip == Dash::Commands::App::ACTIVE_CONTAINERS_UNREADABLE }
17
+ raise Dash::Cli::BootError, "Could not list the running containers of #{role}, not booting it"
18
+ end
19
+
20
+ @clashes = { 1 => clash.presence }.merge(clashes.each.with_index(2).to_h { |id, replica| [ replica, id.presence ] })
21
+ @running = running_by_replica(running.presence, names&.lines&.map(&:strip)&.reject(&:empty?))
22
+ end
23
+
24
+ # How many replicas this boot starts: the slots running now, never fewer than min (so a
25
+ # crash cannot shrink the role below its floor) nor more than max.
26
+ def count
27
+ role.replicas.clamp(running_count)
28
+ end
29
+
30
+ def replicas
31
+ (1..count).to_a
32
+ end
33
+
34
+ def running_count
35
+ @running.size
36
+ end
37
+
38
+ def clashing?(replica)
39
+ @clashes[replica].present?
40
+ end
41
+
42
+ def running_version(replica)
43
+ @running[replica]
44
+ end
45
+
46
+ # Slots running above the count being booted - a lowered max, or a role whose
47
+ # `replicas` key went away. Their containers are stopped with the old version.
48
+ def surplus_replicas
49
+ @running.keys.select { |replica| replica > count }.sort
50
+ end
51
+
52
+ private
53
+ # Every slot's version comes out of its container's name. Slot 1's own `--latest` read
54
+ # wins for slot 1 (it prefers the latest image, as it always has), unless it is really
55
+ # another slot's whole name: on a role that was scalable and no longer is, `--latest`
56
+ # over the role labels can return a leftover slot's container.
57
+ def running_by_replica(slot_one, names)
58
+ by_replica = Array(names).reverse.to_h { |name| [ role.replica_from_name(name), role.version_from_name(name) ] }.except(nil)
59
+
60
+ if slot_one && !foreign?(slot_one)
61
+ by_replica[1] = slot_one
62
+ end
63
+
64
+ by_replica
65
+ end
66
+
67
+ def foreign?(version)
68
+ (owner = role.replica_from_name(version)) && owner != 1
69
+ end
70
+ end
@@ -25,32 +25,51 @@ class Dash::Cli::App::RolloutBoot
25
25
 
26
26
  private
27
27
  def ensure_not_already_deployed
28
- if capture_with_info(*app.container_id_for_version(version), raise_on_non_zero_exit: false).present?
28
+ if replicas.any? { |replica| capture_with_info(*app(replica).container_id_for_version(version), raise_on_non_zero_exit: false).present? }
29
29
  raise Dash::Cli::BootError, "Version #{version} is already deployed for #{role} on #{host}, roll out a different version"
30
30
  end
31
31
  end
32
32
 
33
+ # The rollout target is as many replicas as the live version runs, so a canary split
34
+ # compares like with like.
33
35
  def start_rollout_target
34
36
  audit "Booted rollout target version #{version}"
35
- hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
36
37
 
37
38
  execute *app.ensure_env_directory
38
39
  upload! role.secrets_io(host), role.secrets_path, mode: "0600"
39
40
 
40
- execute *app.run(hostname: hostname)
41
+ endpoints = replicas.map do |replica|
42
+ hostname = "#{host.to_s[0...51].chomp(".")}-#{SecureRandom.hex(6)}"
43
+ execute *app(replica).run(hostname: hostname)
41
44
 
42
- endpoint = capture_with_info(*app.container_id_for_version(version)).strip
43
- raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
45
+ capture_with_info(*app(replica).container_id_for_version(version)).strip.tap do |endpoint|
46
+ raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
47
+ end
48
+ end
44
49
 
45
- execute *app.rollout_deploy(target: endpoint)
50
+ execute *app.rollout_deploy(targets: endpoints)
46
51
  end
47
52
 
48
53
  def stop_rollout_target
49
- execute *app.stop(version: version), raise_on_non_zero_exit: false
54
+ replicas.each do |replica|
55
+ execute *app(replica).stop(version: version), raise_on_non_zero_exit: false
56
+ end
57
+ end
58
+
59
+ # A role without replicas is one slot, and asks nothing more than it always did.
60
+ def replicas
61
+ @replicas ||=
62
+ if role.replicas.scalable?
63
+ names = capture_with_info(*app.active_containers).lines.map(&:strip)
64
+ (1..role.replicas.clamp(names.filter_map { |name| role.replica_from_name(name) }.uniq.size)).to_a
65
+ else
66
+ [ 1 ]
67
+ end
50
68
  end
51
69
 
52
- def app
53
- @app ||= DASH.app(role: role, host: host)
70
+ def app(replica = 1)
71
+ @apps ||= {}
72
+ @apps[replica] ||= DASH.app(role: role, host: host, replica: replica)
54
73
  end
55
74
 
56
75
  def audit(message)
@@ -0,0 +1,39 @@
1
+ # The dash-proxy targets of one role on one host: the running container of every slot, in
2
+ # slot order. A role without replicas has one slot, so this asks what re-registering a
3
+ # single container always asked, plus which slots run at all.
4
+ class Dash::Cli::App::RunningTargets
5
+ attr_reader :sshkit, :role, :host
6
+ delegate :capture_with_info, to: :sshkit
7
+
8
+ def initialize(sshkit, role:, host:)
9
+ @sshkit = sshkit
10
+ @role = role
11
+ @host = host
12
+ end
13
+
14
+ def container_ids(only_running: false, replicas: self.replicas)
15
+ replicas.filter_map do |replica|
16
+ app = DASH.app(role: role, host: host, replica: replica)
17
+
18
+ read = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false)
19
+
20
+ if (version = Dash::Cli::App::SlotVersion.new(sshkit, app).resolve(read))
21
+ capture_with_info(*app.container_id_for_version(version, only_running: only_running), raise_on_non_zero_exit: false).strip.presence
22
+ end
23
+ end
24
+ end
25
+
26
+ # The configured slots, plus any slot still running above them - a lowered max, or a
27
+ # role whose `replicas` went away - until the next deploy stops it.
28
+ def replicas
29
+ @replicas ||= (role.replica_numbers | running_replicas).sort
30
+ end
31
+
32
+ private
33
+ # A listing that fails raises: an empty answer would read as "no surplus slots", and the
34
+ # caller would stop or register an incomplete set.
35
+ def running_replicas
36
+ names = capture_with_info(*DASH.app(role: role, host: host).active_containers).lines.map(&:strip)
37
+ names.filter_map { |name| role.replica_from_name(name) }
38
+ end
39
+ end
@@ -0,0 +1,29 @@
1
+ # The version one replica slot runs, from a `current_running_version` read that may have
2
+ # answered for another slot. A role without replicas reads slot 1 over the role labels alone,
3
+ # so while slots left over from when it had them still run, `--latest` can return one of
4
+ # theirs - an unstripped name like `app-web.2-123`. Taking that as slot 1's version would
5
+ # make the live container look stale, or drop it from the proxy targets. Only then is the
6
+ # role's running containers read, once more, to find the slot's own.
7
+ class Dash::Cli::App::SlotVersion
8
+ attr_reader :sshkit, :app
9
+ delegate :role, :replica, to: :app
10
+ delegate :capture_with_info, to: :sshkit
11
+
12
+ def initialize(sshkit, app)
13
+ @sshkit = sshkit
14
+ @app = app
15
+ end
16
+
17
+ def resolve(read)
18
+ version = read.to_s.strip.presence
19
+ return version unless foreign?(version)
20
+
21
+ names = capture_with_info(*app.active_containers).lines.map(&:strip)
22
+ names.filter_map { |name| role.version_from_name(name) if role.replica_from_name(name) == replica }.first
23
+ end
24
+
25
+ # A listed "version" that is really another slot's whole container name.
26
+ def foreign?(version)
27
+ (owner = version && role.replica_from_name(version)) && owner != replica
28
+ end
29
+ end
data/lib/dash/cli/app.rb CHANGED
@@ -55,15 +55,23 @@ class Dash::Cli::App < Dash::Cli::Base
55
55
  on_roles(DASH.roles, hosts: DASH.app_hosts, parallel: DASH.config.parallel_roles?, rolling: true) do |host, role|
56
56
  app = DASH.app(role: role, host: host)
57
57
  execute *DASH.auditor.record("Started app version #{DASH.config.version}"), verbosity: :debug
58
- execute *app.start, raise_on_non_zero_exit: false
58
+
59
+ # Every slot's container of the version, up to max: a stopped app does not know
60
+ # how many replicas it ran, and a slot that never existed just fails to start.
61
+ role.replica_numbers.each do |replica|
62
+ execute *DASH.app(role: role, host: host, replica: replica).start, raise_on_non_zero_exit: false
63
+ end
59
64
 
60
65
  if role.running_proxy?
61
- version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
62
- endpoint = capture_with_info(*app.container_id_for_version(version)).strip
63
- raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoint.empty?
66
+ endpoints = Dash::Cli::App::RunningTargets.new(self, role: role, host: host).container_ids
67
+ raise Dash::Cli::BootError, "Failed to get endpoint for #{role} on #{host}, did the container boot?" if endpoints.empty?
68
+
69
+ if endpoints.size < role.replicas.min
70
+ raise Dash::Cli::BootError, "Found #{endpoints.size} of the #{role.replicas.min} replicas of #{role} on #{host}, did every container start?"
71
+ end
64
72
 
65
73
  cli.run_hook "pre-proxy-deploy", hosts: host.to_s, role: role.name
66
- execute *app.deploy(target: endpoint)
74
+ execute *app.deploy(targets: endpoints)
67
75
  cli.run_hook "post-proxy-deploy", hosts: host.to_s, role: role.name
68
76
  end
69
77
  end
@@ -77,15 +85,15 @@ class Dash::Cli::App < Dash::Cli::Base
77
85
  app = DASH.app(role: role, host: host)
78
86
  execute *DASH.auditor.record("Stopped app", role: role), verbosity: :debug
79
87
 
80
- if role.running_proxy?
81
- version = capture_with_info(*app.current_running_version, raise_on_non_zero_exit: false).strip
82
- endpoint = capture_with_info(*app.container_id_for_version(version)).strip
83
- if endpoint.present?
84
- execute *app.remove, raise_on_non_zero_exit: false
85
- end
88
+ targets = Dash::Cli::App::RunningTargets.new(self, role: role, host: host)
89
+
90
+ if role.running_proxy? && targets.container_ids.any?
91
+ execute *app.remove, raise_on_non_zero_exit: false
86
92
  end
87
93
 
88
- execute *app.stop, raise_on_non_zero_exit: false
94
+ targets.replicas.each do |replica|
95
+ execute *DASH.app(role: role, host: host, replica: replica).stop, raise_on_non_zero_exit: false
96
+ end
89
97
  end
90
98
  end
91
99
  end
@@ -102,6 +110,7 @@ class Dash::Cli::App < Dash::Cli::Base
102
110
  desc "exec [CMD...]", "Execute a custom command on servers within the app container (use --help to show options)"
103
111
  option :interactive, aliases: "-i", type: :boolean, default: false, desc: "Execute command over ssh for an interactive shell (use for console/bash)"
104
112
  option :reuse, type: :boolean, default: false, desc: "Reuse currently running container instead of starting a new one"
113
+ option :replica, type: :numeric, default: 1, desc: "Replica slot whose container --reuse runs in"
105
114
  option :env, aliases: "-e", type: :hash, desc: "Set environment variables for the command"
106
115
  option :detach, type: :boolean, default: false, desc: "Execute command in a detached container"
107
116
  option :raw, type: :boolean, default: false, desc: "Output raw, unmodified stdout"
@@ -127,12 +136,17 @@ class Dash::Cli::App < Dash::Cli::Base
127
136
  env = options[:env]
128
137
  detach = options[:detach]
129
138
  quiet = options[:quiet]
139
+ replica = options[:replica]
130
140
  case
131
141
  when options[:interactive] && options[:reuse]
142
+ unless DASH.primary_role.replica_numbers.include?(replica)
143
+ raise ArgumentError, "#{DASH.primary_role} has no replica #{replica}, it runs at most #{DASH.primary_role.replicas.max}"
144
+ end
145
+
132
146
  say "Get current version of running container...", :magenta unless options[:version]
133
147
  using_version(options[:version] || current_running_version) do |version|
134
148
  say "Launching interactive command with version #{version} via SSH from existing container on #{DASH.primary_host}...", :magenta
135
- run_locally { exec DASH.app(role: DASH.primary_role, host: DASH.primary_host).execute_in_existing_container_over_ssh(cmd, env: env) }
149
+ run_locally { exec DASH.app(role: DASH.primary_role, host: DASH.primary_host, replica: replica).execute_in_existing_container_over_ssh(cmd, env: env) }
136
150
  end
137
151
 
138
152
  when options[:interactive]
@@ -151,8 +165,10 @@ class Dash::Cli::App < Dash::Cli::Base
151
165
  say "Launching command with version #{version} from existing container...", :magenta
152
166
 
153
167
  on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
168
+ next unless role.replica_numbers.include?(replica)
169
+
154
170
  execute *DASH.auditor.record("Executed cmd '#{cmd}' on app version #{version}", role: role), verbosity: :debug
155
- puts_by_host host, capture_with_info(*DASH.app(role: role, host: host).execute_in_existing_container(cmd, env: env), strip: !raw), quiet: quiet, raw: raw
171
+ puts_by_host host, capture_with_info(*DASH.app(role: role, host: host, replica: replica).execute_in_existing_container(cmd, env: env), strip: !raw), quiet: quiet, raw: raw
156
172
  end
157
173
  end
158
174
 
@@ -172,11 +188,23 @@ class Dash::Cli::App < Dash::Cli::Base
172
188
  end
173
189
 
174
190
  desc "containers", "Show app containers on servers"
191
+ option :json, type: :boolean, default: false, desc: "Print every container per host as JSON (role, replica, version, state, health)"
175
192
  def containers
193
+ return puts_json { Dash::Diagnostics::Containers.new(accessories: []).to_h } if options[:json]
194
+
176
195
  quiet = options[:quiet]
177
196
  on(DASH.app_hosts) { |host| puts_by_host host, capture_with_info(*DASH.app.list_containers), quiet: quiet }
178
197
  end
179
198
 
199
+ desc "stats", "Show CPU, memory, network and block I/O of the app containers (a one-second sample per host)"
200
+ option :json, type: :boolean, default: false, desc: "Print the stats per host as JSON"
201
+ def stats
202
+ return puts_json { Dash::Diagnostics::ContainerStats.new(accessories: []).to_h } if options[:json]
203
+
204
+ pre_connect_if_required
205
+ Dash::Diagnostics::ContainerStats.new(accessories: []).to_h[:hosts].each { |host| puts Dash::Cli::StatsTable.lines("App", host) }
206
+ end
207
+
180
208
  desc "stale_containers", "Detect app stale containers"
181
209
  option :stop, aliases: "-s", type: :boolean, default: false, desc: "Stop the stale containers found"
182
210
  def stale_containers
@@ -185,16 +213,23 @@ class Dash::Cli::App < Dash::Cli::Base
185
213
 
186
214
  with_lock_if_stopping do
187
215
  on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
188
- app = DASH.app(role: role, host: host)
189
- listed, running = Dash::Commands::App.split_state(capture_with_info(*app.stale_state, raise_on_non_zero_exit: false))
190
- versions = listed.strip.split("\n") - [ running.strip ]
191
-
192
- versions.each do |version|
193
- if stop
194
- puts_by_host host, "Stopping stale container for role #{role} with version #{version}", quiet: quiet
195
- execute *app.stop(version: version), raise_on_non_zero_exit: false
196
- else
197
- puts_by_host host, "Detected stale container for role #{role} with version #{version} (use `dash app stale_containers --stop` to stop)", quiet: quiet
216
+ role.replica_numbers.each do |replica|
217
+ app = DASH.app(role: role, host: host, replica: replica)
218
+ listed, running = Dash::Commands::App.split_state(capture_with_info(*app.stale_state, raise_on_non_zero_exit: false))
219
+ slot_version = Dash::Cli::App::SlotVersion.new(self, app)
220
+ running = slot_version.resolve(running)
221
+ next unless running # nothing of this slot runs, so nothing is stale relative to it
222
+
223
+ versions = listed.strip.split("\n").reject { |version| slot_version.foreign?(version) } - [ running ]
224
+ name = replica == 1 ? "role #{role}" : "role #{role} replica #{replica}"
225
+
226
+ versions.each do |version|
227
+ if stop
228
+ puts_by_host host, "Stopping stale container for #{name} with version #{version}", quiet: quiet
229
+ execute *app.stop(version: version), raise_on_non_zero_exit: false
230
+ else
231
+ puts_by_host host, "Detected stale container for #{name} with version #{version} (use `dash app stale_containers --stop` to stop)", quiet: quiet
232
+ end
198
233
  end
199
234
  end
200
235
  end
@@ -215,6 +250,7 @@ class Dash::Cli::App < Dash::Cli::Base
215
250
  option :follow, aliases: "-f", desc: "Follow log on primary server (or specific host set by --hosts)"
216
251
  option :skip_timestamps, type: :boolean, aliases: "-T", desc: "Skip appending timestamps to logging output"
217
252
  option :container_id, desc: "Docker container ID to fetch logs"
253
+ option :replica, type: :numeric, desc: "Replica slot to show (default: every replica, or replica 1 with --follow)"
218
254
  def logs
219
255
  # FIXME: Catch when app containers aren't running
220
256
 
@@ -225,8 +261,15 @@ class Dash::Cli::App < Dash::Cli::Base
225
261
  timestamps = !options[:skip_timestamps]
226
262
  quiet = options[:quiet]
227
263
 
264
+ replica = options[:replica]
265
+
228
266
  if options[:follow]
229
267
  lines = options[:lines].presence || ((since || grep) ? nil : 10) # Default to 10 lines if since or grep isn't set
268
+ follow_role = DASH.specific_roles&.first || DASH.primary_role
269
+
270
+ if replica && !follow_role.replica_numbers.include?(replica)
271
+ raise ArgumentError, "#{follow_role} has no replica #{replica}, it runs at most #{follow_role.replicas.max}"
272
+ end
230
273
 
231
274
  run_locally do
232
275
  info "Following logs on #{DASH.primary_host}..."
@@ -234,7 +277,7 @@ class Dash::Cli::App < Dash::Cli::Base
234
277
  DASH.specific_roles ||= [ DASH.primary_role.name ]
235
278
  role = DASH.roles_on(DASH.primary_host).first
236
279
 
237
- app = DASH.app(role: role, host: host)
280
+ app = DASH.app(role: role, host: host, replica: replica || 1)
238
281
  info app.follow_logs(host: DASH.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
239
282
  exec app.follow_logs(host: DASH.primary_host, container_id: container_id, timestamps: timestamps, lines: lines, grep: grep, grep_options: grep_options)
240
283
  end
@@ -242,10 +285,19 @@ class Dash::Cli::App < Dash::Cli::Base
242
285
  lines = options[:lines].presence || ((since || grep) ? nil : 100) # Default to 100 lines if since or grep isn't set
243
286
 
244
287
  on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
245
- begin
246
- puts_by_host host, capture_with_info(*DASH.app(role: role, host: host).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)), quiet: quiet
247
- rescue SSHKit::Command::Failed
248
- puts_by_host host, "Nothing found", quiet: quiet
288
+ # A --container-id names one container, whatever the slot.
289
+ slots = replica || container_id ? [ replica || 1 ] & role.replica_numbers : role.replica_numbers
290
+
291
+ slots.each do |slot|
292
+ # Each replica's lines under a header of their own, so they are not mistaken for one stream.
293
+ # A --container-id names a container whose slot is unknown, so claim none.
294
+ type = role.replicas.scalable? && (replica || !container_id) ? "App (replica #{slot})" : "App"
295
+
296
+ begin
297
+ puts_by_host host, capture_with_info(*DASH.app(role: role, host: host, replica: slot).logs(container_id: container_id, timestamps: timestamps, since: since, lines: lines, grep: grep, grep_options: grep_options)), type: type, quiet: quiet
298
+ rescue SSHKit::Command::Failed
299
+ puts_by_host host, "Nothing found", type: type, quiet: quiet
300
+ end
249
301
  end
250
302
  end
251
303
  end
@@ -301,6 +353,11 @@ class Dash::Cli::App < Dash::Cli::Base
301
353
  on_roles(DASH.roles, hosts: DASH.app_hosts) do |host, role|
302
354
  execute *DASH.auditor.record("Removed app container with version #{version}", role: role), verbosity: :debug
303
355
  execute *DASH.app(role: role, host: host).remove_container(version: version)
356
+
357
+ # A slot that never ran the version has nothing to remove.
358
+ role.replica_numbers.drop(1).each do |replica|
359
+ execute *DASH.app(role: role, host: host, replica: replica).remove_container(version: version), raise_on_non_zero_exit: false
360
+ end
304
361
  end
305
362
  end
306
363
  end
@@ -408,10 +465,10 @@ class Dash::Cli::App < Dash::Cli::Base
408
465
  def current_running_version(host: DASH.primary_host)
409
466
  version = nil
410
467
  on(host) do
411
- role = DASH.roles_on(host).first
412
- version = capture_with_info(*DASH.app(role: role, host: host).current_running_version).strip
468
+ app = DASH.app(role: DASH.roles_on(host).first, host: host)
469
+ version = Dash::Cli::App::SlotVersion.new(self, app).resolve(capture_with_info(*app.current_running_version))
413
470
  end
414
- version.presence
471
+ version
415
472
  end
416
473
 
417
474
  def version_or_latest