hecks 2.5.1 → 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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/prism.rb +1 -1
  5. data/lib/hecks/bench/cli.rb +74 -0
  6. data/lib/hecks/bench/environment.rb +100 -0
  7. data/lib/hecks/bench/postgres_probe.rb +41 -0
  8. data/lib/hecks/bench/report.rb +116 -0
  9. data/lib/hecks/bench/ruby_runner.rb +151 -0
  10. data/lib/hecks/bench/run.rb +55 -0
  11. data/lib/hecks/bench/rust_runner.rb +165 -0
  12. data/lib/hecks/bench/stats.rb +75 -0
  13. data/lib/hecks/bench/suite.rb +172 -0
  14. data/lib/hecks/bench/workload.rb +137 -0
  15. data/lib/hecks/bench.rb +28 -0
  16. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  17. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  18. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  20. data/lib/hecks/bluebook/hexagon.rb +15 -7
  21. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  22. data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
  23. data/lib/hecks/bluebook/model_check.rb +21 -2
  24. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  25. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  26. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  27. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  28. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  29. data/lib/hecks/forms/value_object_shape.rb +1 -1
  30. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  31. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  32. data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
  33. data/lib/hecks/language/world/world.bluebook +32 -2
  34. data/lib/hecks/mcp_stdio_guard.rb +160 -0
  35. data/lib/hecks/ports/key_vault.rb +1 -1
  36. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  37. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  38. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  39. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  40. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  41. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  42. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  43. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  44. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  45. data/lib/hecks/ports/persistence.rb +4 -0
  46. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  47. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  48. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  49. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  50. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  51. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  52. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  53. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  54. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  55. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  56. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  57. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  58. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  59. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  60. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  61. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  62. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  63. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  64. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  65. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  66. data/lib/hecks/projections/deploy/preview.rb +103 -0
  67. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  68. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  69. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  70. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  71. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  72. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  73. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  74. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  75. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  76. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  77. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  78. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  79. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  80. data/lib/hecks/projections.rb +2 -0
  81. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  82. data/lib/hecks/release/gem_pin.rb +112 -0
  83. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  84. data/lib/hecks/runtime/loader.rb +26 -2
  85. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  86. data/lib/hecks/runtime/registry/verification.rb +4 -2
  87. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  88. data/lib/hecks/runtime/registry.rb +14 -9
  89. data/lib/hecks/runtime/tenant_check.rb +1 -1
  90. data/lib/hecks/vendoring/git_environment.rb +35 -0
  91. data/lib/hecks/vendoring/git_source.rb +102 -0
  92. data/lib/hecks/vendoring.rb +102 -0
  93. data/lib/hecks/version.rb +1 -1
  94. data/lib/hecks.rb +3 -1
  95. metadata +59 -2
@@ -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