cronwatch 0.3.1 → 0.4.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.
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cronwatch
4
+ # A run recorded by JobHandle#start or found by JobHandle#resume, to finish
5
+ # later, perhaps in another process. Lines and metrics wait in the handle
6
+ # until #flush or #finish. Store failures go to on_error; none of these
7
+ # methods raises for them.
8
+ #
9
+ # run = SYNC.start(id: event_id)
10
+ # # later, perhaps elsewhere
11
+ # run = SYNC.resume(event_id)
12
+ # run.log("sent 40 emails")
13
+ # run.finish # or run.fail(error)
14
+ class RunHandle
15
+ # Raised by the client's side of finish when the store failed part way
16
+ # and nothing was recorded (already reported): the handle stays active.
17
+ #
18
+ # @api private
19
+ class Retry < StandardError; end
20
+
21
+ # `started_at` is nil when a resumed run could not be read.
22
+ attr_reader :id, :job, :started_at
23
+
24
+ # Made by the client (Client#run_handle), not by apps. `finish` and
25
+ # `flush` are the client's side of each; `inactive` says why finish has
26
+ # nothing to do, or is nil.
27
+ #
28
+ # @api private
29
+ def initialize(id:, job:, started_at:, inactive:, finish:, flush:, ignored:)
30
+ @id = id
31
+ @job = job
32
+ @started_at = started_at
33
+ @inactive = inactive
34
+ @finish = finish
35
+ @flush = flush
36
+ @ignored = ignored
37
+ # @state guards the flags and which recorder is current; @turn keeps
38
+ # flush and finish in order, one at a time.
39
+ @state = Mutex.new
40
+ @turn = Mutex.new
41
+ @recorder = fresh_recorder
42
+ @finished = !inactive.nil?
43
+ @finish_called = false
44
+ # The first Output::CAP characters of every line flushed from this
45
+ # handle, unredacted, or nil before the first flush. The stored output
46
+ # keeps only the tail, so without it an expect rule at finish would
47
+ # miss a line logged early, which run would have seen.
48
+ @head = nil
49
+ end
50
+
51
+ # False once finished, and from the start for a resumed run that already
52
+ # finished or does not exist.
53
+ def active?
54
+ @state.synchronize { !@finished }
55
+ end
56
+
57
+ # Add a line of output, as JobContext#log does. Kept in the handle until flush or finish.
58
+ def log(*parts)
59
+ @state.synchronize { @recorder.context.log(*parts) }
60
+ end
61
+
62
+ # Report a number for this run. A later value for the same name replaces an earlier one.
63
+ def metric(name, value)
64
+ @state.synchronize { @recorder.context.metric(name, value) }
65
+ end
66
+
67
+ def metrics(values = nil, **more)
68
+ @state.synchronize { @recorder.context.metrics(values, **more) }
69
+ end
70
+
71
+ # Append the lines and metrics added so far to the stored run, which must
72
+ # still be running and belong to this job. Output is redacted as it is
73
+ # written. A read, change and write of the run's row, written only while
74
+ # it is still running: two processes appending to one run at the same
75
+ # moment can lose one's lines, but a flush never undoes a finish. When
76
+ # the write fails the lines stay here for finish. The first 16 KB of
77
+ # everything flushed stay in the handle, so an expect rule at finish
78
+ # sees an early line as run would.
79
+ def flush
80
+ @turn.synchronize do
81
+ next if @flush.nil? || @state.synchronize { @finished }
82
+
83
+ # Lines logged while this waits on the store go to a new recorder.
84
+ taken = @state.synchronize do
85
+ current = @recorder
86
+ @recorder = fresh_recorder
87
+ current
88
+ end
89
+ lines = taken.output
90
+ values = taken.metrics
91
+ next if lines.nil? && values.empty?
92
+
93
+ if @flush.call(lines, values)
94
+ keep_head(taken.expect_text)
95
+ else
96
+ put_back(taken)
97
+ end
98
+ end
99
+ nil
100
+ end
101
+
102
+ # Finish the run, judge it like any other and send what that produces.
103
+ #
104
+ # finish # ok
105
+ # finish(status: "ok") # ok
106
+ # finish(error: e) # failed, recorded like an error run caught
107
+ # finish("text") # like run's return value: the output when
108
+ # finish(result: "text") # nothing was logged, checked by expect
109
+ #
110
+ # Returns the run as recorded, or nil when nothing was: the run was
111
+ # already finished (here or elsewhere), was not found, or belongs to
112
+ # another job, which is reported to on_error. When several processes
113
+ # finish one run, only the one whose write lands judges it. A store that
114
+ # fails is reported, nothing is recorded, and the handle stays active so
115
+ # finish can be called again.
116
+ def finish(outcome = nil)
117
+ was_inactive = nil
118
+ again = @state.synchronize do
119
+ next true if @finish_called
120
+
121
+ @finish_called = true
122
+ was_inactive = @finished
123
+ @finished = true
124
+ false
125
+ end
126
+ if again
127
+ @ignored.call("was already finished by this handle")
128
+ return nil
129
+ end
130
+
131
+ @turn.synchronize do
132
+ if was_inactive
133
+ @ignored.call(@inactive)
134
+ next nil
135
+ end
136
+
137
+ begin
138
+ @finish.call(@state.synchronize { @recorder }, outcome, @head)
139
+ rescue Retry
140
+ reopen
141
+ nil
142
+ rescue Exception # rubocop:disable Lint/RescueException
143
+ # An Interrupt or Timeout mid-finish: the run may still be running,
144
+ # so the handle stays open (lines kept) for finish to be called again.
145
+ reopen
146
+ raise
147
+ end
148
+ end
149
+ end
150
+
151
+ # finish(error: error).
152
+ def fail(error)
153
+ finish({ error: error })
154
+ end
155
+
156
+ # [failed, result, error] from what finish was given. A Hash with an
157
+ # :error key is a failure; a String, or a Hash's :result, is the result.
158
+ #
159
+ # @api private
160
+ def self.read_outcome(outcome)
161
+ case outcome
162
+ when String then [false, outcome, nil]
163
+ when Hash
164
+ key = [:error, "error"].find { |k| outcome.key?(k) }
165
+ return [true, nil, outcome[key]] if key
166
+
167
+ [false, outcome.key?(:result) ? outcome[:result] : outcome["result"], nil]
168
+ else [false, nil, nil]
169
+ end
170
+ end
171
+
172
+ private
173
+
174
+ def fresh_recorder
175
+ RunRecorder.new(Run.new(id: @id, job: @job, started_at: @started_at || 0), nil)
176
+ end
177
+
178
+ # A flush that could not write: its lines go back ahead of any logged since.
179
+ def put_back(taken)
180
+ @state.synchronize do
181
+ later = @recorder
182
+ @recorder = fresh_recorder
183
+ [taken.expect_text, later.expect_text].each { |text| @recorder.log(text) unless text.nil? }
184
+ taken.metrics.merge(later.metrics).each { |name, value| @recorder.metric(name, value) }
185
+ end
186
+ end
187
+
188
+ # A finish that recorded nothing leaves the handle active, to be finished again.
189
+ def reopen
190
+ @state.synchronize do
191
+ @finish_called = false
192
+ @finished = false
193
+ end
194
+ end
195
+
196
+ # Keeps the start of what a flush wrote, up to the cap, for expect at finish.
197
+ def keep_head(text)
198
+ return if text.nil? || (!@head.nil? && JS.length16(@head) >= Output::CAP)
199
+
200
+ @head = JS.head16(@head.nil? || @head.empty? ? text : "#{@head}\n#{text}", Output::CAP)
201
+ end
202
+ end
203
+ end
@@ -216,6 +216,18 @@ module Cronwatch
216
216
  nil
