cronwatch 0.3.1
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +268 -0
- data/lib/cronwatch/abort_signal.rb +45 -0
- data/lib/cronwatch/active_record.rb +11 -0
- data/lib/cronwatch/alerts/console.rb +22 -0
- data/lib/cronwatch/alerts/custom.rb +26 -0
- data/lib/cronwatch/alerts/discord.rb +52 -0
- data/lib/cronwatch/alerts/slack.rb +58 -0
- data/lib/cronwatch/alerts/webhook.rb +46 -0
- data/lib/cronwatch/client.rb +925 -0
- data/lib/cronwatch/cron_pattern.rb +277 -0
- data/lib/cronwatch/duration.rb +77 -0
- data/lib/cronwatch/environment.rb +29 -0
- data/lib/cronwatch/evaluate.rb +345 -0
- data/lib/cronwatch/flight.rb +42 -0
- data/lib/cronwatch/format.rb +89 -0
- data/lib/cronwatch/http.rb +52 -0
- data/lib/cronwatch/job.rb +144 -0
- data/lib/cronwatch/js.rb +188 -0
- data/lib/cronwatch/monitored.rb +259 -0
- data/lib/cronwatch/output.rb +199 -0
- data/lib/cronwatch/rails/active_job.rb +55 -0
- data/lib/cronwatch/rails/check_job.rb +32 -0
- data/lib/cronwatch/rails/railtie.rb +37 -0
- data/lib/cronwatch/rails/tasks.rb +12 -0
- data/lib/cronwatch/rails.rb +35 -0
- data/lib/cronwatch/schedule.rb +191 -0
- data/lib/cronwatch/scheduler.rb +763 -0
- data/lib/cronwatch/serialize.rb +51 -0
- data/lib/cronwatch/sidekiq.rb +129 -0
- data/lib/cronwatch/stats.rb +23 -0
- data/lib/cronwatch/stores/active_record.rb +397 -0
- data/lib/cronwatch/stores/memory.rb +163 -0
- data/lib/cronwatch/ticker.rb +59 -0
- data/lib/cronwatch/triage/anthropic.rb +134 -0
- data/lib/cronwatch/types.rb +341 -0
- data/lib/cronwatch/version.rb +6 -0
- data/lib/cronwatch/walker.rb +137 -0
- data/lib/cronwatch/web/app.rb +484 -0
- data/lib/cronwatch/web/html.rb +314 -0
- data/lib/cronwatch/web.rb +17 -0
- data/lib/cronwatch/zone.rb +72 -0
- data/lib/cronwatch.rb +96 -0
- data/lib/generators/cronwatch/install/install_generator.rb +176 -0
- metadata +104 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
class CronPattern
|
|
5
|
+
# Croner's CronDate: a wall-clock time whose fields are moved forward to
|
|
6
|
+
# the next match, a field at a time, spilling into the next month or year
|
|
7
|
+
# as croner does (including its habit of stepping into a day the month
|
|
8
|
+
# does not have and letting the date roll over). Month is 0 based.
|
|
9
|
+
class Walker
|
|
10
|
+
DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
|
|
11
|
+
# [field, the field above it, offset from a field value to its pattern index]
|
|
12
|
+
ORDER = [%i[month year], %i[day month], %i[hour day], %i[minute hour], %i[second minute]].freeze
|
|
13
|
+
OFFSETS = { month: 0, day: -1, hour: 0, minute: 0, second: 0 }.freeze
|
|
14
|
+
|
|
15
|
+
attr_accessor :year, :month, :day, :hour, :minute, :second
|
|
16
|
+
|
|
17
|
+
def initialize(pattern, wall)
|
|
18
|
+
@pattern = pattern
|
|
19
|
+
@year, month, @day, @hour, @minute, @second = wall
|
|
20
|
+
@month = month - 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def apply
|
|
24
|
+
unless month > 11 || month.negative? || day > DAYS_IN_MONTH[month] || day < 1 || hour > 59 || minute > 59 ||
|
|
25
|
+
second > 59 || hour.negative? || minute.negative? || second.negative?
|
|
26
|
+
return false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
first = Time.utc(year + month.div(12), (month % 12) + 1, 1).to_i
|
|
30
|
+
t = Time.at(first + ((day - 1) * 86_400) + (hour * 3600) + (minute * 60) + second).utc
|
|
31
|
+
@year = t.year
|
|
32
|
+
@month = t.month - 1
|
|
33
|
+
@day = t.day
|
|
34
|
+
@hour = t.hour
|
|
35
|
+
@minute = t.min
|
|
36
|
+
@second = t.sec
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Croner's recurse, as a loop. Returns the wall-clock match or nil.
|
|
41
|
+
def search
|
|
42
|
+
level = 0
|
|
43
|
+
loop do
|
|
44
|
+
field, above = ORDER[level]
|
|
45
|
+
found = find_next(field, OFFSETS[field])
|
|
46
|
+
if found > 1
|
|
47
|
+
((level + 1)...ORDER.length).each { |i| set(ORDER[i][0], -OFFSETS[ORDER[i][0]]) }
|
|
48
|
+
if found == 3
|
|
49
|
+
set(above, get(above) + 1)
|
|
50
|
+
set(field, -OFFSETS[field])
|
|
51
|
+
apply
|
|
52
|
+
level = 0
|
|
53
|
+
next
|
|
54
|
+
elsif apply
|
|
55
|
+
level -= 1
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
level += 1
|
|
60
|
+
return [year, month + 1, day, hour, minute, second] if level >= ORDER.length
|
|
61
|
+
return nil if year >= 3000
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def get(field) = instance_variable_get(:"@#{field}")
|
|
68
|
+
def set(field, value) = instance_variable_set(:"@#{field}", value)
|
|
69
|
+
|
|
70
|
+
def table(field)
|
|
71
|
+
case field
|
|
72
|
+
when :month then @pattern.months
|
|
73
|
+
when :day then @pattern.days
|
|
74
|
+
when :hour then @pattern.hours
|
|
75
|
+
when :minute then @pattern.minutes
|
|
76
|
+
else @pattern.seconds
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# 1: the field already matches, 2: moved forward to a match, 3: none left.
|
|
81
|
+
def find_next(field, offset)
|
|
82
|
+
before = get(field)
|
|
83
|
+
values = table(field)
|
|
84
|
+
last_day = last_day_of_month(year, month) if @pattern.last_day_of_month
|
|
85
|
+
first_weekday = weekday(year, month, 1) if field == :day && !@pattern.star_dow
|
|
86
|
+
index = get(field) + offset
|
|
87
|
+
while index < values.length
|
|
88
|
+
matched = values[index]
|
|
89
|
+
if field == :day
|
|
90
|
+
day_of_month = index - offset
|
|
91
|
+
matched = true if @pattern.last_day_of_month && day_of_month == last_day
|
|
92
|
+
unless @pattern.star_dow
|
|
93
|
+
bits = @pattern.weekdays[(first_weekday + (day_of_month - 1)) % 7]
|
|
94
|
+
weekday_ok = bits.positive? && nth_weekday?(year, month, day_of_month, bits)
|
|
95
|
+
matched =
|
|
96
|
+
if @pattern.and_logic || @pattern.star_dom then matched && weekday_ok
|
|
97
|
+
else matched || weekday_ok
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
if matched
|
|
102
|
+
set(field, index - offset)
|
|
103
|
+
return before == get(field) ? 1 : 2
|
|
104
|
+
end
|
|
105
|
+
index += 1
|
|
106
|
+
end
|
|
107
|
+
3
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def last_day_of_month(year, month)
|
|
111
|
+
return DAYS_IN_MONTH[month] unless month == 1
|
|
112
|
+
|
|
113
|
+
(year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?) ? 29 : 28
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 0 is Sunday. A day past the month's end rolls into the next, as Date.UTC does.
|
|
117
|
+
def weekday(year, month, day)
|
|
118
|
+
days = Time.utc(year + month.div(12), (month % 12) + 1, 1).to_i.div(86_400) + day - 1
|
|
119
|
+
(days + 4) % 7
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def nth_weekday?(year, month, day, bits)
|
|
123
|
+
return true if bits == ANY_NTH
|
|
124
|
+
|
|
125
|
+
target = weekday(year, month, day)
|
|
126
|
+
count = (1..day).count { |d| weekday(year, month, d) == target }
|
|
127
|
+
return true if (bits & ANY_NTH).positive? && count <= 5 && (NTH[count - 1] & bits).positive?
|
|
128
|
+
|
|
129
|
+
if (bits & LAST_NTH).positive?
|
|
130
|
+
((day + 1)..last_day_of_month(year, month)).none? { |d| weekday(year, month, d) == target }
|
|
131
|
+
else
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Cronwatch
|
|
8
|
+
# The dashboard and the small JSON API, as a Rack app: the SDK's routes
|
|
9
|
+
# (routes/index.ts) with the same URLs, JSON, auth, CSRF rules and headers,
|
|
10
|
+
# so @cronwatch/mcp works against a Ruby app as it does against a Node one.
|
|
11
|
+
#
|
|
12
|
+
# # config/routes.rb
|
|
13
|
+
# mount Cronwatch::Web.new => "/cronwatch"
|
|
14
|
+
#
|
|
15
|
+
# # config.ru, standalone
|
|
16
|
+
# run Cronwatch::Web.new(CW)
|
|
17
|
+
#
|
|
18
|
+
# token: required to reach anything. Send it as `Authorization: Bearer <token>`,
|
|
19
|
+
# or open the dashboard once with `?token=<token>` and a cookie is set.
|
|
20
|
+
# Defaults to ENV["CRONWATCH_TOKEN"]; an empty string counts as unset.
|
|
21
|
+
# With no token while Cronwatch::Environment is development or
|
|
22
|
+
# test, the app makes a random one and prints a sign-in link to
|
|
23
|
+
# stdout on its first request; with no token otherwise it answers
|
|
24
|
+
# 503. Pass `token: nil` to opt out and serve it open everywhere,
|
|
25
|
+
# for example behind your own auth. /api/check also accepts the
|
|
26
|
+
# client's cron_secret as a bearer, for a platform cron.
|
|
27
|
+
# base_path: where the app is mounted, so links resolve. Defaults to the
|
|
28
|
+
# mount point (SCRIPT_NAME), which is right under Rails' `mount`.
|
|
29
|
+
class Web
|
|
30
|
+
# Tells "token not given" (read CRONWATCH_TOKEN) from "token: nil" (open on purpose).
|
|
31
|
+
UNSET = Object.new.freeze
|
|
32
|
+
COOKIE = "cronwatch_token"
|
|
33
|
+
DEFAULT_RUNS = 20
|
|
34
|
+
MAX_RUNS = 500
|
|
35
|
+
COOKIE_MAX_AGE = 60 * 60 * 24 * 30
|
|
36
|
+
|
|
37
|
+
CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; " \
|
|
38
|
+
"frame-ancestors 'none'; base-uri 'none'"
|
|
39
|
+
# same-origin rather than no-referrer: under no-referrer browsers send
|
|
40
|
+
# `Origin: null` on form posts, which the CSRF check would refuse, and the
|
|
41
|
+
# forms redirect back to the page named by the same-origin Referer.
|
|
42
|
+
SECURITY_HEADERS = {
|
|
43
|
+
"x-content-type-options" => "nosniff", "referrer-policy" => "same-origin", "x-robots-tag" => "noindex",
|
|
44
|
+
}.freeze
|
|
45
|
+
BEARER = Regexp.new("\\ABearer[#{JS::WHITESPACE}]+", Regexp::IGNORECASE)
|
|
46
|
+
DECIMAL = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
|
|
47
|
+
RADIX = { "x" => 16, "o" => 8, "b" => 2 }.freeze
|
|
48
|
+
|
|
49
|
+
def initialize(client = nil, token: UNSET, base_path: nil)
|
|
50
|
+
@client = client
|
|
51
|
+
@opted_out = token.nil?
|
|
52
|
+
given = token.equal?(UNSET) ? nil : token
|
|
53
|
+
@token = @opted_out ? nil : [given, ENV.fetch("CRONWATCH_TOKEN", nil)].map(&:to_s).find { |t| !t.empty? }
|
|
54
|
+
@base_path = base_path&.to_s&.sub(%r{/+\z}, "")
|
|
55
|
+
# A Rack app cannot reliably tell a local caller from a remote one
|
|
56
|
+
# (proxies, tunnels and a server bound to every interface all look
|
|
57
|
+
# alike), so development gets a token too: made here, and shown only in
|
|
58
|
+
# the server log.
|
|
59
|
+
@generated = @token.nil? && !@opted_out && Client.development?
|
|
60
|
+
@token = Web.development_token if @generated
|
|
61
|
+
@announced = false
|
|
62
|
+
@announce_lock = Mutex.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A token for one app in development, when none is configured: 32 random
|
|
66
|
+
# bytes, base64url (43 characters).
|
|
67
|
+
def self.development_token
|
|
68
|
+
Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The line a development token is announced with, printed once to stdout
|
|
72
|
+
# on the app's first request. `origin` is that request's origin (scheme,
|
|
73
|
+
# host and any port), `base` the base path without a trailing slash (""
|
|
74
|
+
# when mounted at the root).
|
|
75
|
+
def self.development_sign_in_line(origin, base, token)
|
|
76
|
+
"[cronwatch] CRONWATCH_TOKEN is not set, so this development server made a token for the dashboard. " \
|
|
77
|
+
"Sign in: #{origin}#{base}/?token=#{token}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The client given, or Cronwatch.client when none was.
|
|
81
|
+
def client
|
|
82
|
+
@client || Cronwatch.client
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def call(env)
|
|
86
|
+
wants_html = true
|
|
87
|
+
base = @base_path || env["SCRIPT_NAME"].to_s.sub(%r{/+\z}, "")
|
|
88
|
+
begin
|
|
89
|
+
request = Request.new(env)
|
|
90
|
+
path = strip_base(request.pathname, base)
|
|
91
|
+
wants_html = !path.start_with?("/api")
|
|
92
|
+
serve(request, path, wants_html, base)
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
begin
|
|
95
|
+
client.report(e, "routes")
|
|
96
|
+
rescue StandardError
|
|
97
|
+
# Reporting must not turn a 500 into an exception.
|
|
98
|
+
end
|
|
99
|
+
if wants_html
|
|
100
|
+
html(HTML.message_page("Something went wrong", "The request failed and the error was reported.", base), 500)
|
|
101
|
+
else
|
|
102
|
+
api({ ok: false, error: "Internal error" }, 500)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def serve(request, path, wants_html, base)
|
|
110
|
+
cw = client
|
|
111
|
+
method = request.verb
|
|
112
|
+
|
|
113
|
+
announce(request, base) if @generated && !@announced
|
|
114
|
+
|
|
115
|
+
# No token outside development: fail closed.
|
|
116
|
+
if !@token && !@opted_out
|
|
117
|
+
return wants_html ? html(HTML.message_page("CronWatch routes are locked", "Set CRONWATCH_TOKEN (or pass token: to Cronwatch::Web.new), or pass token: nil to serve them open behind your own auth.", base), 503) : api({ ok: false, error: "CRONWATCH_TOKEN is not set" }, 503)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
if method != "GET" && method != "HEAD" && cross_site?(request)
|
|
121
|
+
return wants_html ? html(HTML.message_page("Cross-site request refused", "Changes can only be made from the dashboard itself.", base), 403) : api({ ok: false, error: "Cross-site request refused" }, 403)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
authorization = request.header("authorization")
|
|
125
|
+
bearer = authorization&.sub(BEARER, "")
|
|
126
|
+
if @token
|
|
127
|
+
# ?token= is only the sign-in that moves the token into a cookie.
|
|
128
|
+
query = wants_html && method == "GET" ? request.query("token") : nil
|
|
129
|
+
cookie = read_cookie(request, COOKIE)
|
|
130
|
+
secret = cw.cron_secret
|
|
131
|
+
cron_secret_ok = path == "/api/check" && !bearer.nil? && !secret.nil? && HTTP.constant_time_equal?(bearer, secret)
|
|
132
|
+
token_ok =
|
|
133
|
+
if !bearer.nil? then HTTP.constant_time_equal?(bearer, @token)
|
|
134
|
+
elsif !query.nil? then HTTP.constant_time_equal?(query, @token)
|
|
135
|
+
else !cookie.nil? && HTTP.constant_time_equal?(cookie, cookie_value(@token))
|
|
136
|
+
end
|
|
137
|
+
unless cron_secret_ok || token_ok
|
|
138
|
+
if @generated
|
|
139
|
+
return wants_html ? html(HTML.message_page("Sign in", "CRONWATCH_TOKEN is not set, so this development server made a token. The sign-in link is in the server log: open it once and this browser stays signed in.", base), 401) : api({ ok: false, error: "Unauthorized: CRONWATCH_TOKEN is not set, so this development server made a token; it is in the server log" }, 401)
|
|
140
|
+
end
|
|
141
|
+
return wants_html ? html(HTML.message_page("Sign in", "Open this page with ?token=<your CRONWATCH_TOKEN> once and it will stay signed in.", base), 401) : api({ ok: false, error: "Unauthorized" }, 401)
|
|
142
|
+
end
|
|
143
|
+
unless query.nil?
|
|
144
|
+
# Move the token from the URL into a cookie so it is not in history or logs.
|
|
145
|
+
secure = request.https? ? "; Secure" : ""
|
|
146
|
+
return redirect(request.pathname + request.search_without("token"),
|
|
147
|
+
"set-cookie" => "#{COOKIE}=#{cookie_value(@token)}; Path=#{base.empty? ? "/" : base}; HttpOnly; SameSite=Lax; Max-Age=#{COOKIE_MAX_AGE}#{secure}")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
parts = path.split("/").reject(&:empty?).map { |part| safe_decode(part) }
|
|
152
|
+
if parts.include?(nil)
|
|
153
|
+
return wants_html ? html(HTML.message_page("Bad request", "The path is not valid.", base), 400) : api({ ok: false, error: "Bad path" }, 400)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# HTML
|
|
157
|
+
if method == "GET" && path == "/"
|
|
158
|
+
entries = cw.jobs_with_runs(20)
|
|
159
|
+
runs_by_job = entries.to_h { |entry| [entry.job.name, entry.runs] }
|
|
160
|
+
return html(HTML.dashboard_page(entries.map(&:job), runs_by_job, cw.now, base, nil))
|
|
161
|
+
end
|
|
162
|
+
if method == "GET" && parts[0] == "jobs" && parts.length == 2
|
|
163
|
+
job = cw.job_summary(parts[1])
|
|
164
|
+
return html(HTML.message_page("No such job", "#{parts[1]} is not in the store.", base), 404) unless job
|
|
165
|
+
|
|
166
|
+
return html(HTML.job_page(job, cw.runs(job.name, 50), cw.now, base))
|
|
167
|
+
end
|
|
168
|
+
if method == "POST" && path == "/check"
|
|
169
|
+
cw.check
|
|
170
|
+
return redirect_back(request, base)
|
|
171
|
+
end
|
|
172
|
+
if method == "POST" && parts[0] == "jobs" && parts.length == 3
|
|
173
|
+
name = parts[1]
|
|
174
|
+
action = parts[2]
|
|
175
|
+
if action == "forget"
|
|
176
|
+
cw.forget(name)
|
|
177
|
+
return redirect("#{base}/")
|
|
178
|
+
end
|
|
179
|
+
return html(HTML.message_page("Not found", path, base), 404) unless %w[silence unsilence].include?(action)
|
|
180
|
+
return html(HTML.message_page("No such job", "#{name} is not in the store.", base), 404) unless cw.job_summary(name)
|
|
181
|
+
|
|
182
|
+
if action == "silence"
|
|
183
|
+
begin
|
|
184
|
+
duration = silence_duration(read_body(request)["for"])
|
|
185
|
+
rescue ArgumentError => e
|
|
186
|
+
return html(HTML.message_page("Not silenced", e.message, base), 400)
|
|
187
|
+
end
|
|
188
|
+
cw.silence(name, duration)
|
|
189
|
+
else
|
|
190
|
+
cw.unsilence(name)
|
|
191
|
+
end
|
|
192
|
+
return redirect_back(request, base)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
return serve_api(request, method, parts.drop(1), bearer) if parts[0] == "api"
|
|
196
|
+
|
|
197
|
+
html(HTML.message_page("Not found", path, base), 404)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def serve_api(request, method, rest, bearer)
|
|
201
|
+
cw = client
|
|
202
|
+
return api({ ok: true, jobs: cw.jobs }) if method == "GET" && rest[0] == "jobs" && rest.length == 1
|
|
203
|
+
|
|
204
|
+
if rest[0] == "jobs" && rest.length == 2
|
|
205
|
+
name = rest[1]
|
|
206
|
+
if method == "GET"
|
|
207
|
+
job = cw.job_summary(name)
|
|
208
|
+
return api({ ok: false, error: "No such job" }, 404) unless job
|
|
209
|
+
|
|
210
|
+
return api({ ok: true, job: job, runs: cw.runs(name, runs_limit(request.query("runs"))) })
|
|
211
|
+
end
|
|
212
|
+
if method == "DELETE"
|
|
213
|
+
return api({ ok: false, error: "No such job" }, 404) unless cw.job_summary(name)
|
|
214
|
+
|
|
215
|
+
cw.forget(name)
|
|
216
|
+
return api({ ok: true })
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
if method == "POST" && rest[0] == "jobs" && rest.length == 3
|
|
220
|
+
name = rest[1]
|
|
221
|
+
return api({ ok: false, error: "No such job" }, 404) unless cw.job_summary(name)
|
|
222
|
+
|
|
223
|
+
if rest[2] == "silence"
|
|
224
|
+
body = read_body(request)
|
|
225
|
+
begin
|
|
226
|
+
duration = silence_duration(body.key?("for") ? body["for"] : request.query("for"))
|
|
227
|
+
rescue ArgumentError => e
|
|
228
|
+
return api({ ok: false, error: e.message }, 400)
|
|
229
|
+
end
|
|
230
|
+
return api({ ok: true, state: cw.silence(name, duration) })
|
|
231
|
+
end
|
|
232
|
+
return api({ ok: true, state: cw.unsilence(name) }) if rest[2] == "unsilence"
|
|
233
|
+
end
|
|
234
|
+
if rest[0] == "check" && rest.length == 1
|
|
235
|
+
# A page cannot send an Authorization header cross-site, so a GET
|
|
236
|
+
# may only run the check when it carries a bearer (token or cron secret).
|
|
237
|
+
if method == "GET" && bearer.nil?
|
|
238
|
+
return api({ ok: false, error: "Use POST, or GET with an Authorization bearer" }, 405, "allow" => "POST")
|
|
239
|
+
end
|
|
240
|
+
return api({ "ok" => true }.merge(cw.check.to_h)) if %w[GET POST].include?(method)
|
|
241
|
+
end
|
|
242
|
+
if method == "GET" && rest[0] == "runs" && rest.length == 2
|
|
243
|
+
run = cw.get_run(rest[1])
|
|
244
|
+
return run ? api({ ok: true, run: run }) : api({ ok: false, error: "No such run" }, 404)
|
|
245
|
+
end
|
|
246
|
+
api({ ok: false, error: "Not found" }, 404)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Prints the development sign-in link, once per app.
|
|
250
|
+
def announce(request, base)
|
|
251
|
+
first = @announce_lock.synchronize do
|
|
252
|
+
next false if @announced
|
|
253
|
+
|
|
254
|
+
@announced = true
|
|
255
|
+
end
|
|
256
|
+
return unless first
|
|
257
|
+
|
|
258
|
+
$stdout.puts(Web.development_sign_in_line(request.origin, base, @token))
|
|
259
|
+
$stdout.flush
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The cookie holds a digest of the token, so a leaked cookie does not reveal the bearer token itself.
|
|
263
|
+
def cookie_value(token)
|
|
264
|
+
Digest::SHA256.hexdigest("cronwatch-cookie:#{token}")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def strip_base(pathname, base)
|
|
268
|
+
path = pathname.start_with?(base) ? pathname[base.length..] : pathname
|
|
269
|
+
path = "/" if path.empty?
|
|
270
|
+
path = path[0...-1] if path.length > 1 && path.end_with?("/")
|
|
271
|
+
path
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def read_cookie(request, name)
|
|
275
|
+
header = request.header("cookie")
|
|
276
|
+
return nil if header.nil? || header.empty?
|
|
277
|
+
|
|
278
|
+
header.split(";").each do |part|
|
|
279
|
+
key, *rest = JS.trim(part).split("=", -1)
|
|
280
|
+
# A malformed escape counts as no cookie.
|
|
281
|
+
return safe_decode(rest.join("=")) if key == name
|
|
282
|
+
end
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# decodeURIComponent, or nil where it would throw: a bad escape, or bytes that are not UTF-8.
|
|
287
|
+
def safe_decode(value)
|
|
288
|
+
return nil if value.match?(/%(?![0-9A-Fa-f]{2})/)
|
|
289
|
+
|
|
290
|
+
decoded = value.b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
|
|
291
|
+
decoded.valid_encoding? ? decoded : nil
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# A browser attaches Origin or Sec-Fetch-Site to a cross-site form post, and
|
|
295
|
+
# a page cannot forge either. Non-browser clients send neither.
|
|
296
|
+
def cross_site?(request)
|
|
297
|
+
origin = request.header("origin")
|
|
298
|
+
return true if !origin.nil? && origin != request.origin
|
|
299
|
+
|
|
300
|
+
site = request.header("sec-fetch-site")
|
|
301
|
+
!site.nil? && site != "same-origin" && site != "none"
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The form fields or JSON object of a request, each value as String(value)
|
|
305
|
+
# gives it in JavaScript. The body is read as the SDK's request.json()
|
|
306
|
+
# reads it: bytes that are not UTF-8 become U+FFFD, and a leading byte
|
|
307
|
+
# order mark is dropped.
|
|
308
|
+
def read_body(request)
|
|
309
|
+
type = request.header("content-type") || ""
|
|
310
|
+
if type.include?("application/json")
|
|
311
|
+
data = JSON.parse(Output.utf8(request.body).delete_prefix(""))
|
|
312
|
+
return data.to_h { |k, v| [k.to_s, js_string(v)] } if data.is_a?(Hash)
|
|
313
|
+
return data.each_with_index.to_h { |v, i| [i.to_s, js_string(v)] } if data.is_a?(Array)
|
|
314
|
+
elsif type.include?("application/x-www-form-urlencoded") || type.include?("multipart/form-data")
|
|
315
|
+
return request.form.transform_values { |v| form_value(v) }
|
|
316
|
+
end
|
|
317
|
+
{}
|
|
318
|
+
rescue StandardError
|
|
319
|
+
{}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# String(value) for a parsed JSON value.
|
|
323
|
+
def js_string(value)
|
|
324
|
+
case value
|
|
325
|
+
when nil then "null"
|
|
326
|
+
when Array then value.map { |v| v.nil? ? "" : js_string(v) }.join(",")
|
|
327
|
+
when Hash then "[object Object]"
|
|
328
|
+
when Numeric then JS.number(value)
|
|
329
|
+
else value.to_s
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# String(value) for a form field Rack parsed: a file is "[object File]".
|
|
334
|
+
def form_value(value)
|
|
335
|
+
case value
|
|
336
|
+
when String then Output.utf8(value)
|
|
337
|
+
when Hash then value.key?(:tempfile) ? "[object File]" : "[object Object]"
|
|
338
|
+
when Array then value.map { |v| form_value(v) }.join(",")
|
|
339
|
+
else js_string(value)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Absent means one hour; a number or numeric string is milliseconds. Raises on anything else.
|
|
344
|
+
def silence_duration(value)
|
|
345
|
+
return "1h" if value.nil?
|
|
346
|
+
|
|
347
|
+
text = JS.trim(value.to_s)
|
|
348
|
+
duration = text.match?(/\A\d+(\.\d+)?\z/) ? whole(Float(text)) : text
|
|
349
|
+
Duration.parse(duration, "silence duration")
|
|
350
|
+
duration
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def runs_limit(value)
|
|
354
|
+
n = value.nil? || JS.trim(value).empty? ? Float::NAN : js_number(value)
|
|
355
|
+
JS.finite?(n) ? n.truncate.clamp(1, MAX_RUNS) : DEFAULT_RUNS
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Number(string): decimal, 0x/0o/0b, Infinity, or NaN.
|
|
359
|
+
def js_number(value)
|
|
360
|
+
text = JS.trim(value)
|
|
361
|
+
return 0 if text.empty?
|
|
362
|
+
# Ruby's Float() wants a digit on both sides of the point; JavaScript does not.
|
|
363
|
+
return Float(text.sub(/\A([+-]?)\./, "\\10.").sub(/\.(?=[eE]|\z)/, ".0")) if DECIMAL.match?(text)
|
|
364
|
+
return text.start_with?("-") ? -Float::INFINITY : Float::INFINITY if /\A[+-]?Infinity\z/.match?(text)
|
|
365
|
+
|
|
366
|
+
if (m = /\A0([xXoObB])([0-9a-fA-F]+)\z/.match(text))
|
|
367
|
+
return Integer(m[2], RADIX.fetch(m[1].downcase))
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
Float::NAN
|
|
371
|
+
rescue ArgumentError
|
|
372
|
+
Float::NAN
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def whole(number)
|
|
376
|
+
number.finite? && number == number.floor ? number.to_i : number
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def redirect_back(request, base)
|
|
380
|
+
referer = request.header("referer") || ""
|
|
381
|
+
redirect(referer.start_with?("#{request.origin}/") ? referer : "#{base}/")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def api(body, status = 200, headers = {})
|
|
385
|
+
text = JS.json(body)
|
|
386
|
+
[status, {
|
|
387
|
+
"content-type" => "application/json; charset=utf-8", "cache-control" => "no-store",
|
|
388
|
+
"content-length" => text.bytesize.to_s, **SECURITY_HEADERS, **headers,
|
|
389
|
+
}, [text]]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def redirect(location, headers = {})
|
|
393
|
+
[303, { "location" => location, "cache-control" => "no-store", **SECURITY_HEADERS, **headers }, []]
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def html(body, status = 200)
|
|
397
|
+
[status, {
|
|
398
|
+
"content-type" => "text/html; charset=utf-8", "cache-control" => "no-store",
|
|
399
|
+
"content-security-policy" => CSP, "x-frame-options" => "DENY", "content-length" => body.bytesize.to_s,
|
|
400
|
+
**SECURITY_HEADERS,
|
|
401
|
+
}, [body]]
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# What the routes need from a Rack env, read the way the SDK reads a fetch Request.
|
|
405
|
+
class Request
|
|
406
|
+
attr_reader :env
|
|
407
|
+
|
|
408
|
+
def initialize(env)
|
|
409
|
+
@env = env
|
|
410
|
+
@rack = Rack::Request.new(env)
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def verb
|
|
414
|
+
@env["REQUEST_METHOD"].to_s.upcase
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# The path as the browser asked for it, still percent-encoded, mount point included.
|
|
418
|
+
def pathname
|
|
419
|
+
path = "#{@env["SCRIPT_NAME"]}#{@env["PATH_INFO"]}"
|
|
420
|
+
path.empty? ? "/" : path
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def header(name)
|
|
424
|
+
key = name.tr("-", "_").upcase
|
|
425
|
+
key = "HTTP_#{key}" unless %w[CONTENT_TYPE CONTENT_LENGTH].include?(key)
|
|
426
|
+
@env[key]
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# scheme://host[:port], the page's origin.
|
|
430
|
+
def origin
|
|
431
|
+
@rack.base_url
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def https?
|
|
435
|
+
@rack.scheme == "https"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# URLSearchParams#get: the first value, or nil.
|
|
439
|
+
def query(name)
|
|
440
|
+
pair = Request.parse_query(@env["QUERY_STRING"].to_s).find { |k, _| k == name }
|
|
441
|
+
pair && pair[1]
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# The query string with a parameter removed, "" when nothing is left, as
|
|
445
|
+
# URL#search reads after searchParams.delete.
|
|
446
|
+
def search_without(name)
|
|
447
|
+
pairs = Request.parse_query(@env["QUERY_STRING"].to_s).reject { |k, _| k == name }
|
|
448
|
+
pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def body
|
|
452
|
+
input = @env["rack.input"]
|
|
453
|
+
return "" unless input
|
|
454
|
+
|
|
455
|
+
input.rewind if input.respond_to?(:rewind)
|
|
456
|
+
text = input.read.to_s
|
|
457
|
+
input.rewind if input.respond_to?(:rewind)
|
|
458
|
+
text.force_encoding(Encoding::UTF_8)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The form's fields, as Rack parses them. Rack keeps what it parsed, so
|
|
462
|
+
# a body Rack::MethodOverride (or anything else) already read, which
|
|
463
|
+
# cannot be read twice, still gives its fields.
|
|
464
|
+
def form
|
|
465
|
+
fields = @rack.POST
|
|
466
|
+
fields.each_with_object({}) { |(k, v), out| out[Output.utf8(k)] = v }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# application/x-www-form-urlencoded parsing as URLSearchParams does it:
|
|
470
|
+
# "+" is a space, and a bad escape is kept as written rather than refused.
|
|
471
|
+
def self.parse_query(text)
|
|
472
|
+
text = text.delete_prefix("?")
|
|
473
|
+
text.split("&").reject(&:empty?).map do |pair|
|
|
474
|
+
key, value = pair.include?("=") ? pair.split("=", 2) : [pair, ""]
|
|
475
|
+
[decode(key), decode(value)]
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def self.decode(text)
|
|
480
|
+
text.tr("+", " ").b.gsub(/%([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8).scrub
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|