hotcell-client 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1bd5fbf6b1ac7ad757c4b8215f377b51322beb36994948d1cd384f71b1122e4f
4
- data.tar.gz: d9a8dd19d861d0a67080a749dc86969da12d1604a23cc6b6001b516356ed6f33
3
+ metadata.gz: acb581746375493247ece1d1962ef0f534cbca23e97851c1539f6b75e0eaa495
4
+ data.tar.gz: 621d326e537edb6bdd1125cbb229e20588a3312b7f63bbba56f039ff9c42bf45
5
5
  SHA512:
6
- metadata.gz: dbf9f630feb6d583574f38af75752caed8c54266a05a06b2e4559d53763fba3e173c2fd8a4b8d6c2362da0fed3b0fe538cbf05874c66334f60f44361cfd598d0
7
- data.tar.gz: 94a49d9fb871cdeba4792d4b52a855635048b6fc7aac633d1f58960a4730c24b167ac4afd3a9dfe1cef7beb8a06e6dc1f5b944432f2b774bb2179f3a11e53e4f
6
+ metadata.gz: 9b94270d6241921e1f8b9793e1fafa3dd3c3928d21a68ff14b2012c50eb2fc612007b31c072462ac1b90adb738c61bbf1ab64ee3827a3c31825a20d1718f3e4c
7
+ data.tar.gz: 536992cc18c2777bb5e7ae31e0b7445ebec24987b3d691c8a50d9e8b251d0b00283fdf93cfd94b58c2a6a3f224833f7c3ab4462950db9218073779c3142245d1
data/lib/hot_cell/cell.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tmpdir"
4
+
3
5
  module HotCell
4
6
  # One registered cell: where its sockets are, how long this application will wait, and which of its own
5
7
  # exception classes to raise for each side of the permanent split.
@@ -15,7 +17,7 @@ module HotCell
15
17
  # number would give the call whose job is to say "this cell is down" the patience of a video transcode.
16
18
  #
17
19
  # Both bound the answer rather than the whole call: connecting is not covered. Transport::Socket says
18
- # why that is left alone.
20
+ # why that does not hang.
19
21
  def initialize(name, dir: nil, timeout: 30, control_timeout: 5,
20
22
  permanent: PermanentFailure, transient: TransientFailure,
21
23
  on_contract_skew: nil, transport: Transport::Socket.new)
@@ -72,7 +74,7 @@ module HotCell
72
74
  # deployment rather than a broken one, and an application that refuses to start because its thumbnail
73
75
  # cell is restarting is worse than one that serves placeholders. So this warns and carries on.
74
76
  #
75
- # Nor when a cell answers something this client cannot read. The two warnings below reach into the
77
+ # Nor when a cell answers something this client cannot read. The warnings below reach into the
76
78
  # description without checking types, so a cell that sends the wrong ones raises here — and the README
77
79
  # calls `describe_cells` from `after_initialize`, where that is not a failed check but an application
78
80
  # that does not boot. The process that wrote the description is the one that runs untrusted content.
@@ -86,6 +88,7 @@ module HotCell
86
88
  response.result.tap do |described|
87
89
  warn_about_timeout described
88
90
  warn_about_group_skew described
91
+ warn_about_version_skew described
89
92
  end
90
93
  rescue StandardError => error
91
94
  HotCell.logger.warn "hotcell #{name}: this cell's description could not be read and is being " \
@@ -98,7 +101,65 @@ module HotCell
98
101
  enabled? ? control(METRICS) : nil
99
102
  end
100
103
 
104
+ # The control socket takes no worker but carries no descriptor, so its checks pass on a cell whose work
105
+ # socket this application cannot use. Only the round trips see that, and echo alone passes a cell with
106
+ # the wrong group. `control` rather than `describe`, whose boot warnings would repeat on every poll.
107
+ def diagnose(work: false)
108
+ checks = { describe: check { answered control(DESCRIBE) },
109
+ metrics: check { answered control(METRICS) } }
110
+ return checks unless work
111
+
112
+ checks.merge echo: check { round_trip "health.echo" }, reopen: check { round_trip "health.reopen" }
113
+ end
114
+
101
115
  private