217
217
  end
218
218
 
219
+ # update_run, only while the stored status is one of `from_statuses`,
220
+ # in one statement (sql.ts updateRunIfSql). Returns whether it wrote.
221
+ def update_run_if(run, from_statuses)
222
+ statuses = Array(from_statuses).map(&:to_s)
223
+ return false if statuses.empty?
224
+
225
+ binds = [run.status.to_s, run.finished_at, run.duration_ms, run.error, run.output,
226
+ JS.json(run.metrics || {}), run.id, *statuses]
227
+ changed = with_connection { |conn| conn.exec_update(update_run_if_sql(conn, statuses.length), NAME, binds) }
228
+ changed.to_i.positive?
229
+ end
230
+
219
231
  def get_run(id)
220
232
  row = read(:get_run, [id]).first
221
233
  row && row_to_run(row)
@@ -313,6 +325,17 @@ module Cronwatch
313
325
  (@statements[dialect] ||= statements(dialect)).fetch(key)
314
326
  end
315
327
 
328
+ # sql.ts `updateRunIfSql`: update_run's statement, only while the
329
+ # stored status is one of `count` statuses, bound value by value.
330
+ def update_run_if_sql(conn, count)
331
+ text = "UPDATE #{@prefix}runs SET status = ?, finished_at = ?, duration_ms = ?, error = ?, output = ?, metrics = ? " \
332
+ "WHERE id = ? AND status IN (#{Array.new(count, "?").join(", ")})"
333
+ return text unless self.class.dialect(conn) == :postgres
334
+
335
+ n = 0
336
+ text.gsub("?") { "$#{n += 1}" }
337
+ end
338
+
316
339
  # sql.ts `statements`: the same text, with `?` numbered for Postgres.
