hecks 2.6.0 → 2.7.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/lib/hecks/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +2 -1
- metadata +45 -1
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
require_relative "check"
|
|
2
|
+
require_relative "yaml"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module Projections
|
|
6
|
+
module Deploy
|
|
7
|
+
module Fargate
|
|
8
|
+
# Alerting for a Fargate stack: an `SNS` topic, CloudWatch alarms, and an
|
|
9
|
+
# optional synthetic check.
|
|
10
|
+
#
|
|
11
|
+
# Every alarm reports to the topic on both alarm and recovery. An email
|
|
12
|
+
# address subscribes to the topic, but `SNS` holds the subscription as
|
|
13
|
+
# pending until the recipient follows the confirmation link it mails;
|
|
14
|
+
# the stack cannot do that step.
|
|
15
|
+
#
|
|
16
|
+
# ## Settings
|
|
17
|
+
#
|
|
18
|
+
# `alerts` is a hash:
|
|
19
|
+
#
|
|
20
|
+
# - `topic`: the `SNS` topic name (required).
|
|
21
|
+
# - `email`: an address to subscribe to the topic.
|
|
22
|
+
# - `alarms`: a list of kinds, each a string or a hash `{kind:, container:, threshold:,
|
|
23
|
+
# period:, evaluation_periods:, datapoints_to_alarm:, description:}`. The kinds are
|
|
24
|
+
# `alb_5xx` (5xx responses from targets), `target_unhealthy` (an unhealthy target, for
|
|
25
|
+
# one `container` or for every load-balanced one) and `cloudfront_5xx` (5xx responses
|
|
26
|
+
# counted at the edge).
|
|
27
|
+
# - `warmer`: `{paths:, namespace:, rate:}` adds a function that requests each path
|
|
28
|
+
# through the CDN on a schedule and reports failures as a metric, with an alarm that
|
|
29
|
+
# also fires when the metric goes silent. `code` replaces the function's inline source,
|
|
30
|
+
# and `schedule_description` and `alarm_description` replace the schedule's and the
|
|
31
|
+
# alarm's description text.
|
|
32
|
+
#
|
|
33
|
+
# The logical ids come from `logical_ids`: `alerts_topic`, `alerts_subscription`,
|
|
34
|
+
# `alb_5xx_alarm`, `cloudfront_5xx_alarm`, `cloudfront_monitoring`, `synthetic_alarm`,
|
|
35
|
+
# `warmer_role`, `warmer_function`, `scheduler_role` and `warmer_schedule`. A
|
|
36
|
+
# per-container alarm is `<Container>TargetUnhealthyAlarm`.
|
|
37
|
+
module Monitoring
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
KEYS = [:topic, :email, :alarms, :warmer].freeze
|
|
41
|
+
KINDS = %w[alb_5xx target_unhealthy cloudfront_5xx].freeze
|
|
42
|
+
ALARM_KEYS = [:kind, :container, :threshold, :period, :evaluation_periods, :datapoints_to_alarm, :description].freeze
|
|
43
|
+
WARMER_KEYS = [:paths, :namespace, :rate, :code, :schedule_description, :alarm_description].freeze
|
|
44
|
+
EMAIL = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
45
|
+
DEFAULTS = {
|
|
46
|
+
"alb_5xx" => { threshold: 1, period: 60, evaluation_periods: 3, datapoints_to_alarm: 2 },
|
|
47
|
+
"target_unhealthy" => { threshold: 1, period: 60, evaluation_periods: 2 },
|
|
48
|
+
"cloudfront_5xx" => { threshold: 3, period: 60, evaluation_periods: 3, datapoints_to_alarm: 2 }
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
DESCRIPTIONS = {
|
|
52
|
+
alb_5xx: "The load balancer is returning 5xx responses from the containers behind it.",
|
|
53
|
+
target_unhealthy: "The %<container>s target group has at least one unhealthy target, " \
|
|
54
|
+
"whether or not any traffic arrives.",
|
|
55
|
+
cloudfront_5xx: "CloudFront is serving 5xx responses, measured at the edge.",
|
|
56
|
+
synthetic: "The synthetic check found a non-200 response on a real path, or stopped reporting."
|
|
57
|
+
}.freeze
|
|
58
|
+
|
|
59
|
+
# Reads and checks the `alerts` setting.
|
|
60
|
+
#
|
|
61
|
+
# @param setting [Hash, nil] the world's `alerts` value, or nil when it sets none
|
|
62
|
+
# @param containers [Array<String>] the names of the load-balanced containers
|
|
63
|
+
# @return [Hash{Symbol => Object}, nil] the checked settings, or nil for none
|
|
64
|
+
# @raise [ArgumentError] if a key is unknown or malformed, or an alarm names a container
|
|
65
|
+
# that has no target group
|
|
66
|
+
def normalize(setting, containers:)
|
|
67
|
+
return nil if setting.nil?
|
|
68
|
+
|
|
69
|
+
given = Check.hash!(setting, "alerts", allowed: KEYS, required: [:topic])
|
|
70
|
+
email = given[:email]&.to_s
|
|
71
|
+
raise ArgumentError, "alerts email must be an email address, got #{email.inspect}" if email && !EMAIL.match?(email)
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
topic: Check.resource_name!(given[:topic], "alerts topic"), email: email,
|
|
75
|
+
alarms: alarms(given.fetch(:alarms, []), containers), warmer: warmer(given[:warmer])
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Tells whether any alarm needs the CloudFront metrics subscription.
|
|
80
|
+
#
|
|
81
|
+
# @param alerts [Hash, nil] the checked settings from `normalize`
|
|
82
|
+
# @return [Boolean] true when a `cloudfront_5xx` alarm is declared
|
|
83
|
+
def cloudfront_metrics?(alerts)
|
|
84
|
+
!alerts.nil? && alerts[:alarms].any? { |alarm| alarm[:kind] == "cloudfront_5xx" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Renders every alerting resource.
|
|
88
|
+
#
|
|
89
|
+
# @param alerts [Hash] the checked settings from `normalize`
|
|
90
|
+
# @param context [Hash{Symbol => Object}] `:ids` (resolved logical ids), `:stack_name`,
|
|
91
|
+
# `:alb_id`, `:distribution_id` and `:target_groups` (container name to target group id)
|
|
92
|
+
# @return [String] flush-left resources separated by blank lines, ending in a blank line
|
|
93
|
+
def yaml(alerts, context)
|
|
94
|
+
blocks = [topic_yaml(alerts, context[:ids])]
|
|
95
|
+
blocks << subscription_yaml(alerts, context[:ids]) if alerts[:email]
|
|
96
|
+
blocks << monitoring_subscription_yaml(context) if cloudfront_metrics?(alerts)
|
|
97
|
+
blocks.concat(alerts[:alarms].flat_map { |alarm| alarm_blocks(alarm, context) })
|
|
98
|
+
blocks.concat(warmer_blocks(alerts, context)) if alerts[:warmer]
|
|
99
|
+
"#{blocks.join("\n\n")}\n\n"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def alarms(entries, containers)
|
|
103
|
+
raise ArgumentError, "alerts alarms must be a list, got #{entries.inspect}" unless entries.is_a?(Array)
|
|
104
|
+
|
|
105
|
+
entries.flat_map do |entry|
|
|
106
|
+
given = if entry.is_a?(Hash)
|
|
107
|
+
Check.hash!(entry, "alerts alarms", allowed: ALARM_KEYS,
|
|
108
|
+
required: [:kind])
|
|
109
|
+
else
|
|
110
|
+
{ kind: entry }
|
|
111
|
+
end
|
|
112
|
+
kind = Check.one_of!(given[:kind], "alerts alarms kind", KINDS)
|
|
113
|
+
kind == "target_unhealthy" ? unhealthy_alarms(given, containers) : [alarm(kind, given, nil)]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
private_class_method :alarms
|
|
117
|
+
|
|
118
|
+
def unhealthy_alarms(given, containers)
|
|
119
|
+
named = given[:container]&.to_s
|
|
120
|
+
if named && !containers.include?(named)
|
|
121
|
+
raise ArgumentError,
|
|
122
|
+
"alerts alarms container #{named.inspect} has no target group; have #{containers.join(', ')}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
(named ? [named] : containers).map { |container| alarm("target_unhealthy", given, container) }
|
|
126
|
+
end
|
|
127
|
+
private_class_method :unhealthy_alarms
|
|
128
|
+
|
|
129
|
+
def alarm(kind, given, container)
|
|
130
|
+
values = DEFAULTS.fetch(kind).merge(given.slice(:threshold, :period, :evaluation_periods, :datapoints_to_alarm))
|
|
131
|
+
values.each { |key, value| Check.integer!(value, "alerts alarms #{key}", range: 1..86_400) }
|
|
132
|
+
values.merge(kind: kind, container: container,
|
|
133
|
+
description: description!(given[:description], "alerts alarms description"))
|
|
134
|
+
end
|
|
135
|
+
private_class_method :alarm
|
|
136
|
+
|
|
137
|
+
def warmer(value)
|
|
138
|
+
return nil if value.nil?
|
|
139
|
+
|
|
140
|
+
given = Check.hash!(value, "alerts warmer", allowed: WARMER_KEYS, required: [:paths])
|
|
141
|
+
paths = Check.strings!(given[:paths], "alerts warmer paths", min: 1)
|
|
142
|
+
unless paths.all? { |path| path.start_with?("/") }
|
|
143
|
+
raise ArgumentError, "alerts warmer paths must start with /, got #{paths.reject do |p|
|
|
144
|
+
p.start_with?('/')
|
|
145
|
+
end.join(', ')}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
rate = given.fetch(:rate, "rate(1 minute)").to_s
|
|
149
|
+
unless rate.match?(/\A(rate|cron)\(.+\)\z/)
|
|
150
|
+
raise ArgumentError,
|
|
151
|
+
"alerts warmer rate must be a schedule expression such as rate(1 minute), got #{rate.inspect}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
{ paths: paths, namespace: given[:namespace]&.to_s, rate: rate, code: given[:code]&.to_s,
|
|
155
|
+
schedule_description: description!(given[:schedule_description], "alerts warmer schedule_description"),
|
|
156
|
+
alarm_description: description!(given[:alarm_description], "alerts warmer alarm_description") }
|
|
157
|
+
end
|
|
158
|
+
private_class_method :warmer
|
|
159
|
+
|
|
160
|
+
def description!(value, where)
|
|
161
|
+
return nil if value.nil?
|
|
162
|
+
|
|
163
|
+
text = value.to_s
|
|
164
|
+
unless (1..1023).cover?(text.length)
|
|
165
|
+
raise ArgumentError,
|
|
166
|
+
"#{where} must be 1 to 1023 characters, got #{value.inspect}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
text
|
|
170
|
+
end
|
|
171
|
+
private_class_method :description!
|
|
172
|
+
|
|
173
|
+
def topic_yaml(alerts, ids)
|
|
174
|
+
<<~TOPIC.rstrip
|
|
175
|
+
#{ids[:alerts_topic]}:
|
|
176
|
+
Type: AWS::SNS::Topic
|
|
177
|
+
Properties:
|
|
178
|
+
TopicName: #{alerts[:topic]}
|
|
179
|
+
TOPIC
|
|
180
|
+
end
|
|
181
|
+
private_class_method :topic_yaml
|
|
182
|
+
|
|
183
|
+
def subscription_yaml(alerts, ids)
|
|
184
|
+
<<~SUBSCRIPTION.rstrip
|
|
185
|
+
# SNS holds an email subscription as pending until the recipient follows the
|
|
186
|
+
# confirmation link SNS mails on stack creation; no alert is delivered before then.
|
|
187
|
+
#{ids[:alerts_subscription]}:
|
|
188
|
+
Type: AWS::SNS::Subscription
|
|
189
|
+
Properties:
|
|
190
|
+
TopicArn: !Ref #{ids[:alerts_topic]}
|
|
191
|
+
Protocol: email
|
|
192
|
+
Endpoint: #{alerts[:email]}
|
|
193
|
+
SUBSCRIPTION
|
|
194
|
+
end
|
|
195
|
+
private_class_method :subscription_yaml
|
|
196
|
+
|
|
197
|
+
def monitoring_subscription_yaml(context)
|
|
198
|
+
ids = context[:ids]
|
|
199
|
+
<<~MONITORING.rstrip
|
|
200
|
+
# CloudFront publishes per-minute metrics only for a distribution that opts in.
|
|
201
|
+
#{ids[:cloudfront_monitoring]}:
|
|
202
|
+
Type: AWS::CloudFront::MonitoringSubscription
|
|
203
|
+
Properties:
|
|
204
|
+
DistributionId: !Ref #{context[:distribution_id]}
|
|
205
|
+
MonitoringSubscription:
|
|
206
|
+
RealtimeMetricsSubscriptionConfig:
|
|
207
|
+
RealtimeMetricsSubscriptionStatus: Enabled
|
|
208
|
+
MONITORING
|
|
209
|
+
end
|
|
210
|
+
private_class_method :monitoring_subscription_yaml
|
|
211
|
+
|
|
212
|
+
def alarm_blocks(alarm, context)
|
|
213
|
+
case alarm[:kind]
|
|
214
|
+
when "alb_5xx" then [alb_alarm_yaml(alarm, context)]
|
|
215
|
+
when "target_unhealthy" then [unhealthy_alarm_yaml(alarm, context)]
|
|
216
|
+
else [cloudfront_alarm_yaml(alarm, context)]
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
private_class_method :alarm_blocks
|
|
220
|
+
|
|
221
|
+
def alarm_description(given, default)
|
|
222
|
+
" AlarmDescription: #{Yaml.string(given || default)}"
|
|
223
|
+
end
|
|
224
|
+
private_class_method :alarm_description
|
|
225
|
+
|
|
226
|
+
def alarm_settings(alarm, ids, missing: "notBreaching", period: true)
|
|
227
|
+
lines = []
|
|
228
|
+
lines << "Period: #{alarm[:period]}" if period
|
|
229
|
+
lines << "EvaluationPeriods: #{alarm[:evaluation_periods]}"
|
|
230
|
+
lines << "DatapointsToAlarm: #{alarm[:datapoints_to_alarm]}" if alarm[:datapoints_to_alarm]
|
|
231
|
+
lines.push("Threshold: #{alarm[:threshold]}", "ComparisonOperator: GreaterThanOrEqualToThreshold",
|
|
232
|
+
"TreatMissingData: #{missing}", "AlarmActions: [!Ref #{ids[:alerts_topic]}]",
|
|
233
|
+
"OKActions: [!Ref #{ids[:alerts_topic]}]")
|
|
234
|
+
end
|
|
235
|
+
private_class_method :alarm_settings
|
|
236
|
+
|
|
237
|
+
def alb_alarm_yaml(alarm, context)
|
|
238
|
+
ids = context[:ids]
|
|
239
|
+
lines = [
|
|
240
|
+
"#{ids[:alb_5xx_alarm]}:", " Type: AWS::CloudWatch::Alarm", " Properties:",
|
|
241
|
+
" AlarmName: #{context[:stack_name]}-alb-5xx",
|
|
242
|
+
alarm_description(alarm[:description], DESCRIPTIONS[:alb_5xx]),
|
|
243
|
+
" Namespace: AWS/ApplicationELB", " MetricName: HTTPCode_Target_5XX_Count",
|
|
244
|
+
" Dimensions:", " - Name: LoadBalancer", " Value: !GetAtt #{context[:alb_id]}.LoadBalancerFullName",
|
|
245
|
+
" Statistic: Sum"
|
|
246
|
+
]
|
|
247
|
+
(lines + alarm_settings(alarm, ids).map { |line| " #{line}" }).join("\n")
|
|
248
|
+
end
|
|
249
|
+
private_class_method :alb_alarm_yaml
|
|
250
|
+
|
|
251
|
+
def unhealthy_alarm_yaml(alarm, context)
|
|
252
|
+
ids = context[:ids]
|
|
253
|
+
container = alarm[:container]
|
|
254
|
+
group = context[:target_groups].fetch(container)
|
|
255
|
+
lines = [
|
|
256
|
+
"#{Yaml.camel(container)}TargetUnhealthyAlarm:", " Type: AWS::CloudWatch::Alarm", " Properties:",
|
|
257
|
+
" AlarmName: #{context[:stack_name]}-#{container}-unhealthy",
|
|
258
|
+
alarm_description(alarm[:description], format(DESCRIPTIONS[:target_unhealthy], container: container)),
|
|
259
|
+
" Namespace: AWS/ApplicationELB", " MetricName: UnHealthyHostCount",
|
|
260
|
+
" Dimensions:", " - Name: LoadBalancer", " Value: !GetAtt #{context[:alb_id]}.LoadBalancerFullName",
|
|
261
|
+
" - Name: TargetGroup", " Value: !GetAtt #{group}.TargetGroupFullName",
|
|
262
|
+
" Statistic: Maximum"
|
|
263
|
+
]
|
|
264
|
+
(lines + alarm_settings(alarm, ids).map { |line| " #{line}" }).join("\n")
|
|
265
|
+
end
|
|
266
|
+
private_class_method :unhealthy_alarm_yaml
|
|
267
|
+
|
|
268
|
+
def cloudfront_alarm_yaml(alarm, context)
|
|
269
|
+
ids = context[:ids]
|
|
270
|
+
distribution = context[:distribution_id]
|
|
271
|
+
lines = [
|
|
272
|
+
"#{ids[:cloudfront_5xx_alarm]}:", " Type: AWS::CloudWatch::Alarm", " DependsOn: #{ids[:cloudfront_monitoring]}",
|
|
273
|
+
" Properties:", " AlarmName: #{context[:stack_name]}-cloudfront-5xx",
|
|
274
|
+
alarm_description(alarm[:description], DESCRIPTIONS[:cloudfront_5xx]),
|
|
275
|
+
" Metrics:",
|
|
276
|
+
*edge_metric_lines(distribution)
|
|
277
|
+
]
|
|
278
|
+
(lines + alarm_settings(alarm, ids, period: false).map { |line| " #{line}" }).join("\n")
|
|
279
|
+
end
|
|
280
|
+
private_class_method :cloudfront_alarm_yaml
|
|
281
|
+
|
|
282
|
+
# A count rather than the raw percentage: at low traffic one failed request reads as a
|
|
283
|
+
# large rate, so the count is the rate applied to that minute's requests.
|
|
284
|
+
def edge_metric_lines(distribution)
|
|
285
|
+
[
|
|
286
|
+
" - Id: errors", " Label: CloudFront 5xx responses", " Expression: rate * requests / 100",
|
|
287
|
+
" ReturnData: true",
|
|
288
|
+
*metric_stat_lines("rate", "Average", "5xxErrorRate", distribution),
|
|
289
|
+
*metric_stat_lines("requests", "Sum", "Requests", distribution)
|
|
290
|
+
]
|
|
291
|
+
end
|
|
292
|
+
private_class_method :edge_metric_lines
|
|
293
|
+
|
|
294
|
+
def metric_stat_lines(id, stat, metric, distribution)
|
|
295
|
+
[
|
|
296
|
+
" - Id: #{id}", " ReturnData: false", " MetricStat:", " Stat: #{stat}",
|
|
297
|
+
" Period: 60", " Metric:", " Namespace: AWS/CloudFront",
|
|
298
|
+
" MetricName: #{metric}",
|
|
299
|
+
" Dimensions:", " - Name: DistributionId", " Value: !Ref #{distribution}",
|
|
300
|
+
" - Name: Region", " Value: Global"
|
|
301
|
+
]
|
|
302
|
+
end
|
|
303
|
+
private_class_method :metric_stat_lines
|
|
304
|
+
|
|
305
|
+
def warmer_blocks(alerts, context)
|
|
306
|
+
namespace = alerts[:warmer][:namespace] || "#{context[:stack_name]}/Synthetic"
|
|
307
|
+
warmer = alerts[:warmer]
|
|
308
|
+
[warmer_role_yaml(context[:ids]), warmer_function_yaml(warmer, namespace, context),
|
|
309
|
+
synthetic_alarm_yaml(warmer, namespace, context), scheduler_role_yaml(context[:ids]),
|
|
310
|
+
schedule_yaml(warmer, context[:ids])]
|
|
311
|
+
end
|
|
312
|
+
private_class_method :warmer_blocks
|
|
313
|
+
|
|
314
|
+
def warmer_role_yaml(ids)
|
|
315
|
+
<<~ROLE.rstrip
|
|
316
|
+
#{ids[:warmer_role]}:
|
|
317
|
+
Type: AWS::IAM::Role
|
|
318
|
+
Properties:
|
|
319
|
+
AssumeRolePolicyDocument:
|
|
320
|
+
Version: '2012-10-17'
|
|
321
|
+
Statement:
|
|
322
|
+
- Effect: Allow
|
|
323
|
+
Principal: { Service: lambda.amazonaws.com }
|
|
324
|
+
Action: sts:AssumeRole
|
|
325
|
+
ManagedPolicyArns:
|
|
326
|
+
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
|
|
327
|
+
Policies:
|
|
328
|
+
- PolicyName: PutMetric
|
|
329
|
+
PolicyDocument:
|
|
330
|
+
Version: '2012-10-17'
|
|
331
|
+
Statement:
|
|
332
|
+
# PutMetricData accepts no resource-level permission, so "*" is the only form.
|
|
333
|
+
- Effect: Allow
|
|
334
|
+
Action: cloudwatch:PutMetricData
|
|
335
|
+
Resource: "*"
|
|
336
|
+
ROLE
|
|
337
|
+
end
|
|
338
|
+
private_class_method :warmer_role_yaml
|
|
339
|
+
|
|
340
|
+
def warmer_function_yaml(warmer, namespace, context)
|
|
341
|
+
ids = context[:ids]
|
|
342
|
+
head = <<~FUNCTION.rstrip
|
|
343
|
+
# Requests each real path through the CDN and reports how many failed. It always reports a
|
|
344
|
+
# data point, even zero, so the alarm below treats silence as a finding.
|
|
345
|
+
#{ids[:warmer_function]}:
|
|
346
|
+
Type: AWS::Lambda::Function
|
|
347
|
+
Properties:
|
|
348
|
+
FunctionName: #{context[:stack_name]}-warmer
|
|
349
|
+
Runtime: nodejs22.x
|
|
350
|
+
Architectures: [arm64]
|
|
351
|
+
Handler: index.handler
|
|
352
|
+
MemorySize: 128
|
|
353
|
+
Timeout: 20
|
|
354
|
+
Role: !GetAtt #{ids[:warmer_role]}.Arn
|
|
355
|
+
Environment:
|
|
356
|
+
Variables:
|
|
357
|
+
CLOUDFRONT_DOMAIN: !GetAtt #{context[:distribution_id]}.DomainName
|
|
358
|
+
Code:
|
|
359
|
+
ZipFile: |
|
|
360
|
+
FUNCTION
|
|
361
|
+
"#{head}\n#{Yaml.indent(warmer[:code] || warmer_code(warmer, namespace), ' ')}".rstrip
|
|
362
|
+
end
|
|
363
|
+
private_class_method :warmer_function_yaml
|
|
364
|
+
|
|
365
|
+
def warmer_code(warmer, namespace)
|
|
366
|
+
<<~CODE
|
|
367
|
+
const { CloudWatchClient, PutMetricDataCommand } = require("@aws-sdk/client-cloudwatch");
|
|
368
|
+
const cw = new CloudWatchClient({});
|
|
369
|
+
const PATHS = #{warmer[:paths].to_json};
|
|
370
|
+
exports.handler = async () => {
|
|
371
|
+
const host = process.env.CLOUDFRONT_DOMAIN;
|
|
372
|
+
let failures = 0;
|
|
373
|
+
const results = await Promise.all(PATHS.map(async (path) => {
|
|
374
|
+
try {
|
|
375
|
+
const res = await fetch(`https://${host}${path}`);
|
|
376
|
+
if (res.status !== 200) failures++;
|
|
377
|
+
return `${path}: ${res.status} (x-cache: ${res.headers.get("x-cache")})`;
|
|
378
|
+
} catch (err) {
|
|
379
|
+
failures++;
|
|
380
|
+
return `${path}: FAILED ${err}`;
|
|
381
|
+
}
|
|
382
|
+
}));
|
|
383
|
+
console.log(results.join("\\n"));
|
|
384
|
+
await cw.send(new PutMetricDataCommand({
|
|
385
|
+
Namespace: #{namespace.to_json},
|
|
386
|
+
MetricData: [{ MetricName: "SyntheticCheckFailures", Value: failures, Unit: "Count" }],
|
|
387
|
+
}));
|
|
388
|
+
};
|
|
389
|
+
CODE
|
|
390
|
+
end
|
|
391
|
+
private_class_method :warmer_code
|
|
392
|
+
|
|
393
|
+
def synthetic_alarm_yaml(warmer, namespace, context)
|
|
394
|
+
ids = context[:ids]
|
|
395
|
+
alarm = { period: 60, evaluation_periods: 1, threshold: 1 }
|
|
396
|
+
lines = [
|
|
397
|
+
"#{ids[:synthetic_alarm]}:", " Type: AWS::CloudWatch::Alarm", " Properties:",
|
|
398
|
+
" AlarmName: #{context[:stack_name]}-synthetic-check",
|
|
399
|
+
alarm_description(warmer[:alarm_description], DESCRIPTIONS[:synthetic]),
|
|
400
|
+
" Namespace: #{namespace}", " MetricName: SyntheticCheckFailures", " Statistic: Maximum"
|
|
401
|
+
]
|
|
402
|
+
(lines + alarm_settings(alarm, ids, missing: "breaching").map { |line| " #{line}" }).join("\n")
|
|
403
|
+
end
|
|
404
|
+
private_class_method :synthetic_alarm_yaml
|
|
405
|
+
|
|
406
|
+
def scheduler_role_yaml(ids)
|
|
407
|
+
<<~ROLE.rstrip
|
|
408
|
+
#{ids[:scheduler_role]}:
|
|
409
|
+
Type: AWS::IAM::Role
|
|
410
|
+
Properties:
|
|
411
|
+
AssumeRolePolicyDocument:
|
|
412
|
+
Version: '2012-10-17'
|
|
413
|
+
Statement:
|
|
414
|
+
- Effect: Allow
|
|
415
|
+
Principal: { Service: scheduler.amazonaws.com }
|
|
416
|
+
Action: sts:AssumeRole
|
|
417
|
+
Policies:
|
|
418
|
+
- PolicyName: InvokeWarmer
|
|
419
|
+
PolicyDocument:
|
|
420
|
+
Version: '2012-10-17'
|
|
421
|
+
Statement:
|
|
422
|
+
- Effect: Allow
|
|
423
|
+
Action: lambda:InvokeFunction
|
|
424
|
+
Resource: !GetAtt #{ids[:warmer_function]}.Arn
|
|
425
|
+
ROLE
|
|
426
|
+
end
|
|
427
|
+
private_class_method :scheduler_role_yaml
|
|
428
|
+
|
|
429
|
+
def schedule_yaml(warmer, ids)
|
|
430
|
+
<<~SCHEDULE.rstrip
|
|
431
|
+
#{ids[:warmer_schedule]}:
|
|
432
|
+
Type: AWS::Scheduler::Schedule
|
|
433
|
+
Properties:
|
|
434
|
+
Name: !Sub "${AWS::StackName}-warmer"
|
|
435
|
+
Description: #{Yaml.string(warmer[:schedule_description] || 'Runs the synthetic check against the CDN.')}
|
|
436
|
+
ScheduleExpression: #{warmer[:rate].to_json}
|
|
437
|
+
FlexibleTimeWindow: { Mode: "OFF" }
|
|
438
|
+
Target:
|
|
439
|
+
Arn: !GetAtt #{ids[:warmer_function]}.Arn
|
|
440
|
+
RoleArn: !GetAtt #{ids[:scheduler_role]}.Arn
|
|
441
|
+
SCHEDULE
|
|
442
|
+
end
|
|
443
|
+
private_class_method :schedule_yaml
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|