116
+ class CheckFailed < StandardError; end
117
+
118
+ def check
119
+ return { ok: false, error: "no socket directory, so this cell is off" } unless enabled?
120
+
121
+ { ok: true, result: yield }
122
+ rescue CheckFailed => error
123
+ { ok: false, error: error.message }
124
+ rescue StandardError => error
125
+ { ok: false, error: "#{error.class}: #{Failure.one_line error.message}" }
126
+ end
127
+
128
+ def answered(response)
129
+ raise CheckFailed, response.failure.to_s unless response.ok?
130
+
131
+ response.result
132
+ end
133
+
134
+ def round_trip(operation_name, message = "hotcell")
135
+ client = client_for(operation_name)
136
+
137
+ Dir.mktmpdir "hotcell-diagnose" do |directory|
138
+ source = File.join(directory, "in")
139
+ destination = File.join(directory, "out")
140
+ File.write source, message
141
+
142
+ result = File.open(source, "rb") do |input|
143
+ File.open(destination, "wb") { |output| client.perform_in_hotcell input, output }
144
+ end
145
+
146
+ returned = File.binread(destination)
147
+ raise CheckFailed, "the cell returned other bytes than it was sent" unless returned == message
148
+ raise CheckFailed, "the input was staged, so this did not cross a descriptor" if result[:staged]
149
+
150
+ result
151
+ end
152
+ end
153
+
154
+ def client_for(operation_name)
155
+ cell = self
156
+
157
+ Class.new(Client) do
158
+ define_singleton_method(:cell) { cell }
159
+ operation operation_name
160
+ end
161
+ end
162
+
102
163
  # A cell's sockets are `0660`, so the group that lets a worker re-open a descriptor is also the group
103
164
  # that admits a caller. EACCES therefore means one thing, and it is worth saying rather than leaving an
104
165
  # operator to read "could not describe the cell" as "the cell is down". Every other failure reads that
@@ -162,6 +223,14 @@ module HotCell
162
223
  "every operation that gives a tool a filename will fail with EACCES."
163
224
  end
164
225
 
226
+ def warn_about_version_skew(described)
227
+ reported = described[:server_version]
228
+ return if reported == Client::VERSION
229
+
230
+ HotCell.logger.warn "hotcell #{name}: this client is #{Client::VERSION} and the cell reports " \
231
+ "hotcell-server #{Failure.one_line reported.inspect}."
232
+ end
233
+
165
234
  # A `nil` timeout reaches `Transport::Socket#receive` as `deadline: nil`, and reading with no deadline
166
235
  # blocks: a cell that accepts the connection and then never answers holds this caller for good. At
167
236
  # boot that is an application that never finishes starting, with no exception and nothing to rescue.
@@ -79,6 +79,18 @@ module HotCell
79
79
  "unset HotCell.group where both sides run as one user."
80
80
  end
81
81
 
82
+ def diagnose(work: false)
83
+ Diagnosis.new(cells.values, work: work)
84
+ end
85
+
86
+ # Read when HotCell::DiagnosticsController loads, so set it in an initializer. A development reload of
87
+ # the parent does not reach it.
88
+ attr_writer :diagnostics_controller_parent
89
+
90
+ def diagnostics_controller_parent
91
+ @diagnostics_controller_parent || "ActionController::Base"
92
+ end
93
+
82
94
  # Test support. Named apart from the server gem's own reset, because both gems open this module and a
83
95
  # shared name would mean whichever loaded last silently won.
84
96
  def reset_registrations!
@@ -3,6 +3,6 @@
3
3
  module HotCell
4
4
  # A class and not a module: this file loads before hot_cell/client.rb opens the same name.
5
5
  class Client
6
- VERSION = "0.5.0"
6
+ VERSION = "0.6.0"
7
7
  end
8
8
  end
