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.
- 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/bench/cli.rb +74 -0
- data/lib/hecks/bench/environment.rb +100 -0
- data/lib/hecks/bench/postgres_probe.rb +41 -0
- data/lib/hecks/bench/report.rb +116 -0
- data/lib/hecks/bench/ruby_runner.rb +151 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +165 -0
- data/lib/hecks/bench/stats.rb +75 -0
- data/lib/hecks/bench/suite.rb +172 -0
- data/lib/hecks/bench/workload.rb +137 -0
- data/lib/hecks/bench.rb +28 -0
- 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/client_profile.rb +153 -0
- data/lib/hecks/bluebook/model_check.rb +21 -2
- 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 +136 -69
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/mcp_stdio_guard.rb +160 -0
- 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 +3 -1
- metadata +59 -2
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
require_relative "yaml"
|
|
2
|
+
require_relative "settings"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module Projections
|
|
6
|
+
module Deploy
|
|
7
|
+
module Fargate
|
|
8
|
+
# Fills the optional sections of a rendered Fargate template.
|
|
9
|
+
#
|
|
10
|
+
# The template text carries `# TMPL:<name>` marker lines where an
|
|
11
|
+
# optional section belongs. `apply` replaces each with what the stack's
|
|
12
|
+
# `Settings::Plan` calls for, and with nothing when the plan sets
|
|
13
|
+
# nothing there, so a world that uses none of the optional settings
|
|
14
|
+
# renders the template it always did.
|
|
15
|
+
module Assembly
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Replaces every optional-section marker in a rendered template.
|
|
19
|
+
#
|
|
20
|
+
# @param template [String] the rendered template holding `# TMPL:` marker lines
|
|
21
|
+
# @param plan [Settings::Plan] the stack's checked optional settings
|
|
22
|
+
# @param context [Hash{Symbol => Object}] `:stack_name`, `:vpc_ref`, `:listener_id`,
|
|
23
|
+
# `:alb_id`, `:distribution_id` and `:db_secret_ref`
|
|
24
|
+
# @return [String] the template with the markers replaced
|
|
25
|
+
# @raise [ArgumentError] if `domain_env` removes a variable the generator does not set, or
|
|
26
|
+
# the finished template declares one logical id or parameter twice
|
|
27
|
+
def apply(template, plan, context)
|
|
28
|
+
text = override_domain_env(template, plan.domain_env)
|
|
29
|
+
sections(plan, context).each { |marker, block| text = Yaml.splice(text, marker, block) }
|
|
30
|
+
check_duplicates!(text)
|
|
31
|
+
text
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def sections(plan, context)
|
|
35
|
+
{
|
|
36
|
+
"domain_essential" => domain_essential_yaml(plan.layout.domain),
|
|
37
|
+
"session_secret_properties" => session_secret_yaml(plan.extras[:session_secret]),
|
|
38
|
+
"execution_database_grant" => execution_grant_yaml(plan, context),
|
|
39
|
+
"extra_execution_policies" => Extras.policies_yaml(plan.extras[:execution_policies]),
|
|
40
|
+
"extra_task_policies" => task_policies_yaml(plan),
|
|
41
|
+
"domain_env" => domain_env_yaml(plan.domain_env),
|
|
42
|
+
"extra_containers" => lines(Containers.definitions_yaml(plan.layout, plan.ids[:log_group])),
|
|
43
|
+
"target_group_attributes" => Containers.target_group_attributes_yaml(plan.deregistration_delay),
|
|
44
|
+
"extra_target_groups" => target_groups_yaml(plan, context),
|
|
45
|
+
"service_tuning" => service_tuning_yaml(plan),
|
|
46
|
+
"extra_load_balancers" => Containers.load_balancers_yaml(plan.layout),
|
|
47
|
+
"distribution" => distribution_yaml(plan, context),
|
|
48
|
+
"extra_resources" => extra_resources_yaml(plan, context),
|
|
49
|
+
"extra_outputs" => outputs_yaml(plan)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
private_class_method :sections
|
|
53
|
+
|
|
54
|
+
# Removes each default environment entry the world overrides, with the comments above it.
|
|
55
|
+
def override_domain_env(template, domain_env)
|
|
56
|
+
domain_env.keys.reduce(template) do |text, name|
|
|
57
|
+
entry = /(?:^[ \t]*#.*\n)*^[ \t]*- Name: #{Regexp.escape(name)}\n[ \t]+Value: .*\n/
|
|
58
|
+
if text.match?(entry)
|
|
59
|
+
text.sub(entry, "")
|
|
60
|
+
elsif domain_env[name].nil?
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"domain_env #{name} is nil, which removes a default variable, but the generator sets no #{name}"
|
|
63
|
+
else
|
|
64
|
+
text
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
private_class_method :override_domain_env
|
|
69
|
+
|
|
70
|
+
def execution_grant_yaml(plan, context)
|
|
71
|
+
return "" unless plan.execution_database_grant
|
|
72
|
+
|
|
73
|
+
<<~GRANT
|
|
74
|
+
- PolicyName: DbSecretRead
|
|
75
|
+
PolicyDocument:
|
|
76
|
+
Version: '2012-10-17'
|
|
77
|
+
Statement:
|
|
78
|
+
- Effect: Allow
|
|
79
|
+
Action: secretsmanager:GetSecretValue
|
|
80
|
+
Resource: !Sub "${#{context[:db_secret_ref]}}"
|
|
81
|
+
GRANT
|
|
82
|
+
end
|
|
83
|
+
private_class_method :execution_grant_yaml
|
|
84
|
+
|
|
85
|
+
def domain_essential_yaml(domain)
|
|
86
|
+
domain.essential.nil? ? "" : "Essential: #{domain.essential}\n"
|
|
87
|
+
end
|
|
88
|
+
private_class_method :domain_essential_yaml
|
|
89
|
+
|
|
90
|
+
def domain_env_yaml(domain_env)
|
|
91
|
+
domain_env.compact.map do |name, value|
|
|
92
|
+
"- Name: #{name}\n Value: #{Yaml.string(value)}\n"
|
|
93
|
+
end.join
|
|
94
|
+
end
|
|
95
|
+
private_class_method :domain_env_yaml
|
|
96
|
+
|
|
97
|
+
def session_secret_yaml(given)
|
|
98
|
+
[("Name: #{given[:name]}\n" if given[:name]),
|
|
99
|
+
("Description: #{Yaml.string(given[:description])}\n" if given[:description])].compact.join
|
|
100
|
+
end
|
|
101
|
+
private_class_method :session_secret_yaml
|
|
102
|
+
|
|
103
|
+
def task_policies_yaml(plan)
|
|
104
|
+
Extras.policies_yaml(plan.extras[:task_policies]) + (plan.execute_command ? Extras.execute_command_policy_yaml : "")
|
|
105
|
+
end
|
|
106
|
+
private_class_method :task_policies_yaml
|
|
107
|
+
|
|
108
|
+
def service_tuning_yaml(plan)
|
|
109
|
+
lines = []
|
|
110
|
+
lines << "EnableExecuteCommand: true" if plan.execute_command
|
|
111
|
+
lines << "HealthCheckGracePeriodSeconds: #{plan.health_check_grace_period}" if plan.health_check_grace_period
|
|
112
|
+
lines.empty? ? "" : "#{lines.join("\n")}\n"
|
|
113
|
+
end
|
|
114
|
+
private_class_method :service_tuning_yaml
|
|
115
|
+
|
|
116
|
+
def target_groups_yaml(plan, context)
|
|
117
|
+
blocks(
|
|
118
|
+
[
|
|
119
|
+
Containers.target_groups_yaml(plan.layout, context[:vpc_ref], plan.deregistration_delay),
|
|
120
|
+
Containers.listener_rules_yaml(plan.layout, context[:listener_id])
|
|
121
|
+
].reject(&:empty?).join("\n\n")
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
private_class_method :target_groups_yaml
|
|
125
|
+
|
|
126
|
+
def distribution_yaml(plan, context)
|
|
127
|
+
ids = { distribution_id: context[:distribution_id], alb_id: context[:alb_id] }
|
|
128
|
+
plan.cdn ? Cdn.yaml(plan.cdn, **ids) : Cdn.default_yaml(**ids)
|
|
129
|
+
end
|
|
130
|
+
private_class_method :distribution_yaml
|
|
131
|
+
|
|
132
|
+
def monitoring_context(plan, context, groups)
|
|
133
|
+
{ ids: plan.ids, stack_name: context[:stack_name], alb_id: context[:alb_id],
|
|
134
|
+
distribution_id: context[:distribution_id], target_groups: groups }
|
|
135
|
+
end
|
|
136
|
+
private_class_method :monitoring_context
|
|
137
|
+
|
|
138
|
+
def extra_resources_yaml(plan, context)
|
|
139
|
+
alerts = plan.alerts
|
|
140
|
+
groups = plan.layout.balanced.to_h { |container| [container.name, container.target_group_id] }
|
|
141
|
+
monitoring = alerts ? Monitoring.yaml(alerts, monitoring_context(plan, context, groups)) : ""
|
|
142
|
+
blocks([Containers.repositories_yaml(plan.layout), Extras.resources_yaml(plan.extras),
|
|
143
|
+
monitoring.rstrip].reject(&:empty?).join("\n\n"))
|
|
144
|
+
end
|
|
145
|
+
private_class_method :extra_resources_yaml
|
|
146
|
+
|
|
147
|
+
# Adds one output per added container's repository, plus the cluster and service names a
|
|
148
|
+
# deploy script needs to find the running service.
|
|
149
|
+
def outputs_yaml(plan)
|
|
150
|
+
listed = plan.layout.multi? ? container_outputs(plan) : ""
|
|
151
|
+
alerts = plan.alerts ? "AlertsTopicArn:\n Value: !Ref #{plan.ids[:alerts_topic]}\n" : ""
|
|
152
|
+
listed + alerts + Extras.outputs_yaml(plan.extras[:outputs])
|
|
153
|
+
end
|
|
154
|
+
private_class_method :outputs_yaml
|
|
155
|
+
|
|
156
|
+
def container_outputs(plan)
|
|
157
|
+
repositories = plan.layout.all.map do |container|
|
|
158
|
+
"#{Yaml.camel(container.name)}RepositoryUri:\n Value: !GetAtt #{container.repository_id}.RepositoryUri\n"
|
|
159
|
+
end
|
|
160
|
+
cluster = "ClusterName:\n Value: !Ref #{plan.ids[:cluster]}\n"
|
|
161
|
+
service = "ServiceName:\n Value: !GetAtt #{plan.ids[:service]}.Name\n"
|
|
162
|
+
cluster + service + repositories.join
|
|
163
|
+
end
|
|
164
|
+
private_class_method :container_outputs
|
|
165
|
+
|
|
166
|
+
# Joins resource blocks so the section ends in the blank line the template expects before
|
|
167
|
+
# the next resource, and is empty when there is nothing to add.
|
|
168
|
+
def blocks(text)
|
|
169
|
+
text.empty? ? "" : "#{text}\n\n"
|
|
170
|
+
end
|
|
171
|
+
private_class_method :blocks
|
|
172
|
+
|
|
173
|
+
# Ends a block in one newline, and leaves an empty block empty.
|
|
174
|
+
def lines(text)
|
|
175
|
+
text.empty? ? "" : "#{text}\n"
|
|
176
|
+
end
|
|
177
|
+
private_class_method :lines
|
|
178
|
+
|
|
179
|
+
def check_duplicates!(template)
|
|
180
|
+
resources = template[/^Resources:\n(.*?)^Outputs:/m, 1].to_s
|
|
181
|
+
parameters = template[/^Parameters:\n(.*?)^Resources:/m, 1].to_s
|
|
182
|
+
repeated = Yaml.duplicate_keys(resources) + Yaml.duplicate_keys(parameters)
|
|
183
|
+
unless repeated.empty?
|
|
184
|
+
raise ArgumentError, "logical id or parameter #{repeated.uniq.join(', ')} is declared more than once; " \
|
|
185
|
+
"check logical_ids, containers and parameters for clashes"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
private_class_method :check_duplicates!
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
require_relative "check"
|
|
2
|
+
require_relative "yaml"
|
|
3
|
+
require_relative "cdn_default"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Deploy
|
|
8
|
+
module Fargate
|
|
9
|
+
# The CloudFront distribution in front of the load balancer.
|
|
10
|
+
#
|
|
11
|
+
# With no `cdn` setting the stack gets `default_yaml`: the default
|
|
12
|
+
# certificate, one origin, and one behavior that caches nothing. A
|
|
13
|
+
# `cdn` setting replaces that with a distribution described by the
|
|
14
|
+
# world.
|
|
15
|
+
#
|
|
16
|
+
# ## Settings
|
|
17
|
+
#
|
|
18
|
+
# `cdn` is a hash with these optional keys:
|
|
19
|
+
#
|
|
20
|
+
# - `aliases`: hostnames the distribution answers to. Needs `certificate_arn`.
|
|
21
|
+
# - `certificate_arn`: an `ACM` certificate in us-east-1, or an intrinsic such as
|
|
22
|
+
# `"!Ref CertArn"`.
|
|
23
|
+
# - `minimum_protocol`: the TLS policy for the certificate; `TLSv1.2_2021` by default.
|
|
24
|
+
# - `origin_id`: the id of the load-balancer origin; `<AlbId>Origin` by default.
|
|
25
|
+
# - `origin_ssl_protocols`: the TLS versions CloudFront may use to reach the origin, such
|
|
26
|
+
# as `["TLSv1.2"]`.
|
|
27
|
+
# - `explicit_origin_ports`: false leaves `HTTPPort` and `HTTPSPort` unset; true by
|
|
28
|
+
# default.
|
|
29
|
+
# - `origin_secret`: `{header:, parameter:}` sends a secret header to the origin, filled
|
|
30
|
+
# from a `NoEcho` template parameter the stack declares under that name.
|
|
31
|
+
# - `extra_origins`: S3 origins, each `{id:, domain_name:, origin_access_control_id:}`.
|
|
32
|
+
# - `default_behavior`: `{origin:, viewer_protocol:, methods:, compress:, cache_policy:,
|
|
33
|
+
# origin_request_policy:}`.
|
|
34
|
+
# - `behaviors`: entries of the same shape with a required `path`; the first match wins,
|
|
35
|
+
# so order matters.
|
|
36
|
+
# - `retain`: true keeps the distribution when the stack is deleted or replaces it.
|
|
37
|
+
#
|
|
38
|
+
# A cache or origin-request policy is a managed policy's name (`caching_disabled`,
|
|
39
|
+
# `caching_optimized`, `all_viewer`, `all_viewer_except_host`, or `none` to omit an origin
|
|
40
|
+
# request policy) or the id of a policy in the account. `methods` is `all`, `read` or
|
|
41
|
+
# `get_head`.
|
|
42
|
+
module Cdn
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
MANAGED_POLICIES = {
|
|
46
|
+
"caching_disabled" => ["4135ea2d-6df8-44a3-9df3-4b5a84be39ad", "Managed-CachingDisabled"],
|
|
47
|
+
"caching_optimized" => ["658327ea-f89d-4fab-a63d-7e88639e58f6", "Managed-CachingOptimized"],
|
|
48
|
+
"all_viewer" => ["216adef6-5c7f-47e4-b989-5492eafa07d3", "Managed-AllViewer"],
|
|
49
|
+
"all_viewer_except_host" => ["b689b0a8-53d0-40ab-baf2-68738e2966ac", "Managed-AllViewerExceptHostHeader"]
|
|
50
|
+
}.freeze
|
|
51
|
+
METHODS = {
|
|
52
|
+
"all" => %w[GET HEAD OPTIONS PUT POST PATCH DELETE],
|
|
53
|
+
"read" => %w[GET HEAD OPTIONS],
|
|
54
|
+
"get_head" => %w[GET HEAD]
|
|
55
|
+
}.freeze
|
|
56
|
+
PROTOCOLS = %w[allow-all https-only redirect-to-https].freeze
|
|
57
|
+
KEYS = [
|
|
58
|
+
:aliases, :certificate_arn, :minimum_protocol, :origin_id, :origin_ssl_protocols, :explicit_origin_ports,
|
|
59
|
+
:origin_secret, :extra_origins, :default_behavior, :behaviors, :retain
|
|
60
|
+
].freeze
|
|
61
|
+
BEHAVIOR_KEYS = [:origin, :viewer_protocol, :methods, :compress, :cache_policy, :origin_request_policy].freeze
|
|
62
|
+
UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
63
|
+
ACM_ARN = %r{\Aarn:aws:acm:us-east-1:\d{12}:certificate/[A-Za-z0-9-]+\z}
|
|
64
|
+
HOSTNAME = /\A(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+[A-Za-z]{2,}\z/
|
|
65
|
+
|
|
66
|
+
# Reads and checks the `cdn` setting.
|
|
67
|
+
#
|
|
68
|
+
# @param setting [Hash, nil] the world's `cdn` value, or nil when it sets none
|
|
69
|
+
# @param default_origin_id [String] the load-balancer origin's id when `origin_id` is not
|
|
70
|
+
# set
|
|
71
|
+
# @return [Hash{Symbol => Object}, nil] the checked options with defaults applied, or nil
|
|
72
|
+
# for none
|
|
73
|
+
# @raise [ArgumentError] if an option is unknown, malformed, or names an origin that does
|
|
74
|
+
# not exist
|
|
75
|
+
def normalize(setting, default_origin_id:)
|
|
76
|
+
return nil if setting.nil?
|
|
77
|
+
|
|
78
|
+
given = Check.hash!(setting, "cdn", allowed: KEYS)
|
|
79
|
+
options = base_options(given, default_origin_id)
|
|
80
|
+
options[:extra_origins] = extra_origins(given.fetch(:extra_origins, []))
|
|
81
|
+
origins = [options[:origin_id], *options[:extra_origins].map { |origin| origin[:id] }]
|
|
82
|
+
check_unique!(origins, "cdn origin ids")
|
|
83
|
+
options[:default_behavior] =
|
|
84
|
+
behavior(given.fetch(:default_behavior, {}), "cdn default_behavior", options, origins, path: false)
|
|
85
|
+
options[:behaviors] = behaviors(given.fetch(:behaviors, []), options, origins)
|
|
86
|
+
options
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Lists the template parameters the options need.
|
|
90
|
+
#
|
|
91
|
+
# @param options [Hash, nil] the checked options from `normalize`
|
|
92
|
+
# @return [String] flush-left parameter text, or empty
|
|
93
|
+
def parameters_yaml(options)
|
|
94
|
+
secret = options && options[:origin_secret]
|
|
95
|
+
return "" unless secret
|
|
96
|
+
|
|
97
|
+
<<~PARAMETER
|
|
98
|
+
#{secret[:parameter]}:
|
|
99
|
+
Type: String
|
|
100
|
+
NoEcho: true
|
|
101
|
+
Description: Secret header value CloudFront sends to the origin, so the origin can tell its own traffic.
|
|
102
|
+
PARAMETER
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Renders the distribution for a set of checked options.
|
|
106
|
+
#
|
|
107
|
+
# @param options [Hash] the checked options from `normalize`
|
|
108
|
+
# @param distribution_id [String] the logical id the distribution is given
|
|
109
|
+
# @param alb_id [String] the logical id of the load balancer the origin points at
|
|
110
|
+
# @return [String] the flush-left resource text ending in a newline
|
|
111
|
+
def yaml(options, distribution_id:, alb_id:)
|
|
112
|
+
lines = ["#{distribution_id}:", " Type: AWS::CloudFront::Distribution"]
|
|
113
|
+
lines.push(" DeletionPolicy: Retain", " UpdateReplacePolicy: Retain") if options[:retain]
|
|
114
|
+
lines.push(" Properties:", " DistributionConfig:", " Enabled: true", " HttpVersion: http2")
|
|
115
|
+
lines.concat(alias_lines(options), certificate_lines(options), origin_lines(options, alb_id))
|
|
116
|
+
lines.concat(behavior_lines("DefaultCacheBehavior", options[:default_behavior], " "))
|
|
117
|
+
lines << " CacheBehaviors:" unless options[:behaviors].empty?
|
|
118
|
+
options[:behaviors].each { |entry| lines.concat(behavior_lines(nil, entry, " ")) }
|
|
119
|
+
"#{lines.join("\n")}\n"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def behaviors(entries, options, origins)
|
|
123
|
+
Check.hashes!(entries, "cdn behaviors", allowed: BEHAVIOR_KEYS + [:path], required: [:path]).map do |entry|
|
|
124
|
+
behavior(entry, "cdn behaviors[#{entry[:path]}]", options, origins, path: true)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
private_class_method :behaviors
|
|
128
|
+
|
|
129
|
+
def base_options(given, default_origin_id)
|
|
130
|
+
aliases = Check.strings!(given.fetch(:aliases, []), "cdn aliases")
|
|
131
|
+
bad = aliases.grep_v(HOSTNAME)
|
|
132
|
+
raise ArgumentError, "cdn aliases must be hostnames, got #{bad.join(', ')}" unless bad.empty?
|
|
133
|
+
|
|
134
|
+
certificate = given[:certificate_arn]&.to_s
|
|
135
|
+
if !aliases.empty? && certificate.nil?
|
|
136
|
+
raise ArgumentError,
|
|
137
|
+
"cdn aliases need a certificate_arn, since CloudFront serves an alias only over a certificate"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
aliases: aliases, certificate_arn: certificate_arn(certificate),
|
|
142
|
+
minimum_protocol: given.fetch(:minimum_protocol, "TLSv1.2_2021").to_s,
|
|
143
|
+
origin_id: Check.logical_id!(given.fetch(:origin_id, default_origin_id), "cdn origin_id"),
|
|
144
|
+
origin_ssl_protocols: Check.strings!(given.fetch(:origin_ssl_protocols, []), "cdn origin_ssl_protocols"),
|
|
145
|
+
explicit_origin_ports: Check.boolean!(given.fetch(:explicit_origin_ports, true), "cdn explicit_origin_ports"),
|
|
146
|
+
origin_secret: origin_secret(given[:origin_secret]),
|
|
147
|
+
retain: Check.boolean!(given.fetch(:retain, false), "cdn retain")
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
private_class_method :base_options
|
|
151
|
+
|
|
152
|
+
def certificate_arn(value)
|
|
153
|
+
return nil if value.nil?
|
|
154
|
+
return value if value.start_with?("!") || ACM_ARN.match?(value)
|
|
155
|
+
|
|
156
|
+
raise ArgumentError,
|
|
157
|
+
"cdn certificate_arn must be an ACM certificate ARN in us-east-1 (CloudFront reads no other region) " \
|
|
158
|
+
"or an intrinsic, got #{value.inspect}"
|
|
159
|
+
end
|
|
160
|
+
private_class_method :certificate_arn
|
|
161
|
+
|
|
162
|
+
def origin_secret(value)
|
|
163
|
+
return nil if value.nil?
|
|
164
|
+
|
|
165
|
+
secret = Check.hash!(value, "cdn origin_secret", allowed: [:header, :parameter], required: [:header, :parameter])
|
|
166
|
+
unless secret[:header].to_s.match?(/\A[A-Za-z0-9-]+\z/)
|
|
167
|
+
raise ArgumentError,
|
|
168
|
+
"cdn origin_secret header must be an HTTP header name, got #{secret[:header].inspect}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
{ header: secret[:header].to_s, parameter: Check.logical_id!(secret[:parameter], "cdn origin_secret parameter") }
|
|
172
|
+
end
|
|
173
|
+
private_class_method :origin_secret
|
|
174
|
+
|
|
175
|
+
def extra_origins(entries)
|
|
176
|
+
Check.hashes!(entries, "cdn extra_origins", allowed: [:id, :domain_name, :origin_access_control_id],
|
|
177
|
+
required: [:id, :domain_name]).map do |entry|
|
|
178
|
+
{ id: Check.logical_id!(entry[:id], "cdn extra_origins id"), domain_name: entry[:domain_name].to_s,
|
|
179
|
+
origin_access_control_id: entry[:origin_access_control_id]&.to_s }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
private_class_method :extra_origins
|
|
183
|
+
|
|
184
|
+
def check_unique!(values, what)
|
|
185
|
+
repeated = values.tally.select { |_value, count| count > 1 }.keys
|
|
186
|
+
raise ArgumentError, "#{what} must be unique; repeated: #{repeated.join(', ')}" unless repeated.empty?
|
|
187
|
+
end
|
|
188
|
+
private_class_method :check_unique!
|
|
189
|
+
|
|
190
|
+
def behavior(entry, where, options, origins, path:)
|
|
191
|
+
given = Check.hash!(entry, where, allowed: BEHAVIOR_KEYS + (path ? [:path] : []))
|
|
192
|
+
origin = given.fetch(:origin, options[:origin_id]).to_s
|
|
193
|
+
unless origins.include?(origin)
|
|
194
|
+
raise ArgumentError,
|
|
195
|
+
"#{where} origin #{origin.inspect} is not one of #{origins.join(', ')}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
balanced = origin == options[:origin_id]
|
|
199
|
+
result = {
|
|
200
|
+
origin: origin, methods: allowed_methods(given.fetch(:methods, path ? "read" : "all"), where),
|
|
201
|
+
viewer_protocol: Check.one_of!(given.fetch(:viewer_protocol, "redirect-to-https"), "#{where} viewer_protocol",
|
|
202
|
+
PROTOCOLS),
|
|
203
|
+
cache_policy: policy(given.fetch(:cache_policy, "caching_disabled"), "#{where} cache_policy"),
|
|
204
|
+
origin_request_policy: policy(given.fetch(:origin_request_policy, balanced ? "all_viewer" : "none"),
|
|
205
|
+
"#{where} origin_request_policy", none: true)
|
|
206
|
+
}
|
|
207
|
+
result[:compress] = Check.boolean!(given[:compress], "#{where} compress") if given.key?(:compress)
|
|
208
|
+
result[:compress] = true if !path && !given.key?(:compress)
|
|
209
|
+
result[:path] = path_pattern(given[:path], where) if path
|
|
210
|
+
result
|
|
211
|
+
end
|
|
212
|
+
private_class_method :behavior
|
|
213
|
+
|
|
214
|
+
def path_pattern(value, where)
|
|
215
|
+
text = value.to_s
|
|
216
|
+
raise ArgumentError, "#{where} path must start with / or *, got #{text.inspect}" unless text.start_with?("/", "*")
|
|
217
|
+
|
|
218
|
+
text
|
|
219
|
+
end
|
|
220
|
+
private_class_method :path_pattern
|
|
221
|
+
|
|
222
|
+
def allowed_methods(value, where)
|
|
223
|
+
return Check.strings!(value, "#{where} methods", min: 1) if value.is_a?(Array)
|
|
224
|
+
return METHODS.fetch(value.to_s) if METHODS.key?(value.to_s)
|
|
225
|
+
|
|
226
|
+
raise ArgumentError,
|
|
227
|
+
"#{where} methods must be #{METHODS.keys.join(', ')} or a list of HTTP methods, got #{value.inspect}"
|
|
228
|
+
end
|
|
229
|
+
private_class_method :allowed_methods
|
|
230
|
+
|
|
231
|
+
def policy(value, where, none: false)
|
|
232
|
+
text = value.to_s
|
|
233
|
+
return nil if none && text == "none"
|
|
234
|
+
return MANAGED_POLICIES.fetch(text) if MANAGED_POLICIES.key?(text)
|
|
235
|
+
return [text, nil] if UUID.match?(text)
|
|
236
|
+
|
|
237
|
+
raise ArgumentError,
|
|
238
|
+
"#{where} must be #{MANAGED_POLICIES.keys.join(', ')}#{', none' if none}, or a policy id, got #{value.inspect}"
|
|
239
|
+
end
|
|
240
|
+
private_class_method :policy
|
|
241
|
+
|
|
242
|
+
def alias_lines(options)
|
|
243
|
+
return [] if options[:aliases].empty?
|
|
244
|
+
|
|
245
|
+
[" Aliases:"] + options[:aliases].map { |name| " - #{name}" }
|
|
246
|
+
end
|
|
247
|
+
private_class_method :alias_lines
|
|
248
|
+
|
|
249
|
+
def certificate_lines(options)
|
|
250
|
+
return [" ViewerCertificate:", " CloudFrontDefaultCertificate: true"] unless options[:certificate_arn]
|
|
251
|
+
|
|
252
|
+
[" ViewerCertificate:", " AcmCertificateArn: #{options[:certificate_arn]}",
|
|
253
|
+
" SslSupportMethod: sni-only", " MinimumProtocolVersion: #{options[:minimum_protocol]}"]
|
|
254
|
+
end
|
|
255
|
+
private_class_method :certificate_lines
|
|
256
|
+
|
|
257
|
+
def origin_lines(options, alb_id)
|
|
258
|
+
[" Origins:"] + alb_origin_lines(options, alb_id) + options[:extra_origins].flat_map do |origin|
|
|
259
|
+
s3_origin_lines(origin)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
private_class_method :origin_lines
|
|
263
|
+
|
|
264
|
+
def alb_origin_lines(options, alb_id)
|
|
265
|
+
lines = [" - Id: #{options[:origin_id]}", " DomainName: !GetAtt #{alb_id}.DNSName"]
|
|
266
|
+
secret = options[:origin_secret]
|
|
267
|
+
if secret
|
|
268
|
+
lines.push(" OriginCustomHeaders:", " - HeaderName: #{secret[:header]}",
|
|
269
|
+
" HeaderValue: !Ref #{secret[:parameter]}")
|
|
270
|
+
end
|
|
271
|
+
lines << " CustomOriginConfig:"
|
|
272
|
+
lines << " OriginProtocolPolicy: http-only"
|
|
273
|
+
lines.push(" HTTPPort: 80", " HTTPSPort: 443") if options[:explicit_origin_ports]
|
|
274
|
+
unless options[:origin_ssl_protocols].empty?
|
|
275
|
+
lines << " OriginSSLProtocols: #{Yaml.flow_list(options[:origin_ssl_protocols])}"
|
|
276
|
+
end
|
|
277
|
+
lines
|
|
278
|
+
end
|
|
279
|
+
private_class_method :alb_origin_lines
|
|
280
|
+
|
|
281
|
+
def s3_origin_lines(origin)
|
|
282
|
+
lines = [" - Id: #{origin[:id]}", " DomainName: #{Yaml.string(origin[:domain_name])}"]
|
|
283
|
+
if origin[:origin_access_control_id]
|
|
284
|
+
lines << " OriginAccessControlId: #{Yaml.string(origin[:origin_access_control_id])}"
|
|
285
|
+
end
|
|
286
|
+
lines << " S3OriginConfig: { OriginAccessIdentity: \"\" }"
|
|
287
|
+
lines
|
|
288
|
+
end
|
|
289
|
+
private_class_method :s3_origin_lines
|
|
290
|
+
|
|
291
|
+
def behavior_lines(key, entry, base)
|
|
292
|
+
pad = "#{base} "
|
|
293
|
+
head = key ? "#{base}#{key}:" : "#{base}- PathPattern: #{Yaml.string(entry[:path])}"
|
|
294
|
+
[head, "#{pad}TargetOriginId: #{entry[:origin]}", *behavior_property_lines(entry, pad)]
|
|
295
|
+
end
|
|
296
|
+
private_class_method :behavior_lines
|
|
297
|
+
|
|
298
|
+
def behavior_property_lines(entry, pad)
|
|
299
|
+
lines = ["#{pad}ViewerProtocolPolicy: #{entry[:viewer_protocol]}",
|
|
300
|
+
"#{pad}AllowedMethods: #{Yaml.flow_list(entry[:methods])}",
|
|
301
|
+
"#{pad}CachedMethods: [GET, HEAD]"]
|
|
302
|
+
lines << "#{pad}Compress: #{entry[:compress]}" if entry.key?(:compress)
|
|
303
|
+
lines << policy_line(pad, "CachePolicyId", entry[:cache_policy])
|
|
304
|
+
lines << policy_line(pad, "OriginRequestPolicyId", entry[:origin_request_policy]) if entry[:origin_request_policy]
|
|
305
|
+
lines
|
|
306
|
+
end
|
|
307
|
+
private_class_method :behavior_property_lines
|
|
308
|
+
|
|
309
|
+
def policy_line(pad, key, policy)
|
|
310
|
+
id, label = policy
|
|
311
|
+
label ? "#{pad}#{key}: #{id} # #{label}" : "#{pad}#{key}: #{id}"
|
|
312
|
+
end
|
|
313
|
+
private_class_method :policy_line
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Projections
|
|
3
|
+
module Deploy
|
|
4
|
+
module Fargate
|
|
5
|
+
# The default distribution's text; `cdn.rb` documents the module.
|
|
6
|
+
module Cdn
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# The distribution a stack gets when its world sets no `cdn` options.
|
|
10
|
+
#
|
|
11
|
+
# Real HTTPS in front of the load balancer's HTTP-only listener, and
|
|
12
|
+
# the one safe caching default the generator can offer: every route
|
|
13
|
+
# is treated as session-driven, so nothing is cached. The comment
|
|
14
|
+
# inside the rendered block explains why, and reaches the generated
|
|
15
|
+
# template unchanged.
|
|
16
|
+
#
|
|
17
|
+
# @param alb_id [String] the logical id of the load balancer
|
|
18
|
+
# @param distribution_id [String] the logical id the distribution is given
|
|
19
|
+
# @return [String] the flush-left resource text with its comments, ending in a newline
|
|
20
|
+
def default_yaml(alb_id:, distribution_id:)
|
|
21
|
+
<<~DEFAULT
|
|
22
|
+
# Real HTTPS (the ALB's own Listener above is HTTP-only —
|
|
23
|
+
# nothing else in this stack terminates TLS) and, just as
|
|
24
|
+
# important, the ONE safe default this generator can offer
|
|
25
|
+
# for caching it has no way to reason about: Managed-
|
|
26
|
+
# CachingDisabled. This domain's own routes — including
|
|
27
|
+
# every hecks-native /login, /logout, /auth/google(/callback),
|
|
28
|
+
# /admin/members request (web.rs's own auth_gate/auth_route,
|
|
29
|
+
# generic across every domain, not just this one's own
|
|
30
|
+
# dispatch commands) — are all session-cookie-driven, and
|
|
31
|
+
# this generator has no way to tell which of a domain's own
|
|
32
|
+
# paths would ever be safe to cache. Found live, the hard
|
|
33
|
+
# way (2026-09-21): a hand-authored CloudFront
|
|
34
|
+
# stack applied the OPPOSITE default — a custom, cookie-
|
|
35
|
+
# blind cache policy with a 90-120s TTL — and it served one
|
|
36
|
+
# signed-in session's own response (a short-lived SSO
|
|
37
|
+
# handoff token among them) back to a different, unrelated
|
|
38
|
+
# request within that window. A domain that DOES know one
|
|
39
|
+
# of its own paths is genuinely safe to cache (a public,
|
|
40
|
+
# non-personalized page) adds its own more specific
|
|
41
|
+
# CacheBehavior through the `cdn` setting's `behaviors`,
|
|
42
|
+
# never by loosening this one.
|
|
43
|
+
#{distribution_id}:
|
|
44
|
+
Type: AWS::CloudFront::Distribution
|
|
45
|
+
Properties:
|
|
46
|
+
DistributionConfig:
|
|
47
|
+
Enabled: true
|
|
48
|
+
HttpVersion: http2
|
|
49
|
+
# No ACM/custom domain by default — CloudFront requires
|
|
50
|
+
# an ACM cert in us-east-1 specifically to attach a
|
|
51
|
+
# custom Aliases entry, a real cross-region dependency
|
|
52
|
+
# this generator can't assume. CloudFront's own default
|
|
53
|
+
# *.cloudfront.net certificate/hostname are what
|
|
54
|
+
# Outputs.CloudFrontDomain below reports; the `cdn`
|
|
55
|
+
# setting's `aliases` and `certificate_arn` attach a
|
|
56
|
+
# real domain.
|
|
57
|
+
ViewerCertificate:
|
|
58
|
+
CloudFrontDefaultCertificate: true
|
|
59
|
+
Origins:
|
|
60
|
+
- Id: #{alb_id}Origin
|
|
61
|
+
DomainName: !GetAtt #{alb_id}.DNSName
|
|
62
|
+
CustomOriginConfig:
|
|
63
|
+
OriginProtocolPolicy: http-only
|
|
64
|
+
HTTPPort: 80
|
|
65
|
+
HTTPSPort: 443
|
|
66
|
+
DefaultCacheBehavior:
|
|
67
|
+
TargetOriginId: #{alb_id}Origin
|
|
68
|
+
ViewerProtocolPolicy: redirect-to-https
|
|
69
|
+
Compress: true
|
|
70
|
+
AllowedMethods: [GET, HEAD, OPTIONS, PUT, PATCH, POST, DELETE]
|
|
71
|
+
CachedMethods: [GET, HEAD]
|
|
72
|
+
# Managed-CachingDisabled — see this resource's own
|
|
73
|
+
# header comment for why nothing else is safe here
|
|
74
|
+
# by default.
|
|
75
|
+
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
|
|
76
|
+
# Managed-AllViewer — forwards every cookie/header/
|
|
77
|
+
# query string through uncached, so rust/host's own
|
|
78
|
+
# session-cookie-based auth sees the real request
|
|
79
|
+
# exactly as the browser sent it.
|
|
80
|
+
OriginRequestPolicyId: 216adef6-5c7f-47e4-b989-5492eafa07d3
|
|
81
|
+
DEFAULT
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|