hecks 2.6.0 → 2.8.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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -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