lepus-saga 0.3.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 (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +237 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +337 -0
  7. data/Rakefile +8 -0
  8. data/docs/brand/BRAND.md +67 -0
  9. data/docs/brand/apple-touch-icon.png +0 -0
  10. data/docs/brand/favicon-16.png +0 -0
  11. data/docs/brand/favicon-32.png +0 -0
  12. data/docs/brand/favicon.ico +0 -0
  13. data/docs/brand/favicon.svg +1 -0
  14. data/docs/brand/icon-512.png +0 -0
  15. data/docs/brand/lockup-lepus-mono.svg +1 -0
  16. data/docs/brand/lockup-lepus-on-dark.svg +1 -0
  17. data/docs/brand/lockup-lepus-on-light.svg +1 -0
  18. data/docs/brand/lockup-lepus-saga-mono.svg +1 -0
  19. data/docs/brand/lockup-lepus-saga-on-dark.svg +1 -0
  20. data/docs/brand/lockup-lepus-saga-on-light.svg +1 -0
  21. data/docs/brand/lockup-lepus-saga.svg +1 -0
  22. data/docs/brand/lockup-lepus.svg +1 -0
  23. data/docs/brand/mark-mono.svg +1 -0
  24. data/docs/brand/mark-on-dark.svg +1 -0
  25. data/docs/brand/mark-on-light.svg +1 -0
  26. data/docs/brand/mark-small-mono.svg +1 -0
  27. data/docs/brand/mark-small-on-dark.svg +1 -0
  28. data/docs/brand/mark-small-on-light.svg +1 -0
  29. data/docs/brand/mark-small.svg +1 -0
  30. data/docs/brand/mark.svg +1 -0
  31. data/lib/lepus/saga/configuration.rb +48 -0
  32. data/lib/lepus/saga/control.rb +27 -0
  33. data/lib/lepus/saga/definition.rb +123 -0
  34. data/lib/lepus/saga/execution_behaviour.rb +58 -0
  35. data/lib/lepus/saga/executor.rb +282 -0
  36. data/lib/lepus/saga/executor_consumer.rb +38 -0
  37. data/lib/lepus/saga/instrumentation.rb +41 -0
  38. data/lib/lepus/saga/log_subscriber.rb +42 -0
  39. data/lib/lepus/saga/queue_depth.rb +28 -0
  40. data/lib/lepus/saga/resume_stuck_job.rb +19 -0
  41. data/lib/lepus/saga/retry_policy.rb +19 -0
  42. data/lib/lepus/saga/rspec/engine_examples.rb +635 -0
  43. data/lib/lepus/saga/rspec/helpers.rb +22 -0
  44. data/lib/lepus/saga/rspec/query_examples.rb +378 -0
  45. data/lib/lepus/saga/rspec/store_examples.rb +514 -0
  46. data/lib/lepus/saga/rspec.rb +6 -0
  47. data/lib/lepus/saga/step.rb +9 -0
  48. data/lib/lepus/saga/step_attempt_behaviour.rb +35 -0
  49. data/lib/lepus/saga/step_producer.rb +37 -0
  50. data/lib/lepus/saga/stores/base.rb +305 -0
  51. data/lib/lepus/saga/stores/memory.rb +375 -0
  52. data/lib/lepus/saga/stores.rb +40 -0
  53. data/lib/lepus/saga/testing.rb +35 -0
  54. data/lib/lepus/saga/version.rb +7 -0
  55. data/lib/lepus/saga/web/api.rb +326 -0
  56. data/lib/lepus/saga/web/configuration.rb +49 -0
  57. data/lib/lepus/saga/web/guard.rb +38 -0
  58. data/lib/lepus/saga/web/serializer.rb +59 -0
  59. data/lib/lepus/saga/web/static.rb +108 -0
  60. data/lib/lepus/saga/web.rb +61 -0
  61. data/lib/lepus/saga.rb +212 -0
  62. data/lib/lepus-saga.rb +3 -0
  63. data/mise.toml +2 -0
  64. data/web/assets/app.css +913 -0
  65. data/web/assets/apple-touch-icon.png +0 -0
  66. data/web/assets/favicon-32.png +0 -0
  67. data/web/assets/favicon.svg +1 -0
  68. data/web/assets/fonts/overpass/OFL.txt +93 -0
  69. data/web/assets/fonts/overpass/overpass-latin-wght.woff2 +0 -0
  70. data/web/assets/js/api.js +50 -0
  71. data/web/assets/js/dom.js +84 -0
  72. data/web/assets/js/format.js +79 -0
  73. data/web/assets/js/live.js +74 -0
  74. data/web/assets/js/main.js +143 -0
  75. data/web/assets/js/map.js +639 -0
  76. data/web/assets/js/range.js +19 -0
  77. data/web/assets/js/sagas.js +18 -0
  78. data/web/assets/js/theme.js +8 -0
  79. data/web/assets/js/throughput.js +262 -0
  80. data/web/assets/js/views/execution.js +426 -0
  81. data/web/assets/js/views/executions.js +165 -0
  82. data/web/assets/js/views/overview.js +465 -0
  83. data/web/index.html +63 -0
  84. metadata +323 -0
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ # Persistence adapters. Each one subclasses Stores::Base and lives at
6
+ # "lepus/saga/stores/<name>", which is how `config.store = :<name>` finds
7
+ # it, whether it ships with this gem (Memory) or in its own gem
8
+ # (lepus-saga-active_record ships Stores::ActiveRecord).
9
+ module Stores
10
+ autoload :Base, "lepus/saga/stores/base"
11
+ autoload :Memory, "lepus/saga/stores/memory"
12
+
13
+ class << self
14
+ def build(value)
15
+ case value
16
+ when nil then nil
17
+ when Symbol, String then lookup(value).new
18
+ when Class then value.new
19
+ else value
20
+ end
21
+ end
22
+
23
+ def lookup(name)
24
+ path = Lepus::Primitive::String.new(name.to_s).underscore
25
+ const_name = camelize(path)
26
+ require "lepus/saga/stores/#{path}" unless const_defined?(const_name, false)
27
+ const_get(const_name, false)
28
+ rescue LoadError, NameError => error
29
+ raise ConfigurationError, "Unknown saga store #{name.inspect} (#{error.message})"
30
+ end
31
+
32
+ private
33
+
34
+ def camelize(path)
35
+ path.split("/").map { |part| part.split("_").map(&:capitalize).join }.join("::")
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lepus/testing"
4
+
5
+ module Lepus
6
+ module Saga
7
+ # Test-only helper to run saga step messages inline. Requires
8
+ # Lepus::Testing fake publishing to be enabled.
9
+ module Testing
10
+ class DrainStallError < StandardError; end
11
+
12
+ # Pops messages from the fake saga exchange and delivers them to the
13
+ # executor consumer until the exchange is empty. Messages rejected for
14
+ # retry are redelivered immediately (the TTL delay is irrelevant under
15
+ # fake publishing), so retry loops run to their conclusion inline.
16
+ def self.drain(consumer:, max_iterations: 50)
17
+ exchange = Lepus::Testing.exchange(Lepus::Saga.exchange_name)
18
+ iterations = 0
19
+
20
+ until exchange.empty?
21
+ if (iterations += 1) > max_iterations
22
+ raise DrainStallError, "saga drain did not settle after #{max_iterations} iterations"
23
+ end
24
+
25
+ batch = exchange.messages.dup
26
+ exchange.clear_messages
27
+ batch.each do |message|
28
+ result = Lepus::Testing.consumer_perform(consumer, {payload: message[:payload]})
29
+ exchange.add_message(message) if result == :reject
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Lepus
6
+ module Saga
7
+ module Web
8
+ # The JSON API under /api. Reads need the store's query methods; the
9
+ # two actions are POSTs guarded against cross-site requests: they need a
10
+ # JSON body, the X-Lepus-Saga header and a same-origin Origin (or
11
+ # Referer when the browser sends no Origin).
12
+ class Api
13
+ class Error < StandardError
14
+ attr_reader :status
15
+
16
+ def initialize(status, message)
17
+ @status = status
18
+ super(message)
19
+ end
20
+ end
21
+
22
+ ROUTES = [
23
+ ["GET", %r{\A/me\z}, :me],
24
+ ["GET", %r{\A/sagas\z}, :sagas],
25
+ ["GET", %r{\A/executions\z}, :executions],
26
+ ["GET", %r{\A/executions/(?<id>[^/]+)\z}, :execution],
27
+ ["GET", %r{\A/moving\z}, :moving],
28
+ ["GET", %r{\A/series\z}, :series],
29
+ ["GET", %r{\A/insights/failures\z}, :failures],
30
+ ["GET", %r{\A/insights/latency\z}, :latency],
31
+ ["GET", %r{\A/stalled\z}, :stalled],
32
+ ["POST", %r{\A/executions/(?<id>[^/]+)/retry\z}, :retry_execution],
33
+ ["POST", %r{\A/executions/(?<id>[^/]+)/compensate\z}, :compensate_execution]
34
+ ].freeze
35
+ ACTIONS = {retry_execution: :retry, compensate_execution: :compensate}.freeze
36
+ CSRF_HEADER = "HTTP_X_LEPUS_SAGA"
37
+ DEFAULT_WINDOW = 24 * 3600
38
+ SERIES_BUCKETS = [60, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400].freeze
39
+ SERIES_TARGET = 90
40
+ IN_PROGRESS = ExecutionBehaviour::IN_PROGRESS_STATUSES
41
+ STOPPED_STATUSES = %w[failed compensation_failed].freeze
42
+ RELATED_LIMIT = 20
43
+
44
+ def initialize(config)
45
+ @config = config
46
+ end
47
+
48
+ def call(env)
49
+ request = Rack::Request.new(env)
50
+ method, _pattern, action, match = route(request)
51
+ raise Error.new(404, "Not found") unless action
52
+ raise Error.new(405, "Method not allowed") unless request.request_method == method
53
+
54
+ authorize_action!(request, ACTIONS[action]) if ACTIONS.key?(action)
55
+ json(200, send(action, request, **match.named_captures.transform_keys(&:to_sym)))
56
+ rescue Error => error
57
+ json(error.status, error: error.message)
58
+ rescue NotRetryableError, UnknownSagaError => error
59
+ json(409, error: error.message)
60
+ rescue NotImplementedError
61
+ json(501, error: "The saga store does not implement the query methods")
62
+ rescue ArgumentError => error
63
+ json(400, error: error.message)
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :config
69
+
70
+ def store = config.store
71
+
72
+ def route(request)
73
+ path = request.path_info.empty? ? "/" : request.path_info
74
+ candidates = ROUTES.filter_map { |method, pattern, action| [method, pattern, action, pattern.match(path)] if pattern.match?(path) }
75
+ candidates.find { |method, *| method == request.request_method } || candidates.first
76
+ end
77
+
78
+ def me(request)
79
+ {
80
+ title: config.title,
81
+ version: VERSION,
82
+ read_only: config.read_only ? true : false,
83
+ queries: store.queries?,
84
+ poll_interval: config.default_poll_interval,
85
+ poll_intervals: Configuration::POLL_INTERVALS,
86
+ stalled_after: config.stalled_after,
87
+ actions: ACTIONS.values.to_h { |action| [action, config.allowed?(request.env, action)] }
88
+ }
89
+ end
90
+
91
+ def sagas(request)
92
+ from, to = window(request)
93
+ live = store.count_executions(group_by: %i[saga_name status current_step], status: IN_PROGRESS)
94
+ stalled = store.count_executions(group_by: %i[saga_name status current_step], status: IN_PROGRESS, progress_to: stalled_before)
95
+ stopped = store.count_executions(group_by: %i[saga_name status current_step], status: STOPPED_STATUSES, from: from, to: to)
96
+ totals = store.count_executions(group_by: %i[saga_name status], from: from, to: to)
97
+ failures = store.failure_summary(from: from, to: to, limit: 500)
98
+ definitions = Lepus::Saga.definitions
99
+ names = (definitions.keys + store.saga_names + totals.keys.map(&:first)).uniq.sort
100
+
101
+ sagas = names.map do |name|
102
+ Serializer.definition(name, definitions[name]).merge(
103
+ stations: stations(name, live.merge(stopped), stalled, failures),
104
+ totals: totals.filter_map { |(saga, status), count| [status, count] if saga == name }.to_h
105
+ )
106
+ end
107
+ {from: Serializer.time(from), to: Serializer.time(to), stalled_after: config.stalled_after, sagas: sagas}
108
+ end
109
+
110
+ def stations(name, counts, stalled, failures)
111
+ stations = Hash.new { |hash, step| hash[step] = {attempt_failures: 0, stalled: {}} }
112
+ counts.each do |(saga, status, step), count|
113
+ stations[step][status] = count if saga == name
114
+ end
115
+ stalled.each do |(saga, status, step), count|
116
+ stations[step][:stalled][status] = count if saga == name
117
+ end
118
+ failures.each do |failure|
119
+ stations[failure[:step_name]][:attempt_failures] += failure[:count] if failure[:saga_name] == name
120
+ end
121
+ stations
122
+ end
123
+
124
+ def executions(request)
125
+ limit = integer(request, "limit", default: 50, range: 1..200)
126
+ rows = store.list_executions(**filters(request), before_id: param(request, "before"), limit: limit + 1)
127
+ {
128
+ executions: rows.first(limit).map { |execution| Serializer.execution(execution) },
129
+ next_before: (rows[limit - 1].id if rows.size > limit)
130
+ }
131
+ end
132
+
133
+ # Executions in progress and not stalled at the busiest steps, so a
134
+ # live view can draw every busy step from one request.
135
+ def moving(request)
136
+ per_station = integer(request, "per_station", default: 9, range: 1..50)
137
+ limit = integer(request, "stations", default: 40, range: 1..100)
138
+ since = stalled_before
139
+ counts = store.count_executions(group_by: %i[saga_name current_step], status: IN_PROGRESS, progress_from: since, saga_name: list(request, "saga"))
140
+ .reject { |(_saga, step), _count| step.nil? }
141
+ busiest = counts.sort_by { |(saga, step), count| [-count, saga, step] }.first(limit)
142
+ stations = busiest.map do |(saga, step), count|
143
+ rows = store.list_executions(saga_name: saga, current_step: step, status: IN_PROGRESS, progress_from: since, limit: per_station)
144
+ {saga_name: saga, step_name: step, count: count, executions: rows.map { |execution| Serializer.execution(execution) }}
145
+ end
146
+ {stalled_after: config.stalled_after, stations: stations, omitted: counts.size - busiest.size}
147
+ end
148
+
149
+ def execution(_request, id:)
150
+ execution = find!(id)
151
+ definition = Lepus::Saga.definitions[execution.saga_name]
152
+ related = store.executions_for_resource(execution.resource_type, execution.resource_id)
153
+ .reject { |other| other.id == execution.id }.first(RELATED_LIMIT)
154
+ {
155
+ execution: Serializer.execution(execution, context: true),
156
+ definition: Serializer.definition(execution.saga_name, definition),
157
+ attempts: store.step_attempts(execution).map { |attempt| Serializer.attempt(attempt) },
158
+ related: related.map { |other| Serializer.execution(other) }
159
+ }
160
+ end
161
+
162
+ def series(request)
163
+ from, to = window(request)
164
+ bucket = integer(request, "bucket", default: series_bucket(to - from), range: 1..)
165
+ result = store.execution_series(from: from, to: to, bucket: bucket, saga_name: list(request, "saga"), max_rows: config.max_rows)
166
+ {
167
+ from: Serializer.time(from), to: Serializer.time(to), bucket: bucket, truncated: result[:truncated],
168
+ rows: result[:rows].map { |start, counts| {t: Serializer.time(start), **counts} }
169
+ }
170
+ end
171
+
172
+ def failures(request)
173
+ from, to = window(request)
174
+ limit = integer(request, "limit", default: 20, range: 1..200)
175
+ rows = store.failure_summary(from: from, to: to, saga_name: list(request, "saga"), limit: limit)
176
+ {from: Serializer.time(from), to: Serializer.time(to), failures: rows.map { |row| row.merge(last_seen_at: Serializer.time(row[:last_seen_at])) }}
177
+ end
178
+
179
+ def latency(request)
180
+ from, to = window(request)
181
+ result = store.step_latency(from: from, to: to, saga_name: list(request, "saga"), max_rows: config.max_rows)
182
+ rows = result[:rows].map { |row| row.merge(row.slice(:p50, :p95, :p99, :max).transform_values { |ms| ms&.round(1) }) }
183
+ {from: Serializer.time(from), to: Serializer.time(to), rows: rows, truncated: result[:truncated]}
184
+ end
185
+
186
+ def stalled(request)
187
+ minutes = integer(request, "minutes", default: nil, range: 1..)
188
+ seconds = minutes ? minutes * 60 : config.stalled_after
189
+ limit = integer(request, "limit", default: 50, range: 1..200)
190
+ rows = store.stalled_executions(before: Time.now.utc - seconds).first(limit)
191
+ {stalled_after: seconds, executions: rows.map { |execution| Serializer.execution(execution) }}
192
+ end
193
+
194
+ def retry_execution(_request, id:)
195
+ {execution: Serializer.execution(Lepus::Saga.retry(find_actionable!(id)))}
196
+ end
197
+
198
+ def compensate_execution(request, id:)
199
+ reason = presence(body(request)["reason"].to_s.strip) || "manually compensated"
200
+ {execution: Serializer.execution(Lepus::Saga.compensate(find_actionable!(id), reason: reason))}
201
+ end
202
+
203
+ def authorize_action!(request, action)
204
+ raise Error.new(403, "The console is read-only") if config.read_only
205
+ raise Error.new(403, "Forbidden") unless config.allowed?(request.env, action)
206
+ raise Error.new(403, "Cross-site request rejected") unless same_origin?(request)
207
+ end
208
+
209
+ def same_origin?(request)
210
+ return false unless request.media_type == "application/json"
211
+ return false if request.get_header(CSRF_HEADER).to_s.empty?
212
+
213
+ origin = request.get_header("HTTP_ORIGIN") || referer_origin(request.referer)
214
+ !origin.nil? && origin == request.base_url
215
+ end
216
+
217
+ def referer_origin(referer)
218
+ return if referer.nil?
219
+
220
+ uri = URI.parse(referer)
221
+ port = (uri.port == uri.default_port) ? "" : ":#{uri.port}"
222
+ "#{uri.scheme}://#{uri.host}#{port}" if uri.host
223
+ rescue URI::InvalidURIError
224
+ nil
225
+ end
226
+
227
+ def find!(id)
228
+ store.find_execution(id) || raise(Error.new(404, "Execution #{id} not found"))
229
+ end
230
+
231
+ def find_actionable!(id)
232
+ execution = find!(id)
233
+ return execution if Lepus::Saga.definitions.key?(execution.saga_name)
234
+
235
+ raise Error.new(409, "Saga #{execution.saga_name} is not loaded in this process")
236
+ end
237
+
238
+ def body(request)
239
+ content = request.body&.read.to_s
240
+ return {} if content.empty?
241
+
242
+ data = JSON.parse(content)
243
+ data.is_a?(Hash) ? data : {}
244
+ rescue JSON::ParserError
245
+ raise Error.new(400, "Invalid JSON body")
246
+ end
247
+
248
+ def filters(request)
249
+ filters = {
250
+ saga_name: list(request, "saga"),
251
+ status: list(request, "status"),
252
+ current_step: list(request, "step"),
253
+ partition: param(request, "partition"),
254
+ resource: param(request, "resource"),
255
+ from: time(request, "from"),
256
+ to: time(request, "to"),
257
+ error_class: list(request, "error"),
258
+ error_step: list(request, "error_step"),
259
+ error_from: time(request, "error_from"),
260
+ error_to: time(request, "error_to")
261
+ }
262
+ flag?(request, "stalled") ? stalled_filters(filters) : filters
263
+ end
264
+
265
+ def stalled_filters(filters)
266
+ statuses = filters[:status] ? filters[:status] & IN_PROGRESS : IN_PROGRESS
267
+ raise ArgumentError, "Stalled executions are #{IN_PROGRESS.join(" or ")}" if statuses.empty?
268
+
269
+ filters.merge(status: statuses, progress_to: stalled_before)
270
+ end
271
+
272
+ def stalled_before = Time.now.utc - config.stalled_after
273
+
274
+ def window(request)
275
+ to = time(request, "to") || Time.now.utc
276
+ from = time(request, "from") || (to - DEFAULT_WINDOW)
277
+ raise ArgumentError, "from must be before to" unless from < to
278
+
279
+ [from, to]
280
+ end
281
+
282
+ def series_bucket(span)
283
+ SERIES_BUCKETS.find { |seconds| span / seconds <= SERIES_TARGET } || SERIES_BUCKETS.last
284
+ end
285
+
286
+ def param(request, name)
287
+ value = request.GET[name]
288
+ presence(value.strip) if value.is_a?(String)
289
+ end
290
+
291
+ def flag?(request, name)
292
+ %w[1 true].include?(param(request, name))
293
+ end
294
+
295
+ def list(request, name)
296
+ presence(param(request, name)&.split(",")&.map(&:strip)&.reject(&:empty?))
297
+ end
298
+
299
+ def time(request, name)
300
+ value = param(request, name)
301
+ value && Time.iso8601(value)
302
+ rescue ArgumentError
303
+ raise ArgumentError, "#{name} must be an ISO 8601 time"
304
+ end
305
+
306
+ def integer(request, name, default:, range:)
307
+ value = param(request, name)
308
+ return default if value.nil?
309
+
310
+ number = Integer(value, exception: false)
311
+ raise ArgumentError, "#{name} must be an integer in #{range}" unless number && range.cover?(number)
312
+
313
+ number
314
+ end
315
+
316
+ def presence(value)
317
+ value unless value.nil? || value.empty?
318
+ end
319
+
320
+ def json(status, data)
321
+ [status, {"content-type" => "application/json; charset=utf-8", "cache-control" => "no-store"}, [JSON.generate(data)]]
322
+ end
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module Web
6
+ class Configuration
7
+ ACTIONS = %i[read retry compensate].freeze
8
+ POLL_INTERVALS = [0, 2, 5, 15, 60].freeze
9
+
10
+ # Called with the Rack env and one of ACTIONS; a falsy result answers
11
+ # 403. Allows everything by default.
12
+ attr_accessor :authorize
13
+
14
+ # Hides the retry and compensate actions and refuses their requests.
15
+ attr_accessor :read_only
16
+
17
+ attr_accessor :title
18
+
19
+ # Seconds between refreshes until the viewer picks another interval
20
+ # (one of POLL_INTERVALS; 0 turns polling off).
21
+ attr_accessor :default_poll_interval
22
+
23
+ # Cap on the rows read for the throughput series and step latency.
24
+ attr_accessor :max_rows
25
+
26
+ # Seconds without progress after which an in-progress execution
27
+ # counts as stalled.
28
+ attr_accessor :stalled_after
29
+
30
+ def initialize
31
+ @authorize = ->(_env, _action) { true }
32
+ @read_only = false
33
+ @title = "Sagas"
34
+ @default_poll_interval = 5
35
+ @max_rows = Stores::Base::QUERY_MAX_ROWS
36
+ @stalled_after = 15 * 60
37
+ end
38
+
39
+ def allowed?(env, action)
40
+ return false if read_only && action != :read
41
+
42
+ authorize.call(env, action) ? true : false
43
+ end
44
+
45
+ def store = Lepus::Saga.store
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module Web
6
+ # Refuses every request the `authorize` hook does not allow to read, and
7
+ # adds the headers every response carries.
8
+ class Guard
9
+ HEADERS = {
10
+ "x-content-type-options" => "nosniff",
11
+ "referrer-policy" => "same-origin",
12
+ "x-frame-options" => "SAMEORIGIN"
13
+ }.freeze
14
+
15
+ def initialize(app, config)
16
+ @app = app
17
+ @config = config
18
+ end
19
+
20
+ def call(env)
21
+ status, headers, body = @config.allowed?(env, :read) ? @app.call(env) : forbidden(env)
22
+ HEADERS.each { |name, value| headers[name] ||= value }
23
+ [status, headers, body]
24
+ end
25
+
26
+ private
27
+
28
+ def forbidden(env)
29
+ if env["PATH_INFO"].to_s.start_with?("/api/")
30
+ [403, {"content-type" => "application/json"}, [JSON.generate(error: "Forbidden")]]
31
+ else
32
+ [403, {"content-type" => "text/plain; charset=utf-8"}, ["Forbidden"]]
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lepus
4
+ module Saga
5
+ module Web
6
+ module Serializer
7
+ EXECUTION_FIELDS = %i[
8
+ id saga_name resource_type resource_id status current_step direction
9
+ epoch correlation_id failure_reason resume_count
10
+ ].freeze
11
+ EXECUTION_TIMES = %i[started_at finished_at last_progress_at created_at updated_at].freeze
12
+ ATTEMPT_FIELDS = %i[
13
+ id step_name direction epoch attempt status error_class error_message
14
+ error_backtrace duration_ms
15
+ ].freeze
16
+
17
+ module_function
18
+
19
+ def execution(record, context: false)
20
+ data = EXECUTION_FIELDS.to_h { |name| [name, record.public_send(name)] }
21
+ EXECUTION_TIMES.each { |name| data[name] = time(record.public_send(name)) }
22
+ data[:partition] = partition(record)
23
+ data[:context] = record.context if context
24
+ data
25
+ end
26
+
27
+ def attempt(record)
28
+ data = ATTEMPT_FIELDS.to_h { |name| [name, record.public_send(name)] }
29
+ data[:duration_ms] = data[:duration_ms]&.to_f&.round(1)
30
+ data[:context_after] = record.context_after
31
+ data[:started_at] = time(record.started_at)
32
+ data[:finished_at] = time(record.finished_at)
33
+ data
34
+ end
35
+
36
+ def definition(name, definition)
37
+ {
38
+ name: name,
39
+ known: !definition.nil?,
40
+ partition: definition&.partition,
41
+ steps: Array(definition&.steps).map do |step|
42
+ {name: step.name, compensate: step.compensate, attempts: step.retry_policy.attempts}
43
+ end
44
+ }
45
+ end
46
+
47
+ def time(value)
48
+ value&.utc&.iso8601(3)
49
+ end
50
+
51
+ def partition(record)
52
+ record.partition
53
+ rescue UnknownSagaError
54
+ record[:partition]
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Lepus
6
+ module Saga
7
+ module Web
8
+ # Serves the console page and its assets from Web::ROOT. The page gets a
9
+ # <base href> of the mount path, so every relative URL works wherever
10
+ # the console is mounted, and points at "assets-<digest>/", a path that
11
+ # changes with the asset files and is therefore cached for a year.
12
+ # Plain "assets/" paths are served too, revalidated through their ETag.
13
+ class Static
14
+ CONTENT_SECURITY_POLICY = [
15
+ "default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:",
16
+ "font-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'self'",
17
+ "form-action 'self'", "frame-ancestors 'self'"
18
+ ].join("; ").freeze
19
+ IMMUTABLE = "public, max-age=31536000, immutable"
20
+ REVALIDATE = "no-cache"
21
+ INDEX_PATHS = ["", "/", "/index.html"].freeze
22
+
23
+ def initialize(config, root: ROOT)
24
+ @config = config
25
+ @root = root
26
+ @etags = {}
27
+ end
28
+
29
+ def call(env)
30
+ return not_allowed unless %w[GET HEAD].include?(env["REQUEST_METHOD"])
31
+
32
+ path = env["PATH_INFO"].to_s
33
+ return index(env) if INDEX_PATHS.include?(path)
34
+
35
+ case path
36
+ when %r{\A/assets-(?<digest>\h+)/(?<file>.+)\z}
37
+ asset(env, $~[:file], ($~[:digest] == assets_digest) ? IMMUTABLE : REVALIDATE)
38
+ when %r{\A/assets/(?<file>.+)\z}
39
+ asset(env, $~[:file], REVALIDATE)
40
+ else
41
+ not_found
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :config, :root
48
+
49
+ def index(env)
50
+ base = "#{env["SCRIPT_NAME"].to_s.chomp("/")}/"
51
+ html = File.read(File.join(root, "index.html"))
52
+ .gsub("{{base}}", Rack::Utils.escape_html(base))
53
+ .gsub("{{assets}}", "assets-#{assets_digest}")
54
+ .gsub("{{title}}", Rack::Utils.escape_html(config.title))
55
+ headers = {
56
+ "content-type" => "text/html; charset=utf-8",
57
+ "cache-control" => REVALIDATE,
58
+ "content-security-policy" => CONTENT_SECURITY_POLICY
59
+ }
60
+ respond(env, html, %("#{Digest::SHA256.hexdigest(html)[0, 32]}"), headers)
61
+ end
62
+
63
+ def asset(env, file, cache_control)
64
+ path = resolve(file)
65
+ return not_found unless path
66
+
67
+ headers = {"content-type" => Rack::Mime.mime_type(File.extname(path), "application/octet-stream"), "cache-control" => cache_control}
68
+ respond(env, File.binread(path), etag(path), headers)
69
+ end
70
+
71
+ def respond(env, content, etag, headers)
72
+ headers = headers.merge("etag" => etag)
73
+ return [304, headers, []] if env["HTTP_IF_NONE_MATCH"].to_s.split(/\s*,\s*/).include?(etag)
74
+
75
+ body = (env["REQUEST_METHOD"] == "HEAD") ? [] : [content]
76
+ [200, headers.merge("content-length" => content.bytesize.to_s), body]
77
+ end
78
+
79
+ def resolve(file)
80
+ return if file.include?("\0")
81
+
82
+ assets = File.join(root, "assets")
83
+ path = File.expand_path(file, assets)
84
+ path if path.start_with?("#{assets}/") && File.file?(path)
85
+ end
86
+
87
+ def etag(path)
88
+ stat = File.stat(path)
89
+ @etags[[path, stat.mtime.to_r, stat.size]] ||= %("#{Digest::SHA256.file(path).hexdigest[0, 32]}")
90
+ end
91
+
92
+ def assets_digest
93
+ assets = File.join(root, "assets")
94
+ files = Dir.glob(File.join(assets, "**", "*")).select { |path| File.file?(path) }.sort
95
+ Digest::SHA256.hexdigest(files.map { |path| "#{path.delete_prefix(assets)}:#{etag(path)}" }.join("\n"))[0, 16]
96
+ end
97
+
98
+ def not_found
99
+ [404, {"content-type" => "text/plain; charset=utf-8"}, ["Not found"]]
100
+ end
101
+
102
+ def not_allowed
103
+ [405, {"content-type" => "text/plain; charset=utf-8", "allow" => "GET, HEAD"}, ["Method not allowed"]]
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end