317
340
  def statements(dialect)
318
341
  p = @prefix
@@ -7,7 +7,8 @@ module Cronwatch
7
7
  # so a missed run cannot be noticed across one.
8
8
  #
9
9
  # Every store answers the same methods: init (optional), upsert_job,
10
- # get_job, list_jobs, delete_job, insert_run, update_run, get_run,
10
+ # get_job, list_jobs, delete_job, insert_run, update_run, update_run_if
11
+ # (optional: without it the client reads the run, then writes it), get_run,
11
12
  # list_runs, last_run, running_runs, get_state, set_state, compare_and_set_state
12
13
  # (optional: without it the client falls back to set_state), prune and close
13
14
  # (optional). They take and return the types in types.rb.
@@ -56,8 +57,11 @@ module Cronwatch
56
57
  nil
57
58
  end
58
59
 
60
+ # Like SQL's primary key: an id already recorded is refused, never overwritten.
59
61
  def insert_run(run)
60
62
  sync do
63
+ raise "run #{run.id} already exists" if @runs.key?(run.id)
64
+
61
65
  @runs[run.id] = clone(run, Run)
62
66
  @order[run.id] = (@seq += 1)
63
67
  end
@@ -70,19 +74,25 @@ module Cronwatch
70
74
  existing = @runs[run.id]
71
75
  next unless existing
72
76
 
73
- copy = clone(run, Run)
74
- @runs[run.id] = existing.dup.tap do |r|
75
- r.status = copy.status
76
- r.finished_at = copy.finished_at
77
- r.duration_ms = copy.duration_ms
78
- r.error = copy.error
79
- r.output = copy.output
80
- r.metrics = copy.metrics
81
- end
77
+ @runs[run.id] = finished_fields(existing, run)
82
78
  end
83
79
  nil
84
80
  end
85
81
 
82
+ # update_run, only while the stored run's status is one of
83
+ # `from_statuses`, in one step. Returns whether it wrote. What lets
84
+ # exactly one of several processes finishing a run evaluate it.
85
+ def update_run_if(run, from_statuses)
86
+ statuses = Array(from_statuses).map(&:to_sym)
87
+ sync do
88
+ existing = @runs[run.id]
89
+ next false unless existing && statuses.include?(existing.status)
90
+
91
+ @runs[run.id] = finished_fields(existing, run)
92
+ true
93
+ end
94
+ end
95
+
86
96
  def get_run(id)
