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.
Files changed (82) 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/bluebook/dsl/bootstrap_table.rb +2 -0
  6. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  7. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  8. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  9. data/lib/hecks/bluebook/hexagon.rb +15 -7
  10. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  11. data/lib/hecks/bluebook/model_check.rb +1 -1
  12. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  13. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  14. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  15. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  16. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  17. data/lib/hecks/forms/value_object_shape.rb +1 -1
  18. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  19. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  20. data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
  21. data/lib/hecks/language/world/world.bluebook +32 -2
  22. data/lib/hecks/ports/key_vault.rb +1 -1
  23. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  24. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  25. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  26. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  27. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  28. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  29. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  30. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  31. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  32. data/lib/hecks/ports/persistence.rb +4 -0
  33. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  34. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  35. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  36. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  37. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  38. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  39. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  40. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  41. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  42. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  43. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  44. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  45. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  46. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  47. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  48. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  49. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  50. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  51. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  52. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  53. data/lib/hecks/projections/deploy/preview.rb +103 -0
  54. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  55. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  56. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  57. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  58. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  59. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  60. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  61. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  62. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  63. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  64. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  65. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  66. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  67. data/lib/hecks/projections.rb +2 -0
  68. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  69. data/lib/hecks/release/gem_pin.rb +112 -0
  70. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  71. data/lib/hecks/runtime/loader.rb +26 -2
  72. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  73. data/lib/hecks/runtime/registry/verification.rb +4 -2
  74. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  75. data/lib/hecks/runtime/registry.rb +14 -9
  76. data/lib/hecks/runtime/tenant_check.rb +1 -1
  77. data/lib/hecks/vendoring/git_environment.rb +35 -0
  78. data/lib/hecks/vendoring/git_source.rb +102 -0
  79. data/lib/hecks/vendoring.rb +102 -0
  80. data/lib/hecks/version.rb +1 -1
  81. data/lib/hecks.rb +2 -1
  82. metadata +45 -1