@@ -13,9 +13,16 @@ require "hot_cell/failures"
13
13
  require "hot_cell/transport"
14
14
  require "hot_cell/cell"
15
15
  require "hot_cell/cells"
16
+ require "hot_cell/diagnosis"
16
17
 
17
18
  require "hot_cell/railtie" if defined?(::Rails::Railtie)
18
19
 
20
+ module HotCell
21
+ # Loaded when a route first names them, so an application without Action Pack never requires it.
22
+ autoload :HealthController, "hot_cell/health_controller"
23
+ autoload :DiagnosticsController, "hot_cell/diagnostics_controller"
24
+ end
25
+
19
26
  module HotCell
20
27
  # The application side. A client class names the cell that serves it, and the call carries the same
21
28
  # three things an operation receives on the other side — with the payload Hash arriving there as
@@ -96,7 +103,7 @@ module HotCell
96
103
  line = request_line(inputs, outputs, payload)
97
104
 
98
105
  response = nil
99
- ActiveSupport::Notifications.instrument "perform.hot_cell" do |event|
106
+ ActiveSupport::Notifications.instrument "perform.hot_cell", operation: self.class.operation, cell: cell.name do |event|
100
107
  response = verify_output(cell.transport.call(cell, line, descriptors), outputs)
101
108
  publish event, cell, response, inputs, outputs
102
109
  end
@@ -171,8 +178,6 @@ module HotCell
171
178
  def publish(event, cell, response, inputs, outputs)
172
179
  failure = response.failure
173
180
 
174
- event[:operation] = self.class.operation
175
- event[:cell] = cell.name
176
181
  event[:code] = failure&.code
177
182
  event[:cause] = failure&.cause
178
183
  event[:signal] = failure&.signal
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "time"
5
+
6
+ module HotCell
7
+ class Diagnosis
8
+ attr_reader :cells
9
+
10
+ def initialize(cells, work:)
11
+ @at = Time.now.utc.iso8601(3)
12
+ @host = Socket.gethostname
13
+ @cells = cells.to_h { |cell| [ cell.name, cell.diagnose(work: work) ] }
14
+ end
15
+
16
+ # `cells.any?`, so an application that never registered a cell does not answer OK.
17
+ def healthy?
18
+ cells.any? && cells.each_value.all? { |checks| checks.each_value.all? { |check| check[:ok] } }
19
+ end
20
+
21
+ # `host` because sockets are host-local, and a poll through a load balancer lands wherever it lands.
22
+ def as_json(*)
23
+ { at: @at, host: @host, healthy: healthy?, cells: cells }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+
5
+ module HotCell
6
+ # Each request takes a worker per round trip, so put this behind authentication.
7
+ class DiagnosticsController < HotCell.diagnostics_controller_parent.constantize
8
+ def show
9
+ diagnosis = HotCell.diagnose(work: true)
10
+
11
+ render json: diagnosis, status: diagnosis.healthy? ? :ok : :service_unavailable
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_controller"
4
+
5
+ module HotCell
6
+ # Safe to leave unauthenticated: it asks the control socket only, so a poll takes no worker, and it answers
7
+ # nothing but OK or FAIL.
8
+ class HealthController < ActionController::Base
9
+ def show
10
+ healthy = HotCell.diagnose.healthy?
11
+
12
+ render plain: healthy ? "OK" : "FAIL", status: healthy ? :ok : :service_unavailable
13
+ end
14
+ end
15
+ end
@@ -5,8 +5,7 @@
5
5
 
6
6
  source "https://rubygems.org"
7
7
 
8
- # Pinned to the client that installed it: the two are halves of one wire contract, and a skew between
9
- # them answers `protocol` on every request.
8
+ # Keep this at the application's hotcell-client version; HotCell.describe_cells warns at boot otherwise.
10
9
  gem "hotcell-server", "<%= HotCell::Client::VERSION %>"
11
10
 
