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,40 @@
1
+ require "erb"
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Deploy
6
+ module Preview
7
+ # Renders `preview.sh`, the script that creates, updates and deletes one
8
+ # branch's preview stack.
9
+ #
10
+ # The script text lives in `preview.sh.erb` beside this file because it
11
+ # is a shell program, not Ruby: keeping it as a file lets `bash -n` and
12
+ # `shellcheck` read it as one. Every value baked into it has already
13
+ # passed `Settings`' patterns, so the template never quotes defensively.
14
+ module Script
15
+ TEMPLATE = File.join(__dir__, "preview.sh.erb").freeze
16
+
17
+ module_function
18
+
19
+ # Renders the script.
20
+ #
21
+ # @param settings [Settings] the resolved preview settings
22
+ # @return [String] the bash script, ending in one newline
23
+ def render(settings)
24
+ ERB.new(File.read(TEMPLATE), trim_mode: "-").result_with_hash(
25
+ s: settings, image_hint: image_hint(settings)
26
+ )
27
+ end
28
+
29
+ # Names the local images `deploy` expects, for the script's header.
30
+ #
31
+ # @param settings [Settings] the resolved preview settings
32
+ # @return [String] the image references, comma separated
33
+ def image_hint(settings)
34
+ settings.containers.map(&:image).join(", ")
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,140 @@
1
+ require_relative "containers"
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Deploy
6
+ module Preview
7
+ # The resolved, validated settings of one preview stack family.
8
+ #
9
+ # `Settings.build` is the only place a raw `preview` block meets the
10
+ # main stack's own facts, so every default lives here and the two
11
+ # renderers (`Template`, `Script`) read plain values.
12
+ #
13
+ # ## Why validation is this strict
14
+ #
15
+ # Every value ends up in a CloudFormation name, a Postgres database
16
+ # name that `CREATE DATABASE` cannot parameterize, or a shell script
17
+ # baked with the value. Patterns are checked once here, so neither
18
+ # renderer has to quote defensively.
19
+ class Settings
20
+ KEYS = %i[prefix alb_prefix owner_stack database_stack database_endpoint_output database_secret_output
21
+ db_prefix protected_databases protected_branches cpu memory log_retention_days session_cookie
22
+ first_admin signup_path landing_path db_init_image containers].freeze
23
+
24
+ DEFAULT_DB_INIT_IMAGE = "public.ecr.aws/docker/library/postgres:16-alpine".freeze
25
+ PREFIX_PATTERN = /\A[a-z0-9][a-z0-9-]{0,59}\z/
26
+ ALB_PREFIX_PATTERN = /\A[a-z0-9][a-z0-9-]{0,9}[a-z0-9]\z/
27
+ DB_PREFIX_PATTERN = /\A[a-z][a-z0-9_]{0,29}\z/
28
+ STACK_PATTERN = /\A[A-Za-z][A-Za-z0-9_-]{0,127}\z/
29
+ OUTPUT_PATTERN = /\A[A-Za-z0-9]+\z/
30
+ BRANCH_PATTERN = %r{\A[A-Za-z0-9._/-]+\z}
31
+ PATH_PATTERN = Containers::PATH_PATTERN
32
+ COOKIE_PATTERN = /\A[A-Za-z0-9_.-]+\z/
33
+ RETENTION_DAYS = [1, 3, 5, 7, 14, 30, 60, 90].freeze
34
+
35
+ attr_reader(*KEYS, :region, :stack_name, :infra_name, :main)
36
+
37
+ # Builds the settings for one domain's previews.
38
+ #
39
+ # @param raw [Hash{Symbol => Object}, true] the `preview` block; `true` accepts
40
+ # every default
41
+ # @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings
42
+ # @param main [Hash{Symbol => Object}] the main stack's facts: `:infra_name`,
43
+ # `:stack_name`,
44
+ # `:stack_prefix`, `:region`, `:cpu`, `:memory`, `:db_name`, `:owner_stack` (nil unless
45
+ # the main stack borrows a database), `:name`, `:port`, `:image`, `:domain`, `:web`,
46
+ # `:wasm_path`, `:ir_path`, `:schema`
47
+ # @return [Settings] the validated settings
48
+ # @raise [ArgumentError] if a key is unknown or a value breaks its pattern
49
+ def self.build(raw, deploy_settings:, main:)
50
+ raw = raw.is_a?(Hash) ? raw.transform_keys(&:to_sym) : {}
51
+ unknown = raw.keys - KEYS
52
+ unless unknown.empty?
53
+ raise ArgumentError, "unknown preview setting(s): #{unknown.join(', ')} (known: #{KEYS.join(', ')})"
54
+ end
55
+
56
+ new(raw, deploy_settings, main)
57
+ end
58
+
59
+ # @param raw [Hash{Symbol => Object}] the symbolized `preview` settings
60
+ # @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings
61
+ # @param main [Hash{Symbol => Object}] the main stack's facts, see `build`
62
+ def initialize(raw, deploy_settings, main)
63
+ @main = main
64
+ @region = main.fetch(:region)
65
+ @stack_name = main.fetch(:stack_name)
66
+ @infra_name = main.fetch(:infra_name)
67
+ assign_names(raw, main)
68
+ assign_database(raw, main)
69
+ assign_sizing(raw, main)
70
+ assign_behaviour(raw)
71
+ @containers = Containers.resolve(preview: raw, deploy_settings: deploy_settings, main: main,
72
+ signup_path: (signup_path if first_admin))
73
+ end
74
+
75
+ # Answers the host container, when the task has one.
76
+ #
77
+ # @return [Containers::Entry, nil] the entry flagged `host`
78
+ def host = containers.find(&:host)
79
+
80
+ # Answers the container that receives unmatched requests.
81
+ #
82
+ # @return [Containers::Entry] the default entry
83
+ def default_container = containers.find(&:default)
84
+
85
+ private
86
+
87
+ def assign_names(raw, main)
88
+ prefix_default = "#{main.fetch(:stack_prefix)}-#{infra_name}-preview".downcase.gsub(/[^a-z0-9-]+/, "-")
89
+ @prefix = pick(raw, :prefix, prefix_default, PREFIX_PATTERN)
90
+ alb_default = "#{infra_name.downcase.gsub(/[^a-z0-9]/, '')[0, 8]}pv"
91
+ @alb_prefix = pick(raw, :alb_prefix, alb_default, ALB_PREFIX_PATTERN)
92
+ @owner_stack = pick(raw, :owner_stack, main[:owner_stack] || stack_name, STACK_PATTERN)
93
+ @database_stack = pick(raw, :database_stack, owner_stack, STACK_PATTERN)
94
+ @database_endpoint_output = pick(raw, :database_endpoint_output, "DatabaseEndpoint", OUTPUT_PATTERN)
95
+ @database_secret_output = pick(raw, :database_secret_output, "DatabaseSecretArn", OUTPUT_PATTERN)
96
+ end
97
+
98
+ def assign_database(raw, main)
99
+ db_default = infra_name.downcase.gsub(/[^a-z0-9]+/, "_").sub(/\A[^a-z]+/, "")[0, 30].sub(/_+\z/, "")
100
+ @db_prefix = pick(raw, :db_prefix, db_default, DB_PREFIX_PATTERN)
101
+ extra = Array(raw[:protected_databases]).map(&:to_s)
102
+ @protected_databases = ([main.fetch(:db_name).to_s.downcase, "postgres", "template0", "template1"] + extra).uniq
103
+ end
104
+
105
+ def assign_sizing(raw, main)
106
+ @cpu = Integer(raw.fetch(:cpu, main.fetch(:cpu)))
107
+ @memory = Integer(raw.fetch(:memory, main.fetch(:memory)))
108
+ @log_retention_days = Integer(raw.fetch(:log_retention_days, 7))
109
+ return if RETENTION_DAYS.include?(log_retention_days)
110
+
111
+ raise ArgumentError, "preview log_retention_days must be one of #{RETENTION_DAYS.join(', ')}"
112
+ end
113
+
114
+ def assign_behaviour(raw)
115
+ @protected_branches = Array(raw.fetch(:protected_branches, %w[main master])).map(&:to_s)
116
+ unless protected_branches.all? { |b| b.match?(BRANCH_PATTERN) }
117
+ raise ArgumentError, "preview protected_branches must be plain branch names"
118
+ end
119
+
120
+ @session_cookie = pick(raw, :session_cookie, nil, COOKIE_PATTERN)
121
+ @first_admin = raw.fetch(:first_admin, true) != false
122
+ @signup_path = pick(raw, :signup_path, "/signups", PATH_PATTERN)
123
+ @landing_path = pick(raw, :landing_path, "/", PATH_PATTERN)
124
+ @db_init_image = pick(raw, :db_init_image, DEFAULT_DB_INIT_IMAGE, %r{\A[A-Za-z0-9./:_@-]+\z})
125
+ end
126
+
127
+ def pick(raw, key, default, pattern)
128
+ value = raw.key?(key) ? raw[key] : default
129
+ return value if value.nil?
130
+
131
+ value = value.to_s
132
+ return value if value.match?(pattern)
133
+
134
+ raise ArgumentError, "preview #{key} #{value.inspect} must match #{pattern.inspect}"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,215 @@
1
+ require "json"
2
+ require_relative "yaml_text"
3
+
4
+ module Hecks
5
+ module Projections
6
+ module Deploy
7
+ module Preview
8
+ # The task definitions of a preview stack: the containers it runs and the
9
+ # one-shot task that creates its database.
10
+ #
11
+ # The host container gets the domain, session and database environment; a
12
+ # container's own `environment` wins over a generated entry of the same name.
13
+ module TaskYaml
14
+ # The one-shot task's script. Idempotent, and refuses names the preview must never touch.
15
+ DB_INIT_SCRIPT = <<~SH.freeze
16
+ set -eu
17
+ case "$TARGET_DB" in
18
+ ""|*[!a-z0-9_]*) echo "refusing TARGET_DB '$TARGET_DB': lowercase letters, digits and underscores only" >&2; exit 1 ;;
19
+ esac
20
+ [ "${#TARGET_DB}" -le 63 ] || { echo "refusing TARGET_DB '$TARGET_DB': longer than 63 characters" >&2; exit 1; }
21
+ for protected in $PROTECTED_DATABASES; do
22
+ [ "$TARGET_DB" != "$protected" ] || { echo "refusing to manage the protected database '$protected'" >&2; exit 1; }
23
+ done
24
+ export PGSSLMODE=require PGCONNECT_TIMEOUT=10
25
+ found=$(psql -h "$DB_HOST" -U "$PGUSER" -d postgres -Atc "SELECT 1 FROM pg_database WHERE datname = '$TARGET_DB'")
26
+ if [ "${ACTION:-create}" = "drop" ]; then
27
+ if [ "$found" = "1" ]; then
28
+ psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -U "$PGUSER" -d postgres -c "DROP DATABASE \\"$TARGET_DB\\" WITH (FORCE)"
29
+ echo "dropped database $TARGET_DB"
30
+ else
31
+ echo "database $TARGET_DB does not exist"
32
+ fi
33
+ elif [ "$found" = "1" ]; then
34
+ echo "database $TARGET_DB already exists"
35
+ else
36
+ psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -U "$PGUSER" -d postgres -c "CREATE DATABASE \\"$TARGET_DB\\""
37
+ echo "created database $TARGET_DB"
38
+ fi
39
+ SH
40
+
41
+ # The database credentials as container secrets, so the password never appears as a plain
42
+ # value in the task definition.
43
+ DB_INIT_SECRETS = <<~YAML.chomp.freeze
44
+ Secrets:
45
+ - Name: PGUSER
46
+ ValueFrom: !Sub "${OwningDatabaseSecretArn}:username::"
47
+ - Name: PGPASSWORD
48
+ ValueFrom: !Sub "${OwningDatabaseSecretArn}:password::"
49
+ YAML
50
+
51
+ extend YamlText
52
+
53
+ module_function
54
+
55
+ # Renders the one-shot task that creates (or drops) this preview's database.
56
+ #
57
+ # @param settings [Settings] the resolved preview settings
58
+ # @return [String] the task definition's resource block
59
+ def db_init_task(settings)
60
+ <<~YAML.chomp
61
+ # Run by preview.sh (`aws ecs run-task`) after the images are pushed and before the
62
+ # service scales up: the database instance is reachable only from inside the VPC, so
63
+ # this is the one place a laptop-driven deploy can run CREATE DATABASE. Idempotent.
64
+ DbInitTaskDefinition:
65
+ Type: AWS::ECS::TaskDefinition
66
+ Properties:
67
+ Family: !Sub "#{settings.prefix}-${EnvName}-dbinit"
68
+ RequiresCompatibilities: [FARGATE]
69
+ NetworkMode: awsvpc
70
+ Cpu: "256"
71
+ Memory: "512"
72
+ RuntimePlatform:
73
+ CpuArchitecture: ARM64
74
+ OperatingSystemFamily: LINUX
75
+ ExecutionRoleArn: !GetAtt ExecutionRole.Arn
76
+ TaskRoleArn: !GetAtt TaskRole.Arn
77
+ ContainerDefinitions:
78
+ - Name: dbinit
79
+ Image: #{settings.db_init_image}
80
+ Essential: true
81
+ Command:
82
+ - sh
83
+ - -c
84
+ - |
85
+ #{indent(DB_INIT_SCRIPT.chomp, 12)}
86
+ LogConfiguration:
87
+ LogDriver: awslogs
88
+ Options:
89
+ awslogs-group: !Ref LogGroup
90
+ awslogs-region: !Ref AWS::Region
91
+ awslogs-stream-prefix: dbinit
92
+ Environment:
93
+ - Name: DB_HOST
94
+ Value: !Ref OwningDatabaseEndpoint
95
+ - Name: TARGET_DB
96
+ Value: !Ref DbName
97
+ - Name: PROTECTED_DATABASES
98
+ Value: #{JSON.generate(settings.protected_databases.join(' '))}
99
+ #{indent(DB_INIT_SECRETS, 8)}
100
+ YAML
101
+ end
102
+
103
+ # Renders the preview's task definition, one container per entry of the container list.
104
+ #
105
+ # @param settings [Settings] the resolved preview settings
106
+ # @return [String] the task definition's resource block
107
+ def task_definition(settings)
108
+ <<~YAML.chomp
109
+ TaskDefinition:
110
+ Type: AWS::ECS::TaskDefinition
111
+ Properties:
112
+ Family: !Sub "#{settings.prefix}-${EnvName}"
113
+ RequiresCompatibilities: [FARGATE]
114
+ NetworkMode: awsvpc
115
+ Cpu: "#{settings.cpu}"
116
+ Memory: "#{settings.memory}"
117
+ RuntimePlatform:
118
+ CpuArchitecture: ARM64
119
+ OperatingSystemFamily: LINUX
120
+ ExecutionRoleArn: !GetAtt ExecutionRole.Arn
121
+ TaskRoleArn: !GetAtt TaskRole.Arn
122
+ ContainerDefinitions:
123
+ #{indent(settings.containers.map { |c| container_definition(settings, c) }.join("\n"), 6)}
124
+ YAML
125
+ end
126
+
127
+ # Renders one container of the task definition.
128
+ #
129
+ # @param settings [Settings] the resolved preview settings
130
+ # @param container [Containers::Entry] the container
131
+ # @return [String] the list entry
132
+ def container_definition(settings, container)
133
+ lines = [
134
+ "- Name: #{container.name}",
135
+ %( Image: !Sub "${#{container.logical}Repository.RepositoryUri}:${#{container.logical}ImageTag}"),
136
+ " Essential: true"
137
+ ]
138
+ lines += [" PortMappings:", " - ContainerPort: #{container.port}"] if container.routed?
139
+ lines += [" LogConfiguration:", indent(log_options(container), 4).chomp]
140
+ lines += [" Environment:", indent(environment_yaml(settings, container), 4).chomp]
141
+ lines.join("\n")
142
+ end
143
+
144
+ # Renders the log driver settings of one container.
145
+ #
146
+ # @param container [Containers::Entry] the container
147
+ # @return [String] the settings, ending in a newline
148
+ def log_options(container)
149
+ <<~YAML
150
+ LogDriver: awslogs
151
+ Options:
152
+ awslogs-group: !Ref LogGroup
153
+ awslogs-region: !Ref AWS::Region
154
+ awslogs-stream-prefix: #{container.name}
155
+ YAML
156
+ end
157
+
158
+ # Renders one container's environment as a list.
159
+ #
160
+ # @param settings [Settings] the resolved preview settings
161
+ # @param container [Containers::Entry] the container
162
+ # @return [String] the list entries
163
+ def environment_yaml(settings, container)
164
+ environment_pairs(settings, container).map do |name, value|
165
+ "- Name: #{name}\n Value: #{value}"
166
+ end.join("\n")
167
+ end
168
+
169
+ # Orders one container's environment as `[name, rendered value]` pairs; the container's
170
+ # `environment` wins over a generated entry of the same name.
171
+ #
172
+ # @param settings [Settings] the resolved preview settings
173
+ # @param container [Containers::Entry] the container
174
+ # @return [Array<Array(String, String)>] the pairs, each value already rendered as YAML
175
+ def environment_pairs(settings, container)
176
+ pairs = { "PORT" => scalar(container.port.to_s) }
177
+ pairs.merge!(host_environment(settings)) if container.host
178
+ pairs.merge!(database_environment) if container.host || container.database
179
+ container.secrets.each { |name| pairs[name] = "!Ref #{secret_id(container, name)}" }
180
+ container.environment.each { |name, value| pairs[name] = scalar(value) }
181
+ pairs.to_a
182
+ end
183
+
184
+ # Answers the environment only the Hecks host container gets.
185
+ #
186
+ # @param settings [Settings] the resolved preview settings
187
+ # @return [Hash{String => String}] name to rendered value
188
+ def host_environment(settings)
189
+ main = settings.main
190
+ pairs = {
191
+ "HECKS_DOMAIN" => scalar(main.fetch(:domain)), "HECKS_ERA" => scalar("1"),
192
+ "PORT" => scalar(settings.host.port.to_s), "BIND" => scalar("0.0.0.0"),
193
+ "HECKS_SERVE_MODE" => scalar("1"), "HECKS_WEB" => scalar(main.fetch(:web)),
194
+ "HECKS_WASM_PATH" => scalar(main.fetch(:wasm_path)), "HECKS_IR_PATH" => scalar(main.fetch(:ir_path)),
195
+ "SESSION_SECRET_ARN" => "!Ref SessionSecret", "HECKS_CHECKOUT_DOMAIN" => scalar(main.fetch(:domain))
196
+ }
197
+ pairs["HECKS_SCHEMA"] = scalar(main[:schema]) if main[:schema]
198
+ pairs["HECKS_SESSION_COOKIE"] = scalar(settings.session_cookie) if settings.session_cookie
199
+ pairs
200
+ end
201
+
202
+ # Answers the environment that points a container at this preview's database.
203
+ #
204
+ # @return [Hash{String => String}] name to rendered value
205
+ def database_environment
206
+ {
207
+ "DB_HOST" => "!Ref OwningDatabaseEndpoint", "DB_NAME" => "!Ref DbName",
208
+ "DB_SECRET_ARN" => "!Ref OwningDatabaseSecretArn"
209
+ }
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,247 @@
1
+ require "json"
2
+ require_relative "yaml_text"
3
+ require_relative "access_yaml"
4
+ require_relative "task_yaml"
5
+ require_relative "routing_yaml"
6
+
7
+ module Hecks
8
+ module Projections
9
+ module Deploy
10
+ module Preview
11
+ # Renders `preview.yaml`, the CloudFormation template of one
12
+ # per-branch preview stack.
13
+ #
14
+ # A separate template rather than conditions threaded through the main
15
+ # one, so a preview can never put the main stack's live resources in a
16
+ # change set. The two share a task shape and nothing else.
17
+ #
18
+ # ## What a preview owns
19
+ #
20
+ # Per branch: a cluster, service and task, a load balancer, one image
21
+ # repository per container, a session secret, any secrets a container
22
+ # declares, a log group, a CloudFront distribution (default hostname,
23
+ # nothing cached) and a database name on the shared instance, created
24
+ # by a one-shot task before the service starts.
25
+ #
26
+ # ## What it borrows and leaves out
27
+ #
28
+ # It attaches to the owner stack's VPC, subnets and compute security
29
+ # group and connects to the shared database instance, through the same
30
+ # `Owning*` parameters the main template takes for `database "Shared"`.
31
+ # It has no alarms, no notifications, no warmer and no custom domain:
32
+ # nobody should be paged for a preview.
33
+ module Template
34
+ CACHING_DISABLED = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad".freeze
35
+ ALL_VIEWER = "216adef6-5c7f-47e4-b989-5492eafa07d3".freeze
36
+
37
+ extend YamlText
38
+
39
+ module_function
40
+
41
+ # Renders the whole template.
42
+ #
43
+ # @param settings [Settings] the resolved preview settings
44
+ # @return [String] the CloudFormation YAML, ending in one newline
45
+ def render(settings)
46
+ [
47
+ header(settings), parameters(settings),
48
+ "Resources:\n#{indent(resources(settings).join("\n\n"), 2)}",
49
+ outputs
50
+ ].join("\n") << "\n"
51
+ end
52
+
53
+ # Renders the file's leading comment and template description.
54
+ #
55
+ # @param settings [Settings] the resolved preview settings
56
+ # @return [String] the header block
57
+ def header(settings)
58
+ <<~YAML.chomp
59
+ # GENERATED by bin/project_deploy - re-run it to refresh this file rather than
60
+ # hand-editing. Source: the `preview` block of the domain's deployed_to("AwsFargate").
61
+ #
62
+ # One stack per git branch, driven by preview.sh. A separate template from the main
63
+ # one on purpose, so a preview can never appear in a change set against the live
64
+ # stack. It shares the task shape and nothing else.
65
+ #
66
+ # Per branch: ECS cluster, service and task, an ALB, one ECR repository per
67
+ # container, secrets, a log group, a CloudFront distribution (default hostname,
68
+ # nothing cached) and its own database on the shared instance.
69
+ # Borrowed from #{settings.owner_stack}: VPC, subnets and the compute security group.
70
+ # Left out on purpose: alarms, notifications, warmers and custom domains.
71
+ AWSTemplateFormatVersion: '2010-09-09'
72
+ Description: >
73
+ #{settings.infra_name} preview - one feature branch, behind its own ALB and
74
+ CloudFront, on its own database.
75
+ YAML
76
+ end
77
+
78
+ # Renders the parameters section.
79
+ #
80
+ # @param settings [Settings] the resolved preview settings
81
+ # @return [String] the section, one image-tag parameter per container
82
+ def parameters(settings)
83
+ image_tags = settings.containers.map do |c|
84
+ <<~YAML.chomp
85
+ #{c.logical}ImageTag:
86
+ Type: String
87
+ Default: bootstrap
88
+ YAML
89
+ end
90
+ "Parameters:\n#{indent([base_parameters, *image_tags, desired_count_parameter].join("\n"), 2)}"
91
+ end
92
+
93
+ # Renders the parameters every preview takes: its name, database and the borrowed network.
94
+ #
95
+ # @return [String] the parameter blocks
96
+ def base_parameters
97
+ <<~YAML.chomp
98
+ EnvName:
99
+ Type: String
100
+ AllowedPattern: '^[a-z0-9]([a-z0-9-]{0,18}[a-z0-9])?$'
101
+ ConstraintDescription: lowercase letters, digits and hyphens, at most 20 characters, not ending in a hyphen (the ALB name limit is 32).
102
+ DbName:
103
+ Type: String
104
+ AllowedPattern: '^[a-z][a-z0-9_]{0,62}$'
105
+ Description: This preview's own database on the shared instance, created by the one-shot DbInit task before the service starts.
106
+ OwningVpcId:
107
+ Type: AWS::EC2::VPC::Id
108
+ OwningSubnetAId:
109
+ Type: AWS::EC2::Subnet::Id
110
+ OwningSubnetBId:
111
+ Type: AWS::EC2::Subnet::Id
112
+ OwningPublicSubnetAId:
113
+ Type: AWS::EC2::Subnet::Id
114
+ OwningPublicSubnetBId:
115
+ Type: AWS::EC2::Subnet::Id
116
+ OwningSecurityGroupId:
117
+ Type: AWS::EC2::SecurityGroup::Id
118
+ OwningDatabaseEndpoint:
119
+ Type: String
120
+ OwningDatabaseSecretArn:
121
+ Type: String
122
+ YAML
123
+ end
124
+
125
+ # Renders the parameter `preview.sh` uses to start the stack at zero tasks.
126
+ #
127
+ # @return [String] the parameter block
128
+ def desired_count_parameter
129
+ <<~YAML.chomp
130
+ # The repositories are created by this stack, so the first create has no image to
131
+ # point at. preview.sh creates the stack at 0, pushes, then scales up.
132
+ DesiredCount:
133
+ Type: Number
134
+ Default: 0
135
+ YAML
136
+ end
137
+
138
+ # Collects every resource block of the template.
139
+ #
140
+ # @param settings [Settings] the resolved preview settings
141
+ # @return [Array<String>] the resource blocks, in template order
142
+ def resources(settings)
143
+ [
144
+ AccessYaml.secrets(settings), RoutingYaml.networking(settings), repositories(settings),
145
+ cluster_and_logs(settings),
146
+ AccessYaml.roles(settings), TaskYaml.db_init_task(settings), TaskYaml.task_definition(settings),
147
+ RoutingYaml.load_balancing(settings),
148
+ RoutingYaml.service(settings), distribution
149
+ ].flatten
150
+ end
151
+
152
+ # Renders one image repository per container, emptied when the stack is deleted.
153
+ #
154
+ # @param settings [Settings] the resolved preview settings
155
+ # @return [Array<String>] the resource blocks
156
+ def repositories(settings)
157
+ settings.containers.map do |c|
158
+ <<~YAML.chomp
159
+ #{c.logical}Repository:
160
+ Type: AWS::ECR::Repository
161
+ Properties:
162
+ RepositoryName: !Sub "#{settings.prefix}-${EnvName}-#{c.name}"
163
+ EmptyOnDelete: true
164
+ YAML
165
+ end
166
+ end
167
+
168
+ # Renders the cluster and the log group.
169
+ #
170
+ # @param settings [Settings] the resolved preview settings
171
+ # @return [Array<String>] the two resource blocks
172
+ def cluster_and_logs(settings)
173
+ [
174
+ <<~YAML.chomp,
175
+ Cluster:
176
+ Type: AWS::ECS::Cluster
177
+ Properties:
178
+ ClusterName: !Sub "#{settings.prefix}-${EnvName}"
179
+ YAML
180
+ <<~YAML.chomp
181
+ LogGroup:
182
+ Type: AWS::Logs::LogGroup
183
+ Properties:
184
+ LogGroupName: !Sub "/ecs/#{settings.prefix}-${EnvName}"
185
+ RetentionInDays: #{settings.log_retention_days}
186
+ YAML
187
+ ]
188
+ end
189
+
190
+ # Renders the CDN distribution, which caches nothing.
191
+ #
192
+ # @return [String] the resource block
193
+ def distribution
194
+ <<~YAML.chomp
195
+ # One behavior, nothing cached: a preview is for looking at a change, so no cache means
196
+ # no stale page after a redeploy and no session response served to the wrong visitor.
197
+ # AllViewer forwards the viewer's Host so a same-origin check sees the real hostname.
198
+ PreviewDistribution:
199
+ Type: AWS::CloudFront::Distribution
200
+ Properties:
201
+ DistributionConfig:
202
+ Enabled: true
203
+ HttpVersion: http2
204
+ Comment: !Sub "preview ${EnvName}"
205
+ Origins:
206
+ - Id: PreviewAlbOrigin
207
+ DomainName: !GetAtt Alb.DNSName
208
+ CustomOriginConfig:
209
+ OriginProtocolPolicy: http-only
210
+ OriginSSLProtocols: [TLSv1.2]
211
+ DefaultCacheBehavior:
212
+ TargetOriginId: PreviewAlbOrigin
213
+ ViewerProtocolPolicy: redirect-to-https
214
+ AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE]
215
+ CachedMethods: [GET, HEAD]
216
+ Compress: true
217
+ CachePolicyId: #{CACHING_DISABLED}
218
+ OriginRequestPolicyId: #{ALL_VIEWER}
219
+ YAML
220
+ end
221
+
222
+ # Renders the outputs `preview.sh` reads back.
223
+ #
224
+ # @return [String] the section
225
+ def outputs
226
+ <<~YAML.chomp
227
+ Outputs:
228
+ PreviewUrl:
229
+ Value: !Sub "https://${PreviewDistribution.DomainName}"
230
+ ClusterName:
231
+ Value: !Ref Cluster
232
+ ServiceName:
233
+ Value: !GetAtt Service.Name
234
+ DbInitTaskDefinition:
235
+ Value: !Ref DbInitTaskDefinition
236
+ # The ARN only, so preview.sh can fetch the secret to mint signup and session tokens.
237
+ SessionSecretArn:
238
+ Value: !Ref SessionSecret
239
+ DatabaseName:
240
+ Value: !Ref DbName
241
+ YAML
242
+ end
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end