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.
- checksums.yaml +4 -4
- data/README.md +19 -3
- data/lib/cronwatch/alerts/bugsnag.rb +69 -0
- data/lib/cronwatch/alerts/custom.rb +9 -2
- data/lib/cronwatch/alerts/datadog.rb +54 -0
- data/lib/cronwatch/alerts/email.rb +78 -0
- data/lib/cronwatch/alerts/honeybadger.rb +59 -0
- data/lib/cronwatch/alerts/mailgun.rb +36 -0
- data/lib/cronwatch/alerts/newrelic.rb +52 -0
- data/lib/cronwatch/alerts/postmark.rb +41 -0
- data/lib/cronwatch/alerts/provider.rb +159 -0
- data/lib/cronwatch/alerts/resend.rb +39 -0
- data/lib/cronwatch/alerts/rollbar.rb +53 -0
- data/lib/cronwatch/alerts/sendgrid.rb +41 -0
- data/lib/cronwatch/alerts/sentry.rb +94 -0
- data/lib/cronwatch/alerts/ses.rb +72 -0
- data/lib/cronwatch/alerts/sigv4.rb +84 -0
- data/lib/cronwatch/alerts/twilio.rb +206 -0
- data/lib/cronwatch/alerts/webhook.rb +7 -2
- data/lib/cronwatch/client.rb +473 -52
- data/lib/cronwatch/evaluate.rb +14 -1
- data/lib/cronwatch/format.rb +10 -4
- data/lib/cronwatch/http.rb +67 -6
- data/lib/cronwatch/job.rb +17 -0
- data/lib/cronwatch/pg_cron.rb +524 -0
- data/lib/cronwatch/run_handle.rb +203 -0
- data/lib/cronwatch/stores/active_record.rb +23 -0
- data/lib/cronwatch/stores/memory.rb +33 -10
- data/lib/cronwatch/types.rb +1 -0
- data/lib/cronwatch/version.rb +1 -1
- data/lib/cronwatch/web/app.rb +38 -11
- data/lib/cronwatch/web/origin.rb +133 -0
- data/lib/cronwatch/web.rb +1 -0
- data/lib/cronwatch.rb +17 -1
- metadata +19 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Sends alerts as email through Resend
|
|
6
|
+
# (https://resend.com/docs/api-reference/emails/send-email).
|
|
7
|
+
#
|
|
8
|
+
# Cronwatch::Alerts::Resend.new(api_key: ENV.fetch("RESEND_API_KEY"),
|
|
9
|
+
# from: "CronWatch <alerts@example.com>", to: "ops@example.com")
|
|
10
|
+
class Resend
|
|
11
|
+
ENDPOINT = "https://api.resend.com/emails"
|
|
12
|
+
|
|
13
|
+
attr_reader :name
|
|
14
|
+
|
|
15
|
+
def initialize(api_key:, from:, to:, subject_prefix: nil, link: nil, http: HTTP.default)
|
|
16
|
+
@api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Resend needs an api_key")
|
|
17
|
+
@to = Email.recipients("Resend", from, to)
|
|
18
|
+
@from = from
|
|
19
|
+
@subject_prefix = subject_prefix
|
|
20
|
+
@link = link
|
|
21
|
+
@http = http
|
|
22
|
+
@name = "resend"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(alert)
|
|
26
|
+
email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
|
|
27
|
+
headers = {
|
|
28
|
+
"content-type" => "application/json",
|
|
29
|
+
"authorization" => "Bearer #{@api_key}",
|
|
30
|
+
# The same alert sent twice within 24 hours is delivered once.
|
|
31
|
+
"idempotency-key" => "cronwatch-#{Provider.alert_id(alert)}",
|
|
32
|
+
}
|
|
33
|
+
body = JS.json({ "from" => email.from, "to" => email.to, "subject" => email.subject, "text" => email.text, "html" => email.html })
|
|
34
|
+
Provider.post(@http, "Resend", ENDPOINT, headers, body, [@api_key])
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Reports alerts to Rollbar, one item per job and alert type
|
|
6
|
+
# (https://docs.rollbar.com/reference/create-item). `access_token` needs
|
|
7
|
+
# the post_server_item scope. Recoveries go too, as info items, unless
|
|
8
|
+
# `recovered: false`.
|
|
9
|
+
class Rollbar
|
|
10
|
+
ENDPOINT = "https://api.rollbar.com/api/1/item/"
|
|
11
|
+
|
|
12
|
+
attr_reader :name
|
|
13
|
+
|
|
14
|
+
def initialize(access_token:, environment: nil, recovered: true, link: nil, http: HTTP.default)
|
|
15
|
+
@access_token = Provider.require_credential(access_token, "Cronwatch::Alerts::Rollbar needs an access_token")
|
|
16
|
+
@environment = environment
|
|
17
|
+
@recovered = recovered
|
|
18
|
+
@link = link
|
|
19
|
+
@http = http
|
|
20
|
+
@name = "rollbar"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(alert)
|
|
24
|
+
return if alert.type == :recovered && @recovered == false
|
|
25
|
+
|
|
26
|
+
link = Provider.link_for(@link, alert)
|
|
27
|
+
type = alert.type.to_s
|
|
28
|
+
custom = { "job" => alert.job, "type" => type }
|
|
29
|
+
custom["triage"] = alert.triage if Provider.present?(alert.triage)
|
|
30
|
+
custom["link"] = link if link
|
|
31
|
+
custom["details"] = Naming.to_json_value(alert.details)
|
|
32
|
+
custom["run"] = Provider.run_summary(alert)
|
|
33
|
+
item = {
|
|
34
|
+
"data" => {
|
|
35
|
+
"environment" => Provider.cut(@environment.nil? ? "production" : @environment.to_s, 255),
|
|
36
|
+
"level" => Provider.severity(alert.type),
|
|
37
|
+
"timestamp" => alert.at.div(1000),
|
|
38
|
+
"title" => Provider.cut(alert.title, 255),
|
|
39
|
+
# Rollbar hashes a fingerprint longer than 40 characters itself.
|
|
40
|
+
"fingerprint" => "cronwatch:#{alert.job}:#{type}",
|
|
41
|
+
"uuid" => Provider.as_uuid(Provider.alert_id(alert)),
|
|
42
|
+
"body" => { "message" => { "body" => alert.message } },
|
|
43
|
+
"custom" => custom,
|
|
44
|
+
"notifier" => { "name" => "cronwatch" },
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
headers = { "content-type" => "application/json", "x-rollbar-access-token" => @access_token }
|
|
48
|
+
Provider.post(@http, "Rollbar", ENDPOINT, headers, JS.json(item), [@access_token])
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Sends alerts as email through SendGrid
|
|
6
|
+
# (https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send).
|
|
7
|
+
# `region: "eu"` for an EU regional subuser.
|
|
8
|
+
class Sendgrid
|
|
9
|
+
attr_reader :name
|
|
10
|
+
|
|
11
|
+
def initialize(api_key:, from:, to:, region: nil, subject_prefix: nil, link: nil, http: HTTP.default)
|
|
12
|
+
@api_key = Provider.require_credential(api_key, "Cronwatch::Alerts::Sendgrid needs an api_key")
|
|
13
|
+
@to = Email.recipients("Sendgrid", from, to)
|
|
14
|
+
@from = from
|
|
15
|
+
@url = region.to_s == "eu" ? "https://api.eu.sendgrid.com/v3/mail/send" : "https://api.sendgrid.com/v3/mail/send"
|
|
16
|
+
@subject_prefix = subject_prefix
|
|
17
|
+
@link = link
|
|
18
|
+
@http = http
|
|
19
|
+
@name = "sendgrid"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(alert)
|
|
23
|
+
email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
|
|
24
|
+
body = JS.json({
|
|
25
|
+
"personalizations" => [{ "to" => email.to.map { |a| Email.parse_address(a) } }],
|
|
26
|
+
"from" => Email.parse_address(email.from),
|
|
27
|
+
"subject" => email.subject,
|
|
28
|
+
# text/plain must come before text/html.
|
|
29
|
+
"content" => [
|
|
30
|
+
{ "type" => "text/plain", "value" => email.text },
|
|
31
|
+
{ "type" => "text/html", "value" => email.html },
|
|
32
|
+
],
|
|
33
|
+
"categories" => ["cronwatch"],
|
|
34
|
+
})
|
|
35
|
+
headers = { "content-type" => "application/json", "authorization" => "Bearer #{@api_key}" }
|
|
36
|
+
Provider.post(@http, "SendGrid", @url, headers, body, [@api_key])
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Cronwatch
|
|
6
|
+
module Alerts
|
|
7
|
+
# Sends alerts to Sentry as events through the envelope endpoint, one
|
|
8
|
+
# issue per job and alert type. Recoveries go too, as info events, unless
|
|
9
|
+
# `recovered: false`.
|
|
10
|
+
#
|
|
11
|
+
# Cronwatch::Alerts::Sentry.new(dsn: ENV.fetch("SENTRY_DSN"), environment: "production")
|
|
12
|
+
#
|
|
13
|
+
# Envelopes: https://develop.sentry.dev/sdk/data-model/envelopes/
|
|
14
|
+
# Event payload: https://develop.sentry.dev/sdk/data-model/event-payloads/
|
|
15
|
+
class Sentry
|
|
16
|
+
Dsn = Struct.new(:endpoint, :public_key, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
attr_reader :name
|
|
19
|
+
|
|
20
|
+
def initialize(dsn:, environment: nil, release: nil, recovered: true, link: nil, http: HTTP.default)
|
|
21
|
+
dsn = Provider.require_credential(dsn, "Cronwatch::Alerts::Sentry needs a dsn")
|
|
22
|
+
parsed = Sentry.parse_dsn(dsn)
|
|
23
|
+
@endpoint = parsed.endpoint
|
|
24
|
+
@public_key = parsed.public_key
|
|
25
|
+
@environment = environment
|
|
26
|
+
@release = release
|
|
27
|
+
@recovered = recovered
|
|
28
|
+
@link = link
|
|
29
|
+
@http = http
|
|
30
|
+
@name = "sentry"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# "https://<key>@<host>/<project>" as the envelope endpoint and the public key.
|
|
34
|
+
def self.parse_dsn(dsn)
|
|
35
|
+
uri = begin
|
|
36
|
+
URI.parse(dsn.to_s)
|
|
37
|
+
rescue URI::Error
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
raise ArgumentError, "Cronwatch::Alerts::Sentry needs a valid dsn" unless uri&.scheme && uri.host
|
|
41
|
+
|
|
42
|
+
segments = uri.path.to_s.split("/").reject(&:empty?)
|
|
43
|
+
project = segments.pop
|
|
44
|
+
user = uri.user
|
|
45
|
+
if user.nil? || user.empty? || project.nil? || !/\A\d+\z/.match?(project)
|
|
46
|
+
raise ArgumentError, "Cronwatch::Alerts::Sentry needs a dsn like https://<key>@<host>/<project>"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
prefix = segments.empty? ? "" : "/#{segments.join("/")}"
|
|
50
|
+
Dsn.new(endpoint: "#{uri.scheme.downcase}://#{SigV4.host(uri).downcase}#{prefix}/api/#{project}/envelope/",
|
|
51
|
+
public_key: URI.decode_uri_component(user))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def call(alert)
|
|
55
|
+
return if alert.type == :recovered && @recovered == false
|
|
56
|
+
|
|
57
|
+
event_id = Provider.alert_id(alert)
|
|
58
|
+
link = Provider.link_for(@link, alert)
|
|
59
|
+
event = {
|
|
60
|
+
"event_id" => event_id,
|
|
61
|
+
"timestamp" => alert.at / 1000.0,
|
|
62
|
+
"platform" => "other",
|
|
63
|
+
"level" => Provider.severity(alert.type),
|
|
64
|
+
"logger" => "cronwatch",
|
|
65
|
+
"transaction" => alert.job,
|
|
66
|
+
"environment" => @environment.nil? ? "production" : @environment,
|
|
67
|
+
}
|
|
68
|
+
event["release"] = @release if Provider.present?(@release)
|
|
69
|
+
# The first line is the issue title.
|
|
70
|
+
event["logentry"] = { "formatted" => Provider.cut("#{alert.title}\n\n#{alert.message}", 8192) }
|
|
71
|
+
event["fingerprint"] = ["cronwatch", alert.job, alert.type.to_s]
|
|
72
|
+
event["tags"] = { "job" => Provider.cut(alert.job, 199), "type" => alert.type.to_s }
|
|
73
|
+
extra = {}
|
|
74
|
+
extra["triage"] = alert.triage if Provider.present?(alert.triage)
|
|
75
|
+
extra["link"] = link if link
|
|
76
|
+
extra["details"] = Naming.to_json_value(alert.details)
|
|
77
|
+
extra["run"] = Provider.run_summary(alert)
|
|
78
|
+
event["extra"] = extra
|
|
79
|
+
payload = JS.json(event)
|
|
80
|
+
envelope = [
|
|
81
|
+
JS.json({ "event_id" => event_id }),
|
|
82
|
+
JS.json({ "type" => "event", "content_type" => "application/json", "length" => payload.bytesize }),
|
|
83
|
+
payload,
|
|
84
|
+
].join("\n") + "\n"
|
|
85
|
+
headers = {
|
|
86
|
+
"content-type" => "application/x-sentry-envelope",
|
|
87
|
+
"x-sentry-auth" => "Sentry sentry_version=7, sentry_key=#{@public_key}, sentry_client=cronwatch",
|
|
88
|
+
}
|
|
89
|
+
Provider.post(@http, "Sentry", @endpoint, headers, envelope, [@public_key])
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cronwatch
|
|
4
|
+
module Alerts
|
|
5
|
+
# Sends alerts as email through Amazon SES, API v2 SendEmail
|
|
6
|
+
# (https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_SendEmail.html),
|
|
7
|
+
# signed with AWS Signature Version 4 (see SigV4), so no AWS SDK is
|
|
8
|
+
# needed. The from identity must be verified in `region`.
|
|
9
|
+
#
|
|
10
|
+
# Cronwatch::Alerts::Ses.new(region: "us-east-1", access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"),
|
|
11
|
+
# secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"),
|
|
12
|
+
# from: "alerts@example.com", to: "ops@example.com")
|
|
13
|
+
class Ses
|
|
14
|
+
attr_reader :name
|
|
15
|
+
|
|
16
|
+
# now: the clock used to sign requests, a callable returning epoch milliseconds. For tests.
|
|
17
|
+
def initialize(region:, access_key_id:, secret_access_key:, from:, to:, session_token: nil, configuration_set_name: nil,
|
|
18
|
+
subject_prefix: nil, link: nil, now: nil, http: HTTP.default)
|
|
19
|
+
region = Provider.require_option(region, "Cronwatch::Alerts::Ses needs a region")
|
|
20
|
+
raise ArgumentError, "Cronwatch::Alerts::Ses needs a region like us-east-1" unless /\A[a-z0-9-]+\z/.match?(region)
|
|
21
|
+
# A pasted credential often carries a stray space or newline, which would spoil the signature.
|
|
22
|
+
access_key_id = Provider.trimmed(access_key_id)
|
|
23
|
+
secret_access_key = Provider.trimmed(secret_access_key)
|
|
24
|
+
if access_key_id.empty? || secret_access_key.empty?
|
|
25
|
+
raise ArgumentError, "Cronwatch::Alerts::Ses needs an access_key_id and secret_access_key"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@to = Email.recipients("Ses", from, to)
|
|
29
|
+
@region = region
|
|
30
|
+
@access_key_id = access_key_id
|
|
31
|
+
@secret_access_key = secret_access_key
|
|
32
|
+
session_token = Provider.trimmed(session_token)
|
|
33
|
+
@session_token = session_token.empty? ? nil : session_token
|
|
34
|
+
@configuration_set_name = configuration_set_name
|
|
35
|
+
@from = from
|
|
36
|
+
@subject_prefix = subject_prefix
|
|
37
|
+
@link = link
|
|
38
|
+
@now = now || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) }
|
|
39
|
+
@url = "https://email.#{region}.amazonaws.com/v2/email/outbound-emails"
|
|
40
|
+
@http = http
|
|
41
|
+
@name = "ses"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def call(alert)
|
|
45
|
+
email = Email.compose(alert, from: @from, to: @to, subject_prefix: @subject_prefix, link: @link)
|
|
46
|
+
payload = {
|
|
47
|
+
"FromEmailAddress" => email.from,
|
|
48
|
+
"Destination" => { "ToAddresses" => email.to },
|
|
49
|
+
"Content" => {
|
|
50
|
+
"Simple" => {
|
|
51
|
+
"Subject" => { "Data" => email.subject, "Charset" => "UTF-8" },
|
|
52
|
+
"Body" => {
|
|
53
|
+
"Text" => { "Data" => email.text, "Charset" => "UTF-8" },
|
|
54
|
+
"Html" => { "Data" => email.html, "Charset" => "UTF-8" },
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
payload["ConfigurationSetName"] = @configuration_set_name if Provider.present?(@configuration_set_name)
|
|
60
|
+
payload["EmailTags"] = [{ "Name" => "source", "Value" => "cronwatch" }]
|
|
61
|
+
body = JS.json(payload)
|
|
62
|
+
headers = SigV4.sign(
|
|
63
|
+
method: "POST", url: @url, headers: { "content-type" => "application/json" }, body: body, region: @region,
|
|
64
|
+
service: "ses", now: @now.call, access_key_id: @access_key_id, secret_access_key: @secret_access_key,
|
|
65
|
+
session_token: @session_token
|
|
66
|
+
)
|
|
67
|
+
Provider.post(@http, "SES", @url, headers, body, [@secret_access_key, @session_token])
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Cronwatch
|
|
7
|
+
module Alerts
|
|
8
|
+
# AWS Signature Version 4 with OpenSSL, for the SES channel, as the SDK's
|
|
9
|
+
# alerts/sigv4.ts. Spec:
|
|
10
|
+
# https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
|
|
11
|
+
# Checked against the AWS SigV4 test suite (test/provider_channels_test.rb).
|
|
12
|
+
module SigV4
|
|
13
|
+
SPACES = Regexp.new("[#{JS::WHITESPACE}]+")
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Returns the headers to send: the given ones (lowercased) plus
|
|
18
|
+
# x-amz-date, the session token when there is one, and authorization.
|
|
19
|
+
# Host is signed but not returned; Net::HTTP sets it.
|
|
20
|
+
def sign(method:, url:, headers:, body:, region:, service:, now:, access_key_id:, secret_access_key:, session_token: nil)
|
|
21
|
+
uri = URI(url)
|
|
22
|
+
amz_date = JS.iso(now).delete("-:").sub(/\.\d{3}/, "")
|
|
23
|
+
day = amz_date[0, 8]
|
|
24
|
+
out = {}
|
|
25
|
+
headers.each { |name, value| out[name.to_s.downcase] = value.to_s }
|
|
26
|
+
out["x-amz-date"] = amz_date
|
|
27
|
+
out["x-amz-security-token"] = session_token if Provider.present?(session_token)
|
|
28
|
+
|
|
29
|
+
signed = out.merge("host" => host(uri))
|
|
30
|
+
names = signed.keys.sort
|
|
31
|
+
canonical_headers = names.map { |n| "#{n}:#{JS.trim(signed[n]).gsub(SPACES, " ")}\n" }.join
|
|
32
|
+
signed_headers = names.join(";")
|
|
33
|
+
canonical_request = [
|
|
34
|
+
method.to_s.upcase,
|
|
35
|
+
canonical_uri(uri.path),
|
|
36
|
+
canonical_query(uri.query),
|
|
37
|
+
canonical_headers,
|
|
38
|
+
signed_headers,
|
|
39
|
+
Provider.sha256_hex(body),
|
|
40
|
+
].join("\n")
|
|
41
|
+
scope = "#{day}/#{region}/#{service}/aws4_request"
|
|
42
|
+
string_to_sign = ["AWS4-HMAC-SHA256", amz_date, scope, Provider.sha256_hex(canonical_request)].join("\n")
|
|
43
|
+
|
|
44
|
+
key = hmac("AWS4#{secret_access_key}", day)
|
|
45
|
+
key = hmac(key, region)
|
|
46
|
+
key = hmac(key, service)
|
|
47
|
+
key = hmac(key, "aws4_request")
|
|
48
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", key, string_to_sign)
|
|
49
|
+
|
|
50
|
+
out["authorization"] = "AWS4-HMAC-SHA256 Credential=#{access_key_id}/#{scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
|
|
51
|
+
out
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# URL#host: the host, and the port when it is not the scheme's default.
|
|
55
|
+
def host(uri)
|
|
56
|
+
uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def hmac(key, data)
|
|
60
|
+
OpenSSL::HMAC.digest("SHA256", key, data)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# RFC 3986 encoding of every byte but the unreserved characters.
|
|
64
|
+
def uri_encode(text)
|
|
65
|
+
text.to_s.b.gsub(/[^A-Za-z0-9\-_.~]/n) { |c| format("%%%02X", c.ord) }.force_encoding(Encoding::UTF_8)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def canonical_uri(path)
|
|
69
|
+
return "/" if path.nil? || path.empty?
|
|
70
|
+
|
|
71
|
+
# The path is already encoded once; every AWS service but S3 expects each segment encoded again.
|
|
72
|
+
path.split("/", -1).map { |segment| uri_encode(segment) }.join("/")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The query as URLSearchParams reads it, each name and value encoded, sorted.
|
|
76
|
+
def canonical_query(query)
|
|
77
|
+
return "" if query.nil? || query.empty?
|
|
78
|
+
|
|
79
|
+
pairs = URI.decode_www_form(query).map { |name, value| [uri_encode(name), uri_encode(value)] }
|
|
80
|
+
pairs.sort.map { |name, value| "#{name}=#{value}" }.join("&")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Cronwatch
|
|
6
|
+
module Alerts
|
|
7
|
+
# Texts alerts through Twilio
|
|
8
|
+
# (https://www.twilio.com/docs/messaging/api/message-resource), form
|
|
9
|
+
# encoded with basic auth, one message per number, to every number at once.
|
|
10
|
+
#
|
|
11
|
+
# Cronwatch::Alerts::Twilio.new(account_sid: ENV.fetch("TWILIO_ACCOUNT_SID"),
|
|
12
|
+
# auth_token: ENV.fetch("TWILIO_AUTH_TOKEN"),
|
|
13
|
+
# from: "+15005550006", to: ["+15551110000"])
|
|
14
|
+
#
|
|
15
|
+
# Sign with auth_token, or with api_key_sid and api_key_secret (each
|
|
16
|
+
# trimmed of the spaces and newlines a paste leaves). Send from a number,
|
|
17
|
+
# or through messaging_service_sid. Recoveries are not texted unless
|
|
18
|
+
# `recovered: true`: a text is for what needs a person. A message fits in
|
|
19
|
+
# `segments` SMS segments (default 3, 1 to 10).
|
|
20
|
+
#
|
|
21
|
+
# The alert counts as delivered when any number took it; each number that
|
|
22
|
+
# refused it is reported through the client's channel context (on_error).
|
|
23
|
+
# It fails only when every number did.
|
|
24
|
+
class Twilio
|
|
25
|
+
# The GSM 03.38 alphabet: a message in it takes 153 characters a segment
|
|
26
|
+
# (when split), anything else is UCS-2 at 67. The extension table costs two.
|
|
27
|
+
GSM = Set.new("@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"\#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿" \
|
|
28
|
+
"abcdefghijklmnopqrstuvwxyzäöñüà".chars).freeze
|
|
29
|
+
GSM_EXTENDED = Set.new("^{}\\[~]|€\f".chars).freeze
|
|
30
|
+
# The most segments a message may use, which keeps it inside Twilio's 1600 character Body limit.
|
|
31
|
+
MAX_SEGMENTS = 10
|
|
32
|
+
# Twilio refuses a Body longer than this.
|
|
33
|
+
MAX_BODY = 1600
|
|
34
|
+
|
|
35
|
+
attr_reader :name
|
|
36
|
+
|
|
37
|
+
def initialize(account_sid:, to:, auth_token: nil, api_key_sid: nil, api_key_secret: nil, from: nil,
|
|
38
|
+
messaging_service_sid: nil, recovered: false, segments: 3, link: nil, http: HTTP.default)
|
|
39
|
+
# A pasted credential often carries a stray space or newline, which the Authorization header would refuse or send.
|
|
40
|
+
account_sid = Provider.require_credential(account_sid, "Cronwatch::Alerts::Twilio needs an account_sid")
|
|
41
|
+
api_key_sid = Provider.trimmed(api_key_sid)
|
|
42
|
+
user = api_key_sid.empty? ? account_sid : api_key_sid
|
|
43
|
+
password = Provider.trimmed(api_key_sid.empty? ? auth_token : api_key_secret)
|
|
44
|
+
if password.empty?
|
|
45
|
+
raise ArgumentError, "Cronwatch::Alerts::Twilio needs an auth_token, or an api_key_sid and api_key_secret"
|
|
46
|
+
end
|
|
47
|
+
unless Provider.present?(from) || Provider.present?(messaging_service_sid)
|
|
48
|
+
raise ArgumentError, "Cronwatch::Alerts::Twilio needs a from number or a messaging_service_sid"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@to = (to.is_a?(Array) ? to : [to]).select { |n| n.is_a?(String) && JS.trim(n) != "" }.map { |n| JS.trim(n) }
|
|
52
|
+
raise ArgumentError, "Cronwatch::Alerts::Twilio needs at least one to number" if @to.empty?
|
|
53
|
+
|
|
54
|
+
@url = "https://api.twilio.com/2010-04-01/Accounts/#{Provider.encode_uri_component(account_sid)}/Messages.json"
|
|
55
|
+
@password = password
|
|
56
|
+
@authorization = Provider.basic_auth(user, @password)
|
|
57
|
+
@from = from
|
|
58
|
+
@messaging_service_sid = messaging_service_sid
|
|
59
|
+
@recovered = recovered
|
|
60
|
+
@segments = Twilio.segment_budget(segments)
|
|
61
|
+
@link = link
|
|
62
|
+
@http = http
|
|
63
|
+
# How long to wait for the numbers' requests, each already bounded by its own HTTP deadline.
|
|
64
|
+
@deadline_s = HTTP::TIMEOUT + 1
|
|
65
|
+
@name = "twilio"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# `context` is the client's channel context: each number that refused
|
|
69
|
+
# the alert, when another took it, is reported through its on_error.
|
|
70
|
+
def call(alert, context = nil)
|
|
71
|
+
return if alert.type == :recovered && !@recovered
|
|
72
|
+
|
|
73
|
+
body = Twilio.sms_body(alert, Provider.link_for(@link, alert), @segments)
|
|
74
|
+
errors = send_all(body)
|
|
75
|
+
failed = @to.each_index.filter_map { |i| [@to[i], errors[i]] if errors[i] }
|
|
76
|
+
return if failed.empty?
|
|
77
|
+
|
|
78
|
+
if failed.length == @to.length
|
|
79
|
+
message = failed[0][1].message
|
|
80
|
+
raise(@to.length > 1 ? "#{message} (#{failed.length} of #{@to.length} numbers failed)" : message)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Delivered to someone: counted as sent, so a retry never texts the numbers that took it again.
|
|
84
|
+
took = @to.length - failed.length
|
|
85
|
+
failed.each do |number, error|
|
|
86
|
+
report = RuntimeError.new("#{error.message} (to #{Twilio.mask_number(number)}; #{took} of #{@to.length} numbers took the alert)")
|
|
87
|
+
if context.respond_to?(:on_error)
|
|
88
|
+
context.on_error(report)
|
|
89
|
+
else
|
|
90
|
+
warn "[cronwatch] alert channel twilio: #{report.message}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A number with all but its last four digits hidden, for an error message.
|
|
97
|
+
def self.mask_number(number)
|
|
98
|
+
length = JS.length16(number)
|
|
99
|
+
length <= 4 ? number : "#{"*" * [length - 4, 8].min}#{JS.tail16(number, 4)}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A segment count clamped to 1 to MAX_SEGMENTS; 3 for anything not a number.
|
|
103
|
+
def self.segment_budget(segments)
|
|
104
|
+
n = segments.is_a?(Numeric) && segments.real? && JS.finite?(segments) ? segments.floor.to_i : 3
|
|
105
|
+
[MAX_SEGMENTS, [1, n].max].min
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The segments `text` takes. A character is never split across two: an
|
|
109
|
+
# extension character (two septets) or a surrogate pair (two UCS-2
|
|
110
|
+
# units) that would straddle a boundary starts the next segment, as
|
|
111
|
+
# phones pack them.
|
|
112
|
+
def self.sms_segments(text)
|
|
113
|
+
units = []
|
|
114
|
+
gsm = true
|
|
115
|
+
text.each_char do |ch|
|
|
116
|
+
if GSM.include?(ch) then units << 1
|
|
117
|
+
elsif GSM_EXTENDED.include?(ch) then units << 2
|
|
118
|
+
else
|
|
119
|
+
gsm = false
|
|
120
|
+
break
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
single, per, sizes = gsm ? [160, 153, units] : [70, 67, text.each_char.map { |ch| ch.ord > 0xFFFF ? 2 : 1 }]
|
|
124
|
+
return 1 if sizes.sum <= single
|
|
125
|
+
|
|
126
|
+
count = 1
|
|
127
|
+
used = 0
|
|
128
|
+
sizes.each do |u|
|
|
129
|
+
if used + u > per
|
|
130
|
+
count += 1
|
|
131
|
+
used = 0
|
|
132
|
+
end
|
|
133
|
+
used += u
|
|
134
|
+
end
|
|
135
|
+
count
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Whether `text` fits in `segments` SMS segments and Twilio's Body limit.
|
|
139
|
+
def self.fits?(text, segments)
|
|
140
|
+
JS.length16(text) <= MAX_BODY && sms_segments(text) <= segments
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The title, then as many lines of the message (and the triage) as fit,
|
|
144
|
+
# then the link. The link is kept whole; the text before it is cut to
|
|
145
|
+
# make room. `segments` is clamped to 1 to 10.
|
|
146
|
+
def self.sms_body(alert, link, segments = 3)
|
|
147
|
+
budget = segment_budget(segments)
|
|
148
|
+
tail = Provider.present?(link) ? "\n#{link}" : ""
|
|
149
|
+
lines = [alert.title, *alert.message.split("\n", -1).reject { |l| JS.trim(l) == "" }]
|
|
150
|
+
lines << "Triage: #{alert.triage}" if Provider.present?(alert.triage)
|
|
151
|
+
text = +""
|
|
152
|
+
lines.each do |line|
|
|
153
|
+
following = text.empty? ? line : "#{text}\n#{line}"
|
|
154
|
+
if fits?(following + tail, budget)
|
|
155
|
+
text = following
|
|
156
|
+
next
|
|
157
|
+
end
|
|
158
|
+
# Part of this line, cut on a code point and marked.
|
|
159
|
+
chars = line.chars
|
|
160
|
+
lo = 0
|
|
161
|
+
hi = chars.length
|
|
162
|
+
while lo < hi
|
|
163
|
+
mid = (lo + hi + 1) / 2
|
|
164
|
+
candidate = (text.empty? ? "" : "#{text}\n") + chars[0, mid].join + "..."
|
|
165
|
+
if fits?(candidate + tail, budget)
|
|
166
|
+
lo = mid
|
|
167
|
+
else
|
|
168
|
+
hi = mid - 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
text = (text.empty? ? "" : "#{text}\n") + chars[0, lo].join + "..." if lo.positive?
|
|
172
|
+
break
|
|
173
|
+
end
|
|
174
|
+
# Only a link too long for any budget gets here too long; Twilio would refuse it whole.
|
|
175
|
+
Provider.cut(text + tail, MAX_BODY)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
private
|
|
179
|
+
|
|
180
|
+
# Posts to every number at once, each in its own thread. Returns the
|
|
181
|
+
# error for each number, by index, or nil where it took the alert.
|
|
182
|
+
def send_all(body)
|
|
183
|
+
errors = Array.new(@to.length)
|
|
184
|
+
threads = @to.each_with_index.map do |number, i|
|
|
185
|
+
Thread.new do
|
|
186
|
+
Thread.current.report_on_exception = false
|
|
187
|
+
pairs = [["To", number]]
|
|
188
|
+
pairs << (Provider.present?(@messaging_service_sid) ? ["MessagingServiceSid", @messaging_service_sid] : ["From", @from])
|
|
189
|
+
pairs << ["Body", body]
|
|
190
|
+
headers = { "content-type" => "application/x-www-form-urlencoded", "authorization" => @authorization }
|
|
191
|
+
Provider.post(@http, "Twilio", @url, headers, Provider.form(pairs), [@password])
|
|
192
|
+
rescue StandardError => e
|
|
193
|
+
errors[i] = e
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
deadline = HTTP.monotonic + @deadline_s
|
|
197
|
+
threads.each_with_index do |thread, i|
|
|
198
|
+
next if thread.join([deadline - HTTP.monotonic, 0].max)
|
|
199
|
+
|
|
200
|
+
errors[i] = HTTP::TimeoutError.new
|
|
201
|
+
end
|
|
202
|
+
errors
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -7,7 +7,10 @@ module Cronwatch
|
|
|
7
7
|
# POSTs the alert as JSON to any URL. The body is the alert's JSON:
|
|
8
8
|
# { type, run, details, job, definition, title, message, at, triage }.
|
|
9
9
|
# With a secret, each request carries `X-CronWatch-Signature: sha256=<hex>`,
|
|
10
|
-
# the HMAC-SHA256 of the raw body, so the receiver can verify it.
|
|
10
|
+
# the HMAC-SHA256 of the raw body, so the receiver can verify it. Header
|
|
11
|
+
# values are trimmed. A redirect is an error, not followed (the headers
|
|
12
|
+
# and the signature would go with it): point the url at where the
|
|
13
|
+
# receiver really is.
|
|
11
14
|
class Webhook
|
|
12
15
|
attr_reader :name
|
|
13
16
|
|
|
@@ -23,7 +26,9 @@ module Cronwatch
|
|
|
23
26
|
|
|
24
27
|
def call(alert)
|
|
25
28
|
body = JS.json(alert.to_h)
|
|
26
|
-
headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
|
|
29
|
+
headers = { "content-type" => "application/json", "user-agent" => "cronwatch" }
|
|
30
|
+
# A pasted Authorization value often carries a stray space or newline, which a header would refuse.
|
|
31
|
+
@headers.each { |name, value| headers[name.to_s] = value.is_a?(String) ? JS.trim(value) : value }
|
|
27
32
|
if @secret && !@secret.empty?
|
|
28
33
|
headers["x-cronwatch-signature"] = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", @secret, body)}"
|
|
29
34
|
end
|