12
11
  # Gems your operations need go here. For example:
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # All three, in this order, on Rails main: log_subscriber needs ColorizeLogging, which only the top-level file
6
+ # autoloads, and loading it before deprecation warns of a circular require.
7
+ require "active_support"
8
+ require "active_support/deprecation"
9
+ require "active_support/log_subscriber"
10
+
11
+ module HotCell
12
+ # One line per call, success or failure, from the `perform.hot_cell` event. The railtie attaches it; an
13
+ # application without Rails calls `HotCell::LogSubscriber.attach_to :hot_cell` and sets
14
+ # `ActiveSupport::LogSubscriber.logger`.
15
+ #
16
+ # Two clocks on purpose: `perform_ms` is what the cell measured inside the worker and `duration_ms` is what
17
+ # this process waited, and their difference is the queue and the socket.
18
+ #
19
+ # `stderr` is text a tool wrote while reading a hostile file. It goes into the JSON and nowhere else, so
20
+ # its newlines arrive escaped rather than as forged log lines. `ascii_only` because JSON leaves U+2028 and
21
+ # its kin raw, and a log viewer may break a line on them.
22
+ class LogSubscriber < ActiveSupport::LogSubscriber
23
+ def perform(event)
24
+ payload = event.payload
25
+ duration_ms = event.duration.round(1)
26
+
27
+ info do
28
+ " HotCell (#{duration_ms}ms) " + JSON.generate({
29
+ cell: payload[:cell],
30
+ operation: payload[:operation],
31
+ code: payload[:code] || ("ok" unless payload[:exception]),
32
+ exception: payload[:exception]&.first,
33
+ cause: payload[:cause],
34
+ perform_ms: payload[:perform_ms],
35
+ duration_ms: duration_ms,
36
+ bytes_in: payload[:bytes_in],
37
+ bytes_out: payload[:bytes_out],
38
+ stderr: payload[:stderr],
39
+ }.compact, ascii_only: true)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -1,9 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "hot_cell/log_subscriber"
4
+
3
5
  module HotCell
4
- # Loads the hotcell:install task into a Rails application. The gem works without Rails, so this file
5
- # is required only when Rails::Railtie is already defined.
6
+ # Loads the hotcell:install task into a Rails application and logs each call. The gem works without
7
+ # Rails, so this file is required only when Rails::Railtie is already defined.
6
8
  class Railtie < ::Rails::Railtie
9
+ initializer "hot_cell.log_subscriber" do
10
+ HotCell::LogSubscriber.attach_to :hot_cell
11
+ end
12
+
7
13
  rake_tasks do
8
14
  load File.expand_path("tasks/hotcell.rake", __dir__)
9
15
  end
@@ -10,20 +10,19 @@ module HotCell
10
10
  # Retry belongs in the job layer, which is the whole reason the transient class exists.
11
11
  module Transport
12
12
  class Socket
13
- # **Accepted risk.** `timeout` covers the answer and not the connection. `UNIXSocket.new` blocks, and
14
- # `connect` to a Unix socket blocks while the listener's backlog is full — a supervisor that is alive
15
- # and no longer calling `accept`. A caller can be held there with no bound, on a path an application
16
- # may be calling from a web request.
13
+ # `timeout` covers the answer and not the connection. On Linux a blocking `connect` to a Unix socket waits
14
+ # in the kernel while the listener's backlog is full, which is where a supervisor that stops calling
15
+ # `accept` leaves it. `UNIXSocket.new` does not wait: Ruby opens every socket non-blocking and takes the
16
+ # kernel's EAGAIN for a connection in progress, so it returns a socket that never connected, the send
17
+ # fails with ENOTCONN, and the caller gets `unavailable` at once. That is Ruby's behavior rather than
18
+ # this gem's, so ControlTimeoutTest holds it.
17
19
  #