@@ -0,0 +1,125 @@
1
+ require "open3"
2
+ require "securerandom"
3
+ require_relative "postgres_dump/connection"
4
+
5
+ module Hecks
6
+ module Ports
7
+ module Persistence
8
+ # Dumps one schema of a Postgres database and proves the dump restores.
9
+ #
10
+ # ## What the proof is
11
+ #
12
+ # A dump nobody has restored is a hope. After `pg_dump` writes the file,
13
+ # the dump is restored into a scratch database (on the source server, or on a
14
+ # separate `verify_url` server such as the one the dump will be loaded
15
+ # into) and every table's row count is compared with the source. That shows
16
+ # the dump is complete and loadable. It does not re-derive current state
17
+ # from an event journal, so a caller who needs that proves it separately.
18
+ #
19
+ # ## Credentials
20
+ #
21
+ # URLs are split by `Connection`: the password travels in the child
22
+ # process's environment, never in `argv`.
23
+ #
24
+ # ## Requirements
25
+ #
26
+ # The `pg` gem, and `pg_dump` and `pg_restore` on `PATH` at a version no older
27
+ # than the server's. Neither the gem nor the tools are a dependency of
28
+ # hecks; nothing here loads until a dump is asked for.
29
+ class PostgresDump
30
+ # Raised for a refusal or a failed tool.
31
+ class Error < StandardError; end
32
+
33
+ # What a verified dump holds.
34
+ #
35
+ # @!attribute [r] tables
36
+ # @return [Hash{String => Integer}] row count of every table in the schema, by name
37
+ Result = Struct.new(:tables, keyword_init: true)
38
+
39
+ # @param url [String] the source database's `postgres://` URL
40
+ # @param schema [String] the schema to dump, a plain identifier
41
+ # @param verify_url [String, nil] the server to restore into for the proof; the source
42
+ # server when nil
43
+ # @raise [Error] if `schema` is not a plain identifier or a URL is not a Postgres URL
44
+ def initialize(url:, schema:, verify_url: nil)
45
+ raise Error, "schema #{schema.inspect} is not a plain identifier" unless schema.to_s.match?(/\A[a-z_][a-z0-9_]*\z/i)
46
+
47
+ @schema = schema
48
+ @source = Connection.new(url)
49
+ @verify = Connection.new(verify_url || url)
50
+ end
51
+
52
+ # Writes a custom-format dump of the schema and proves it restores.
53
+ #
54
+ # @param dest [String] the file to write
55
+ # @return [Result] the verified row counts
56
+ # @raise [Error] if the schema has no tables, a tool fails, the database refuses, or
57
+ # the restored counts differ from the source's
58
+ def call(dest)
59
+ require "pg"
60
+ raise Error, "schema #{@schema} has no tables in #{@source.database}" if counting(@source) { |db| counts(db) }.empty?
61
+
62
+ run("pg_dump", @source, "--format=custom", "--no-owner", "--no-privileges", "--schema=#{@schema}", "--file=#{dest}")
63
+ expected = counting(@source) { |db| counts(db) }
64
+ verify_restore(dest, expected)
65
+ Result.new(tables: expected)
66
+ rescue PG::Error => e
67
+ raise Error, "database error (#{e.class}): #{e.message.lines.first.to_s.strip}"
68
+ end
69
+
70
+ private
71
+
72
+ def verify_restore(dump, expected)
73
+ scratch = "dump_verify_#{Process.pid}_#{SecureRandom.hex(3)}"
74
+ counting(@verify) do |admin|
75
+ admin.exec("CREATE DATABASE #{admin.quote_ident(scratch)}")
76
+ begin
77
+ target = @verify.with_database(scratch)
78
+ run("pg_restore", target, "--no-owner", "--no-privileges", "--exit-on-error", "--dbname=#{scratch}", dump)
79
+ actual = counting(target) { |db| counts(db) }
80
+ raise Error, mismatch(expected, actual) unless actual == expected
81
+ ensure
82
+ drop(admin, scratch)
83
+ end
84
+ end
85
+ end
86
+
87
+ def counting(connection)
88
+ db = connection.connect
89
+ yield db
90
+ ensure
91
+ db&.close
92
+ end
93
+
94
+ def counts(db)
95
+ names = db.exec_params("SELECT table_name FROM information_schema.tables " \
96
+ "WHERE table_schema = $1 AND table_type = 'BASE TABLE' ORDER BY 1", [@schema])
97
+ .map { |row| row["table_name"] }
98
+ names.to_h do |name|
99
+ [name, db.exec("SELECT count(*) FROM #{db.quote_ident(@schema)}.#{db.quote_ident(name)}").getvalue(0, 0).to_i]
100
+ end
101
+ end
102
+
103
+ def mismatch(expected, actual)
104
+ differing = (expected.keys | actual.keys).reject { |name| expected[name] == actual[name] }
105
+ detail = differing.map { |name| "#{name} (source #{expected[name].inspect}, restored #{actual[name].inspect})" }
106
+ "the restored dump disagrees with the source for #{detail.join(', ')}; " \
107
+ "if the database took writes during the dump, dump it again"
108
+ end
109
+
110
+ def drop(admin, scratch)
111
+ admin.exec("DROP DATABASE IF EXISTS #{admin.quote_ident(scratch)} WITH (FORCE)")
112
+ rescue PG::Error => e
113
+ warn "could not drop scratch database #{scratch} (#{e.class}); drop it by hand"
114
+ end
115
+
116
+ def run(tool, connection, *)
117
+ _out, err, status = Open3.capture3(connection.env, tool, *)
118
+ raise Error, "#{tool} failed: #{err.lines.first(3).join.strip}" unless status.success?
119
+ rescue Errno::ENOENT
120
+ raise Error, "#{tool} is not on PATH"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -30,8 +30,8 @@ module Hecks
30
30
  # lacks `append`, `project` or `entries`
31
31
  def build(registry, domain, aggregate, bind, recover: true, settings_verb: VERB)
32
32
  registry.check_verb(bind)
33
- settings = (registry.world(domain)&.for_binding(settings_verb, bind.adapter) || {})
34
- .reject { |key, _| key.to_sym == :role }
33
+ settings = registry.binding_settings(domain, settings_verb, bind.adapter)
34
+ .reject { |key, _| key.to_sym == :role }
35
35
  registry.check_settings(bind, settings)
36
36
  # The domain, the resolved era, and (for an old checkout) the era
37
37
  # that superseded it ride along after the declared-settings
@@ -17,6 +17,10 @@ require_relative "persistence/remote_runtime"
17
17
  require_relative "persistence/null_saga_store"
18
18
  require_relative "persistence/state_codec"
19
19
 
20
+ # Loaded on first mention: nothing about a domain's own boot needs the dump
21
+ # tooling, and it wants the optional `pg` gem.
22
+ Hecks::Ports::Persistence.autoload(:PostgresDump, File.join(__dir__, "persistence/postgres_dump"))
23
+
20
24
  module Hecks
21
25
  module Ports
22
26
  # Reopened once `BindingPolicy`/`RepositoryFactory` (required above) are
@@ -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