87
97
  sync { (run = @runs[id]) && clone(run, Run) }
88
98
  end
@@ -149,6 +159,19 @@ module Cronwatch
149
159
 
150
160
  private
151
161
 
162
+ # `existing` with the fields an update writes taken from `run`.
163
+ def finished_fields(existing, run)
164
+ copy = clone(run, Run)
165
+ existing.dup.tap do |r|
166
+ r.status = copy.status
167
+ r.finished_at = copy.finished_at
168
+ r.duration_ms = copy.duration_ms
169
+ r.error = copy.error
170
+ r.output = copy.output
171
+ r.metrics = copy.metrics
172
+ end
173
+ end
174
+
152
175
  def sync(&block)
153
176
  @lock.synchronize(&block)
154
177
  end
@@ -226,6 +226,7 @@ module Cronwatch
226
226
  run = Naming.fetch(hash, "run")
227
227
  details = Naming.from_json_value(Naming.fetch(hash, "details") || {})
228
228
  details[:after] = details[:after].map(&:to_sym) if details[:after].is_a?(Array)
229
+ details[:reason] = details[:reason].to_sym if details[:reason].is_a?(String)
229
230
  alert = new(
230
231
  type: Naming.fetch(hash, "type")&.to_sym,
231
232
  run: run && Run.from_h(run),
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Cronwatch
4
4
  # Kept in step with @cronwatch/sdk.
5
- VERSION = "0.3.1"
5
+ VERSION = "0.4.0"
6
6
  end
@@ -3,6 +3,7 @@
3
3
  require "base64"
4
4
  require "digest"
5
5
  require "securerandom"
6
+ require "uri"
6
7
 
7
8
  module Cronwatch
8
9
  # The dashboard and the small JSON API, as a Rack app: the SDK's routes
@@ -26,6 +27,20 @@ module Cronwatch
26
27
  # client's cron_secret as a bearer, for a platform cron.
27
28
  # base_path: where the app is mounted, so links resolve. Defaults to the
28
29
  # mount point (SCRIPT_NAME), which is right under Rails' `mount`.
30
+ # origin: the public origin the dashboard is served from, such as
31
+ # "https://app.example.com". By default the request's own origin
32
+ # is used, as Rack reads it (Rack::Request#base_url, which follows
33
+ # X-Forwarded-Proto and X-Forwarded-Host the way Rails does). Set
34
+ # this to pin it, for an app behind a proxy that does not set
35
+ # those headers or one that should not trust them. Used in place
36
+ # of the request's origin for the cross-site check on writes, the
37
+ # sign-in cookie's Secure flag, the Referer the redirect back after
38
+ # a form follows, and the development sign-in line. Read as
39
+ # `new URL(value).origin` reads it (Web::Origin) and normalised to
40
+ # scheme://host[:port]; anything that is not an absolute http or
41
+ # https URL, or has a port outside 1 to 65535, raises
42
+ # ArgumentError here. "" counts as unset. Behind more than one
43
+ # proxy set it: Rack takes the last of several forwarded values.
29
44
  class Web
30
45
  # Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
31
46
  UNSET = Object.new.freeze
@@ -46,8 +61,9 @@ module Cronwatch
46
61
  DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
47
62
  RADIX = { "x" => 16, "o" => 8, "b" => 2 }.freeze
48
63
 
49
- def initialize(client = nil, token: UNSET, base_path: nil)
64
+ def initialize(client = nil, token: UNSET, base_path: nil, origin: nil)
50
65
  @client = client
66
+ @origin = Web.configured_origin(origin)
51
67
  @opted_out = token.nil?
52
68
  given = token.equal?(UNSET) ? nil : token
53
69
  @token = @opted_out ? nil : [given, ENV.fetch("CRONWATCH_TOKEN", nil)].map(&:to_s).find { |t| !t.empty? }
@@ -77,6 +93,14 @@ module Cronwatch
77
93
  "Sign in: #{origin}#{base}/?token=#{token}"
78
94
  end
79
95
 
96
+ # The `origin` option, normalised to scheme://host[:port], or nil when
97
+ # unset. Raises on a value that is not an http or https origin, so a typo
98
+ # fails at startup.
99
+ # Read as the SDK's `new URL(value).origin` reads it (Web::Origin).
100
+ def self.configured_origin(value)
101
+ Origin.parse(value)
102
+ end
103
+
80
104
  # The client given, or Cronwatch.client when none was.
81
105
  def client
82
106
  @client || Cronwatch.client
@@ -142,7 +166,7 @@ module Cronwatch
142
166
  end
143
167
  unless query.nil?
144
168
  # Move the token from the URL into a cookie so it is not in history or logs.
145
- secure = request.https? ? "; Secure" : ""
169
+ secure = public_origin(request).start_with?("https:") ? "; Secure" : ""
146
170
  return redirect(request.pathname + request.search_without("token"),
147
171
  "set-cookie" => "#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}")
148
172
  end
@@ -255,7 +279,7 @@ module Cronwatch
255
279
  end
256
280
  return unless first
257
281
 
258
- $stdout.puts(Web.development_sign_in_line(request.origin, base, @token))
282
+ $stdout.puts(Web.development_sign_in_line(public_origin(request), base, @token))
259
283
  $stdout.flush
260
284
  end
261
285
 
@@ -295,7 +319,7 @@ module Cronwatch
295
319
  # a page cannot forge either. Non-browser clients send neither.
296
320
  def cross_site?(request)
297
321
  origin = request.header("origin")
298
- return true if !origin.nil? && origin != request.origin
322
+ return true if !origin.nil? && origin != public_origin(request)
299
323
 
300
324
  site = request.header("sec-fetch-site")
301
325
  !site.nil? && site != "same-origin" && site != "none"
@@ -378,7 +402,12 @@ module Cronwatch
378
402
 
379
403
  def redirect_back(request, base)
380
404
  referer = request.header("referer") || ""
381
- redirect(referer.start_with?("#{request.origin}/") ? referer : "#{base}/")
405
+ redirect(referer.start_with?("#{public_origin(request)}/") ? referer : "#{base}/")
406
+ end
407
+
408
+ # The origin a browser sees: the `origin` option when set, otherwise the request's own.
409
+ def public_origin(request)
410
+ @origin || request.origin
382
411
  end
383
412
 
384
413
  def api(body, status = 200, headers = {})
@@ -426,13 +455,11 @@ module Cronwatch
426
455
  @env[key]
427
456
  end
428
457
 
429
- # scheme://host[:port], the page's origin.
458
+ # scheme://host[:port], the page's origin, lowercased as a browser
459
+ # writes it in Origin and Referer (Rack's base_url keeps the case of
460
+ # the Host or X-Forwarded-Host it read).
430
461
  def origin
431
- @rack.base_url
432
- end
433
-
434
- def https?
435
- @rack.scheme == "https"
462
+ @rack.base_url.downcase
436
463
  end
437
464
 
438
465
  # URLSearchParams#get: the first value, or nil.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Cronwatch
6
+ class Web
7
+ # Reads the `origin` option as the SDK's `new URL(value).origin` does:
8
+ # whitespace around it and tabs or line breaks in it are dropped, slashes
9
+ # after the scheme may be missing or backslashes, credentials are
10
+ # ignored, the host is lowercased (percent escapes decoded, IPv4 numbers
11
+ # written out, IPv6 compressed, a non-ASCII host converted to punycode)
12
+ # and a default port is left out. A port outside 1 to 65535 is refused.
13
+ module Origin
14
+ ABSOLUTE = "routes: origin must be an absolute URL such as \"https://app.example.com\", got %s"
15
+ FORBIDDEN_HOST = /[\x00-\x20#%\/:<>?@\[\\\]^|\x7F]/
16
+ DEFAULT_PORTS = { "http" => 80, "https" => 443 }.freeze
17
+
18
+ module_function
19
+
20
+ # scheme://host[:port], or nil for nil or "". Raises ArgumentError otherwise.
21
+ def parse(value)
22
+ return nil if value.nil? || value == ""
23
+ raise ArgumentError, format(ABSOLUTE, JS.json(value)) unless value.is_a?(String)
24
+
25
+ text = value.sub(/\A[\x00-\x20]+/, "").sub(/[\x00-\x20]+\z/, "").delete("\t\n\r")
26
+ match = text.match(/\A([A-Za-z][A-Za-z0-9+.\-]*):(.*)\z/m)
27
+ raise ArgumentError, format(ABSOLUTE, JS.json(value)) unless match
28
+
29
+ scheme = match[1].downcase
30
+ unless DEFAULT_PORTS.key?(scheme)
31
+ raise ArgumentError, "routes: origin must be http or https, got #{JS.json(value)}"
32
+ end
33
+
34
+ authority = match[2].sub(%r{\A[/\\]*}, "")[%r{\A[^/\\?#]*}]
35
+ authority = authority.split("@", -1).last.to_s
36
+ host, port = split_port(authority, value)
37
+ host = read_host(host, value)
38
+ port = port.nil? || port == DEFAULT_PORTS[scheme] ? "" : ":#{port}"
39
+ "#{scheme}://#{host}#{port}"
40
+ end
41
+
42
+ # [host, port] with the scheme's default port when none is given.
43
+ def split_port(authority, value)
44
+ if authority.start_with?("[")
45
+ close = authority.index("]") or raise ArgumentError, format(ABSOLUTE, JS.json(value))
46
+ host = authority[0..close]
47
+ rest = authority[(close + 1)..]
48
+ else
49
+ host, colon, rest = authority.rpartition(":")
50
+ host, rest = rest, "" if colon.empty?
51
+ rest = ":#{rest}" unless colon.empty?
52
+ end
53
+ return [host, nil] if rest.empty? || rest == ":"
54
+ raise ArgumentError, format(ABSOLUTE, JS.json(value)) unless rest.match?(/\A:[0-9]+\z/)
55
+
56
+ port = rest[1..].to_i
57
+ unless port.between?(1, 65_535)
58
+ raise ArgumentError, "routes: origin has a port outside 1 to 65535, got #{JS.json(value)}"
59
+ end
60
+
61
+ [host, port]
62
+ end
63
+
64
+ def read_host(host, value)
65
+ invalid = -> { raise ArgumentError, format(ABSOLUTE, JS.json(value)) }
66
+ invalid.call if host.empty?
67
+ if host.start_with?("[")
68
+ invalid.call unless host.end_with?("]")
69
+ address = begin
70
+ IPAddr.new(host[1..-2])
71
+ rescue IPAddr::Error
72
+ invalid.call
73
+ end
74
+ invalid.call unless address.ipv6?
75
+ return "[#{address}]"
76
+ end
77
+
78
+ decoded = host.b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
79
+ invalid.call unless decoded.valid_encoding?
80
+ decoded = to_ascii(decoded, value) unless decoded.ascii_only?
81
+ decoded = decoded.downcase
82
+ invalid.call if decoded.empty? || decoded.match?(FORBIDDEN_HOST)
83
+ ipv4 = ipv4(decoded, invalid)
84
+ ipv4 || decoded
85
+ end
86
+
87
+ # WHATWG's IPv4 parser, for a host whose last label is a number:
88
+ # "127.1" and "0x7f.1" are 127.0.0.1. nil for a host that is a name.
89
+ def ipv4(host, invalid)
90
+ parts = host.split(".", -1)
91
+ parts.pop if parts.length > 1 && parts.last.empty?
92
+ return nil unless number?(parts.last)
93
+
94
+ invalid.call if parts.length > 4
95
+ numbers = parts.map { |part| number(part) || invalid.call }
96
+ last = numbers.pop
97
+ invalid.call if numbers.any? { |n| n > 255 } || last >= 256**(4 - numbers.length)
98
+ address = numbers.each_with_index.sum { |n, i| n * (256**(3 - i)) } + last
99
+ [24, 16, 8, 0].map { |shift| (address >> shift) & 255 }.join(".")
100
+ end
101
+
102
+ def number?(part)
103
+ part.match?(/\A[0-9]+\z/) || part.match?(/\A0[xX][0-9A-Fa-f]*\z/)
104
+ end
105
+
106
+ def number(part)
107
+ return nil if part.empty?
108
+ return (part.length == 2 ? 0 : part[2..].hex) if part.match?(/\A0[xX][0-9A-Fa-f]*\z/)
109
+ return part.to_i(8) if part.match?(/\A0[0-7]+\z/)
110
+
111
+ part.match?(/\A[0-9]+\z/) && !part.match?(/\A0[0-9]+\z/) ? part.to_i : nil
112
+ end
113
+
114
+ # A non-ASCII host in punycode, through URI::IDNA when this Ruby has it,
115
+ # else the simpleidn gem, else Addressable's (already loaded by many apps).
116
+ def to_ascii(host, value)
117
+ normalized = host.unicode_normalize(:nfc).downcase
118
+ return URI::IDNA.to_ascii(normalized) if defined?(URI::IDNA) && URI::IDNA.respond_to?(:to_ascii)
119
+
120
+ begin
121
+ require "simpleidn" unless defined?(::SimpleIDN)
122
+ rescue LoadError
123
+ nil
124
+ end
125
+ return ::SimpleIDN.to_ascii(normalized) if defined?(::SimpleIDN)
126
+ return ::Addressable::IDNA.to_ascii(normalized) if defined?(::Addressable::IDNA)
127
+
128
+ raise ArgumentError, "routes: origin has a host that is not ASCII, got #{JS.json(value)}; write it in punycode " \
129
+ "(xn--...) or add the simpleidn gem"
130
+ end
131
+ end
132
+ end
133
+ end
data/lib/cronwatch/web.rb CHANGED
@@ -15,3 +15,4 @@ end
15
15
  require "cronwatch" unless defined?(Cronwatch::Client)
16
16
  require_relative "web/html"
17
17
  require_relative "web/app"
18
+ require_relative "web/origin"
data/lib/cronwatch.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "cronwatch/evaluate"
13
13
  require_relative "cronwatch/format"
14
14
  require_relative "cronwatch/serialize"
15
15
  require_relative "cronwatch/job"
16
+ require_relative "cronwatch/run_handle"
16
17
  require_relative "cronwatch/http"
17
18
  require_relative "cronwatch/stores/memory"
18
19
  require_relative "cronwatch/alerts/console"
@@ -20,6 +21,21 @@ require_relative "cronwatch/alerts/custom"
20
21
  require_relative "cronwatch/alerts/slack"
21
22
  require_relative "cronwatch/alerts/discord"
22
23
  require_relative "cronwatch/alerts/webhook"
24
+ require_relative "cronwatch/alerts/provider"
25
+ require_relative "cronwatch/alerts/email"
26
+ require_relative "cronwatch/alerts/sigv4"
27
+ require_relative "cronwatch/alerts/resend"
28
+ require_relative "cronwatch/alerts/postmark"
29
+ require_relative "cronwatch/alerts/sendgrid"
30
+ require_relative "cronwatch/alerts/mailgun"
31
+ require_relative "cronwatch/alerts/ses"
32
+ require_relative "cronwatch/alerts/twilio"
33
+ require_relative "cronwatch/alerts/sentry"
34
+ require_relative "cronwatch/alerts/honeybadger"
35
+ require_relative "cronwatch/alerts/datadog"
36
+ require_relative "cronwatch/alerts/rollbar"
37
+ require_relative "cronwatch/alerts/bugsnag"
38
+ require_relative "cronwatch/alerts/newrelic"
23
39
  require_relative "cronwatch/client"
24
40
 
25
41
  # Cron and scheduled-job monitoring that lives inside your app.
@@ -32,7 +48,7 @@ require_relative "cronwatch/client"
32
48
  module Cronwatch
33
49
  # The options Cronwatch.configure sets. Anything left unset takes the client's default.
34
50
  class Configuration
35
- OPTIONS = %i[store alerts triage cron_secret retention defaults redact deliver now on_error].freeze
51
+ OPTIONS = %i[store alerts triage cron_secret retention defaults redact deliver now on_error sources].freeze
36
52
 
37
53
  attr_accessor(*OPTIONS)
38
54
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cronwatch
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jon C. Phillips
@@ -35,10 +35,25 @@ files:
35
35
  - lib/cronwatch.rb
36
36
  - lib/cronwatch/abort_signal.rb
37
37
  - lib/cronwatch/active_record.rb
38
+ - lib/cronwatch/alerts/bugsnag.rb
38
39
  - lib/cronwatch/alerts/console.rb
39
40
  - lib/cronwatch/alerts/custom.rb
41
+ - lib/cronwatch/alerts/datadog.rb
40
42
  - lib/cronwatch/alerts/discord.rb
43
+ - lib/cronwatch/alerts/email.rb
44
+ - lib/cronwatch/alerts/honeybadger.rb
45
+ - lib/cronwatch/alerts/mailgun.rb
46
+ - lib/cronwatch/alerts/newrelic.rb
47
+ - lib/cronwatch/alerts/postmark.rb
48
+ - lib/cronwatch/alerts/provider.rb
49
+ - lib/cronwatch/alerts/resend.rb
50
+ - lib/cronwatch/alerts/rollbar.rb
51
+ - lib/cronwatch/alerts/sendgrid.rb
52
+ - lib/cronwatch/alerts/sentry.rb
53
+ - lib/cronwatch/alerts/ses.rb
54
+ - lib/cronwatch/alerts/sigv4.rb
41
55
  - lib/cronwatch/alerts/slack.rb
56
+ - lib/cronwatch/alerts/twilio.rb
42
57
  - lib/cronwatch/alerts/webhook.rb
43
58
  - lib/cronwatch/client.rb
44
59
  - lib/cronwatch/cron_pattern.rb
@@ -52,11 +67,13 @@ files:
52
67
  - lib/cronwatch/js.rb
53
68
  - lib/cronwatch/monitored.rb
54
69
  - lib/cronwatch/output.rb
70
+ - lib/cronwatch/pg_cron.rb
55
71
  - lib/cronwatch/rails.rb
56
72
  - lib/cronwatch/rails/active_job.rb
57
73
  - lib/cronwatch/rails/check_job.rb
58
74
  - lib/cronwatch/rails/railtie.rb
59
75
  - lib/cronwatch/rails/tasks.rb
76
+ - lib/cronwatch/run_handle.rb
60
77
  - lib/cronwatch/schedule.rb
61
78
  - lib/cronwatch/scheduler.rb
62
79
  - lib/cronwatch/serialize.rb
@@ -72,6 +89,7 @@ files:
72
89
  - lib/cronwatch/web.rb
73
90
  - lib/cronwatch/web/app.rb
74
91
  - lib/cronwatch/web/html.rb
92
+ - lib/cronwatch/web/origin.rb
75
93
  - lib/cronwatch/zone.rb
76
94
  - lib/generators/cronwatch/install/install_generator.rb
77
95
  homepage: https://cronwatch.dev