18
- # The premise is that the state is nearly unreachable rather than tolerable. A supervisor that dies
19
- # gives ECONNREFUSED, not a hang, so this needs one that lives and stops accepting — and the loop is
20
- # built to make that not happen: `Log#emit` writes non-blocking so a stalled container log pipe cannot
21
- # park it, and a recursive delete is renamed out of the loop rather than performed inside it. Bounding
22
- # it means `connect_nonblock` plus `wait_writable` against the deadline `receive` already builds, which
23
- # is worth doing the day this is observed and not before.
20
+ # If Ruby ever starts waiting, `connect_nonblock` plus `wait_writable` against a deadline would bound the
21
+ # call only by spinning: on Linux an unconnected Unix socket is always writable, so it retries EAGAIN until
22
+ # the deadline.
24
23
  def call(cell, line, descriptors, socket: cell.work_socket, timeout: cell.timeout)
25
24
  connection = Connection.new(UNIXSocket.new(socket))
26
- connection.send_message line, descriptors: descriptors
25
+ deliver connection, line, descriptors
27
26
  receive connection, timeout
28
27
  rescue SystemCallError, IOError => error
29
28
  # A socket that does not exist, a cell that is restarting, an accessory not yet booted. These are
@@ -36,6 +35,16 @@ module HotCell
36
35
  end
37
36
 
38
37
  private
38
+ # A full cell writes its answer and closes the connection without reading the request. The send can
39
+ # then fail while the answer is already waiting on the socket. So this ignores the failed send, and
40
+ # `receive` reads the answer. If the peer closed without an answer, `receive` reports that the
41
+ # supervisor is gone.
42
+ def deliver(connection, line, descriptors)
43
+ connection.send_message line, descriptors: descriptors
44
+ rescue Errno::EPIPE, Errno::ECONNRESET
45
+ nil
46
+ end
47
+
39
48
  # One absolute deadline across the whole response, not a wait for the first byte. Waiting for
40
49
  # readability and then calling a blocking read bounded nothing: a peer that sent one byte inside the
41
50
  # timeout and then stopped held this caller until the cell's own deadline, and a peer that never
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotcell-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.5.0
18
+ version: 0.6.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.5.0
25
+ version: 0.6.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: activesupport
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -53,11 +53,15 @@ files:
53
53
  - lib/hot_cell/cells.rb
54
54
  - lib/hot_cell/client.rb
55
55
  - lib/hot_cell/client/version.rb
56
+ - lib/hot_cell/diagnosis.rb
57
+ - lib/hot_cell/diagnostics_controller.rb
56
58
  - lib/hot_cell/failures.rb
59
+ - lib/hot_cell/health_controller.rb
57
60
  - lib/hot_cell/install.rb
58
61
  - lib/hot_cell/install/Dockerfile.tt
59
62
  - lib/hot_cell/install/Gemfile.tt
60
63
  - lib/hot_cell/install/config.rb.tt
64
+ - lib/hot_cell/log_subscriber.rb
61
65
  - lib/hot_cell/railtie.rb
62
66
  - lib/hot_cell/tasks/hotcell.rake
63
67
  - lib/hot_cell/transport.rb
@@ -67,8 +71,8 @@ licenses:
67
71
  - MIT
68
72
  metadata:
69
73
  homepage_uri: https://github.com/basecamp/hotcell
70
- source_code_uri: https://github.com/basecamp/hotcell/tree/v0.5.0/hotcell-client
71
- changelog_uri: https://github.com/basecamp/hotcell/blob/v0.5.0/CHANGELOG.md
74
+ source_code_uri: https://github.com/basecamp/hotcell/tree/v0.6.0/hotcell-client
75
+ changelog_uri: https://github.com/basecamp/hotcell/blob/v0.6.0/CHANGELOG.md
72
76
  bug_tracker_uri: https://github.com/basecamp/hotcell/issues
73
77
  rubygems_mfa_required: 'true'
74
78
  rdoc_options: []
@@ -85,7 +89,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
85
89
  - !ruby/object:Gem::Version
86
90
  version: '0'
87
91
  requirements: []
88
- rubygems_version: 4.0.16
92
+ rubygems_version: 4.0.6
89
93
  specification_version: 4
90
94
  summary: Call a HotCell from an application.
91
95
  test_files: []