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
@@ -148,7 +148,7 @@ module Hecks
148
148
  # `stack_name` at all. They diverge on purpose for a domain whose
149
149
  # declared identity (`Hecks.bluebook`, hence `world.domain`, hence
150
150
  # `.world`'s own filename — `domain_name` above, needed just to find
151
- # that file) no longer matches its live AWS stack's own name: a
151
+ # that file) does not match its live AWS stack's own name: a
152
152
  # CloudFormation stack cannot be renamed in place, and this domain's
153
153
  # logical ids are already load-bearing on a real RDS instance other
154
154
  # domains borrow from in Shared mode (see `owner_stack_name` below) —
@@ -1039,10 +1039,10 @@ module Hecks
1039
1039
  # matching this stack's own name), a question `infra_name`
1040
1040
  # answers and a domain's own business identity does not.
1041
1041
  # Real, live, and confirmed the hard way: this was still
1042
- # wired to `declared_domain_name` when EmbryonautFoundersApp
1042
+ # wired to `declared_domain_name` when a client app
1043
1043
  # first deployed, and WebFunction immediately threw
1044
1044
  # `ResourceNotFoundException: Function not found:
1045
- # hecks-embryonautfoundersapp` on every read AND on the
1045
+ # hecks-<domain>` on every read AND on the
1046
1046
  # Google OAuth callback itself — a real signed-in user hit
1047
1047
  # this within minutes of the deploy that introduced it.
1048
1048
  DOMAIN_NAME: #{infra_name}
@@ -1246,7 +1246,7 @@ module Hecks
1246
1246
  template_yaml = template_yaml.sub(
1247
1247
  /^ - LambdaInvokePolicy:\n FunctionName: !Ref #{Regexp.escape(logical_id)}\n/, ""
1248
1248
  )
1249
- # Outputs — no `#{logical_id}Url` exists any more for `FunctionUrl` to
1249
+ # Outputs — no `#{logical_id}Url` exists for `FunctionUrl` to
1250
1250
  # `!GetAtt`; WebFunction's own URL becomes the stack's one and only
1251
1251
  # "FunctionUrl" (never "WebFunctionUrl" — that name is reserved for
1252
1252
  # the two-URL case, a domain that also has a #{logical_id} of its
@@ -1317,7 +1317,7 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1317
1317
  # aggregates with no snapshot table at all -- it only provisions the
1318
1318
  # ONE bluebook it's called with (era_resolver.rb's own `bluebook.
1319
1319
  # aggregates.each`), never the whole registry. Found live minting
1320
- # lifeadelics' own era: `Payments::Payment.Initiate` refused with
1320
+ # a client site's own era: `Payments::Payment.Initiate` refused with
1321
1321
  # "relation \\"payment_head_snapshot_1\\" does not exist" the first
1322
1322
  # time this domain's own checkout route ran for real. The automated
1323
1323
  # (non-Shared) recipe below now does this correctly for every OTHER
@@ -1490,7 +1490,7 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1490
1490
  # `Hecks::Bluebook::Behaviour::World#for_binding` nor
1491
1491
  # `PostgresEra.connect_for` ever reads `ENV["DATABASE_URL"]` or
1492
1492
  # `ENV["HECKS_SCHEMA"]` — both scripts call
1493
- # `registry.world(bluebook.name)&.for_binding(...)`, a pure hash lookup
1493
+ # `registry.binding_settings(...)`, a pure hash lookup
1494
1494
  # against whatever literal `database "..."` string #{domain}'s own
1495
1495
  # `.world` file declares. Without `db_env_blind:` below, this whole
1496
1496
  # recipe would stand up a real bastion, punch a live 5432 ingress rule
@@ -1722,7 +1722,7 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1722
1722
  # `shared` are collected together here rather than spelled as an
1723
1723
  # if/elsif/else, the same shape `Parameters:` above holds to: the
1724
1724
  # two are independent facts about a domain, not alternatives, and
1725
- # lifeadelics is both — an elsif shape would silently drop the
1725
+ # a client site can be both — an elsif shape would silently drop the
1726
1726
  # Owning* overrides whenever OAuth is also present. Caught live
1727
1727
  # the first time a Shared-mode domain with real Google OAuth
1728
1728
  # actually ran `make deploy`: `sam deploy` refused
@@ -2287,7 +2287,7 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
2287
2287
  reindent = ->(text) { text.each_line.map { |line| line.strip.empty? ? line : " #{line}" }.join }
2288
2288
 
2289
2289
  # Not pre-reindented (unlike the return value as a whole, below) — each
2290
- # is spliced back into the still-being-dedented RESOURCES heredoc via
2290
+ # is spliced back into the still-being-dedented `RESOURCES` heredoc via
2291
2291
  # `#{...}`, which the outer `reindent.call` already shifts by 2
2292
2292
  # spaces once; reindenting here too would double it.
2293
2293
  origin_access_control = use_oac ? <<~OAC : ""
@@ -0,0 +1,145 @@
1
+ require_relative "yaml_text"
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Deploy
6
+ module Preview
7
+ # The secrets and IAM roles of a preview stack.
8
+ #
9
+ # The task role reads the shared database secret and this preview's own
10
+ # secrets and nothing owned by the main stack. The execution role also reads
11
+ # the database secret, only so the one-shot task can receive the credentials
12
+ # as container secrets.
13
+ module AccessYaml
14
+ extend YamlText
15
+
16
+ module_function
17
+
18
+ # Renders the secrets a preview generates: its session secret and the ones
19
+ # containers declare.
20
+ #
21
+ # @param settings [Settings] the resolved preview settings
22
+ # @return [Array<String>] one YAML resource block per secret
23
+ def secrets(settings)
24
+ [session_secret, *settings.containers.flat_map { |c| container_secrets(c) }]
25
+ end
26
+
27
+ # Renders the per-branch session secret the host reads at start.
28
+ #
29
+ # @return [String] the secret's resource block
30
+ def session_secret
31
+ <<~YAML.chomp
32
+ # No explicit Name: Secrets Manager keeps a deleted secret through its recovery window,
33
+ # and a fixed name would make destroy followed by deploy on one branch fail.
34
+ SessionSecret:
35
+ Type: AWS::SecretsManager::Secret
36
+ Properties:
37
+ Description: !Sub "Session secret of the ${EnvName} preview."
38
+ GenerateSecretString:
39
+ SecretStringTemplate: '{}'
40
+ GenerateStringKey: session_secret
41
+ PasswordLength: 64
42
+ ExcludePunctuation: true
43
+ YAML
44
+ end
45
+
46
+ # Renders one generated secret per name a container declares.
47
+ #
48
+ # @param container [Containers::Entry] the container
49
+ # @return [Array<String>] one resource block per declared secret name
50
+ def container_secrets(container)
51
+ container.secrets.map do |name|
52
+ <<~YAML.chomp
53
+ #{secret_id(container, name)}:
54
+ Type: AWS::SecretsManager::Secret
55
+ Properties:
56
+ Description: !Sub "#{name} of the ${EnvName} preview's #{container.name} container."
57
+ GenerateSecretString:
58
+ SecretStringTemplate: '{}'
59
+ GenerateStringKey: secret
60
+ PasswordLength: 64
61
+ ExcludePunctuation: true
62
+ YAML
63
+ end
64
+ end
65
+
66
+ # Renders the task execution role and the task role.
67
+ #
68
+ # @param settings [Settings] the resolved preview settings
69
+ # @return [Array<String>] the two role resource blocks
70
+ def roles(settings)
71
+ assume = <<~YAML.chomp
72
+ AssumeRolePolicyDocument:
73
+ Version: '2012-10-17'
74
+ Statement:
75
+ - Effect: Allow
76
+ Principal: { Service: ecs-tasks.amazonaws.com }
77
+ Action: sts:AssumeRole
78
+ YAML
79
+ [execution_role(assume), task_role(assume, settings)]
80
+ end
81
+
82
+ # Renders the execution role, which pulls images and hands the one-shot task
83
+ # its credentials.
84
+ #
85
+ # @param assume [String] the shared trust policy block
86
+ # @return [String] the role's resource block
87
+ def execution_role(assume)
88
+ <<~YAML.chomp
89
+ # The execution role reads the database secret only so the one-shot task can be given
90
+ # its credentials as container secrets.
91
+ ExecutionRole:
92
+ Type: AWS::IAM::Role
93
+ Properties:
94
+ #{indent(assume, 4)}
95
+ ManagedPolicyArns:
96
+ - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
97
+ Policies:
98
+ - PolicyName: SharedDatabaseSecretRead
99
+ PolicyDocument:
100
+ Version: '2012-10-17'
101
+ Statement:
102
+ - Effect: Allow
103
+ Action: secretsmanager:GetSecretValue
104
+ Resource: !Ref OwningDatabaseSecretArn
105
+ YAML
106
+ end
107
+
108
+ # Renders the task role: read access to the shared database secret and this
109
+ # preview's own secrets.
110
+ #
111
+ # @param assume [String] the shared trust policy block
112
+ # @param settings [Settings] the resolved preview settings
113
+ # @return [String] the role's resource block
114
+ def task_role(assume, settings)
115
+ readable = ["SessionSecret", *settings.containers.flat_map { |c| c.secrets.map { |n| secret_id(c, n) } }]
116
+ <<~YAML.chomp
117
+ # Reads the shared database secret (it connects with it and creates its database) and
118
+ # this preview's own secrets; nothing owned by the main stack.
119
+ TaskRole:
120
+ Type: AWS::IAM::Role
121
+ Properties:
122
+ #{indent(assume, 4)}
123
+ Policies:
124
+ - PolicyName: SharedDatabaseSecretRead
125
+ PolicyDocument:
126
+ Version: '2012-10-17'
127
+ Statement:
128
+ - Effect: Allow
129
+ Action: secretsmanager:GetSecretValue
130
+ Resource: !Ref OwningDatabaseSecretArn
131
+ - PolicyName: PreviewSecretRead
132
+ PolicyDocument:
133
+ Version: '2012-10-17'
134
+ Statement:
135
+ - Effect: Allow
136
+ Action: secretsmanager:GetSecretValue
137
+ Resource:
138
+ #{indent(readable.map { |id| "- !Ref #{id}" }.join("\n"), 16)}
139
+ YAML
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,218 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Preview
5
+ # The containers one preview task runs, in one normalized shape.
6
+ #
7
+ # A preview task runs the same containers as the stack it previews.
8
+ # This module is the single place that decides which containers those
9
+ # are, so the rest of `Preview` never asks where the list came from.
10
+ #
11
+ # ## One adapter to the shared setting
12
+ #
13
+ # The main Fargate stack is growing a multi-container setting.
14
+ # `shared_setting` is the only method that reads it; every other
15
+ # method here works on the normalized entries described below. When
16
+ # that setting lands with a different shape, translate it inside
17
+ # `shared_setting` and nothing else changes.
18
+ #
19
+ # ## Entry keys
20
+ #
21
+ # Each entry is a Hash with Symbol keys:
22
+ #
23
+ # - `name` (required): lowercase letters, digits and hyphens; also the
24
+ # container name and the suffix of its ECR repository.
25
+ # - `port` (required): the port the container listens on. Unique
26
+ # across the task, since all containers share one network namespace.
27
+ # - `health_check_path`: target group health check path, default `"/"`.
28
+ # - `paths`: load balancer path patterns forwarded to this container.
29
+ # - `default`: `true` sends every unmatched request here. When no
30
+ # entry says so, the `host` entry is the default, else the first.
31
+ # - `host`: `true` marks the Hecks host container. It gets the domain,
32
+ # session and database environment, and the signup path.
33
+ # - `database`: `true` gives a non-host container the database
34
+ # environment as well. The host always has it.
35
+ # - `environment`: extra environment, name to value. `{{preview_url}}`
36
+ # inside a value becomes the preview's public https URL.
37
+ # - `secrets`: environment variable names. Each becomes a per-branch
38
+ # generated secret whose ARN is that variable's value.
39
+ # - `image`: the local docker image `preview.sh` pushes for this
40
+ # container, default `"<name>:latest"`.
41
+ module Containers
42
+ # One normalized container of the preview task.
43
+ Entry = Data.define(:name, :port, :health_check_path, :paths, :default, :host, :database,
44
+ :environment, :secrets, :image) do
45
+ # Answers the CloudFormation logical-id fragment for this container.
46
+ #
47
+ # @return [String] `name` in CamelCase, such as `"MySite"` for `"my-site"`
48
+ def logical = name.split("-").map(&:capitalize).join
49
+
50
+ # Answers whether the container is reachable through the load balancer.
51
+ #
52
+ # @return [Boolean] true when it is the default or owns any path
53
+ def routed? = default || !paths.empty?
54
+ end
55
+
56
+ NAME_PATTERN = /\A[a-z][a-z0-9-]{0,29}\z/
57
+ PATH_PATTERN = %r{\A/[A-Za-z0-9._~/*?%-]*\z}
58
+ IMAGE_PATTERN = %r{\A[A-Za-z0-9._/:@-]+\z}
59
+ ENV_NAME_PATTERN = /\A[A-Z_][A-Z0-9_]*\z/
60
+
61
+ module_function
62
+
63
+ # Answers the preview task's containers, normalized and validated.
64
+ #
65
+ # @param preview [Hash{Symbol => Object}] the preview settings; `:containers` wins
66
+ # @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings, read
67
+ # by `shared_setting`
68
+ # @param main [Hash{Symbol => Object}] the main stack's own facts; `:name`, `:port`,
69
+ # `:image` describe the single container used when neither list is given
70
+ # @param signup_path [String, nil] path forwarded to the host container, or nil
71
+ # @return [Array<Entry>] at least one entry, exactly one of them the default
72
+ # @raise [ArgumentError] if an entry is malformed, a name or port repeats, or two
73
+ # entries claim to be the default or the host
74
+ def resolve(preview:, deploy_settings:, main:, signup_path: nil)
75
+ raw = preview[:containers] || shared_setting(deploy_settings) || [single(main)]
76
+ entries = Array(raw).map { |item| normalize(item) }
77
+ check!(entries)
78
+ with_default(with_signup(entries, signup_path))
79
+ end
80
+
81
+ # Reads the main stack's own container list, when it declares one.
82
+ #
83
+ # This is the one hook to the multi-container setting shared with the
84
+ # main template; it answers entries in the shape documented above.
85
+ #
86
+ # @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings
87
+ # @return [Array<Hash>, nil] the list, or nil when the stack runs one container
88
+ def shared_setting(deploy_settings)
89
+ deploy_settings[:containers]
90
+ end
91
+
92
+ # Builds the single-container entry a one-container stack previews as.
93
+ #
94
+ # @param main [Hash{Symbol => Object}] the main stack's facts, see `resolve`
95
+ # @return [Hash{Symbol => Object}] the entry, always the host and the default
96
+ def single(main)
97
+ name = main.fetch(:name).to_s.downcase.gsub(/[^a-z0-9]+/, "-").sub(/\A[^a-z]+/, "")[0, 30].sub(/-+\z/, "")
98
+ { name: name, port: main.fetch(:port), host: true, default: true, image: main[:image] }
99
+ end
100
+
101
+ # Turns one raw entry into an `Entry`, filling every default.
102
+ #
103
+ # @param item [Hash{Symbol => Object}] the raw entry, string keys accepted
104
+ # @return [Entry] the normalized entry
105
+ # @raise [ArgumentError] if the entry is not a Hash or lacks a valid `name` or `port`
106
+ def normalize(item)
107
+ raise ArgumentError, "preview container #{item.inspect} must be a Hash" unless item.is_a?(Hash)
108
+
109
+ item = item.transform_keys(&:to_sym)
110
+ name = matching(item[:name], NAME_PATTERN, "preview container name")
111
+ Entry.new(
112
+ name: name, port: port_of(item, name), health_check_path: path_of(item[:health_check_path] || "/"),
113
+ paths: Array(item[:paths]).map { |path| path_of(path) }, default: item[:default] == true,
114
+ host: item[:host] == true, database: item[:database] == true,
115
+ environment: environment_of(item[:environment]), secrets: Array(item[:secrets]).map(&:to_s),
116
+ image: matching(item[:image] || "#{name}:latest", IMAGE_PATTERN, "preview container #{name} image")
117
+ )
118
+ end
119
+
120
+ # Answers a value as a String, refusing one that does not match a pattern.
121
+ #
122
+ # @param value [Object] the raw value
123
+ # @param pattern [Regexp] what the string must match
124
+ # @param label [String] what the value is, for the message
125
+ # @return [String] the value as a String
126
+ # @raise [ArgumentError] if the string does not match
127
+ def matching(value, pattern, label)
128
+ text = value.to_s
129
+ raise ArgumentError, "#{label} #{text.inspect} must match #{pattern.inspect}" unless text.match?(pattern)
130
+
131
+ text
132
+ end
133
+
134
+ # Answers one path pattern, refusing one the template could not quote safely.
135
+ #
136
+ # @param path [Object] the raw path
137
+ # @return [String] the path
138
+ # @raise [ArgumentError] if it does not start with `/` or holds an unexpected character
139
+ def path_of(path) = matching(path, PATH_PATTERN, "preview container path")
140
+
141
+ # Reads and range-checks one entry's port.
142
+ #
143
+ # @param item [Hash{Symbol => Object}] the raw entry
144
+ # @param name [String] the entry's name, for the message
145
+ # @return [Integer] the port
146
+ # @raise [ArgumentError] if the port is missing or outside 1..65535
147
+ def port_of(item, name)
148
+ port = Integer(item[:port], exception: false)
149
+ raise ArgumentError, "preview container #{name} needs a port between 1 and 65535" unless port&.between?(1, 65_535)
150
+
151
+ port
152
+ end
153
+
154
+ # Stringifies an entry's environment, keeping declaration order.
155
+ #
156
+ # @param environment [Hash{Symbol, String => Object}, nil] the raw environment
157
+ # @return [Hash{String => String}] name to value
158
+ def environment_of(environment)
159
+ (environment || {}).to_h { |name, value| [name.to_s, value.to_s] }
160
+ end
161
+
162
+ # Refuses a list that cannot be one task.
163
+ #
164
+ # @param entries [Array<Entry>] the normalized entries
165
+ # @return [void]
166
+ # @raise [ArgumentError] on an empty list, a repeated name or port, more than one
167
+ # default or host, or a secret name that is not an environment variable name
168
+ def check!(entries)
169
+ raise ArgumentError, "preview containers must not be empty" if entries.empty?
170
+
171
+ %i[name port].each do |field|
172
+ dup = entries.map(&field).tally.find { |_value, count| count > 1 }
173
+ raise ArgumentError, "preview containers repeat the #{field} #{dup.first.inspect}" if dup
174
+ end
175
+ check_roles!(entries)
176
+ end
177
+
178
+ # Refuses a second default or host, and a secret that is not an environment variable name.
179
+ #
180
+ # @param entries [Array<Entry>] the normalized entries
181
+ # @return [void]
182
+ # @raise [ArgumentError] on a second default or host, or a malformed secret name
183
+ def check_roles!(entries)
184
+ raise ArgumentError, "at most one preview container may be the default" if entries.count(&:default) > 1
185
+ raise ArgumentError, "at most one preview container may be the host" if entries.count(&:host) > 1
186
+
187
+ bad = entries.flat_map(&:secrets).find { |secret| !secret.match?(ENV_NAME_PATTERN) }
188
+ raise ArgumentError, "preview secret name #{bad.inspect} must be an environment variable name" if bad
189
+ end
190
+
191
+ # Adds the signup path to the host container's own paths.
192
+ #
193
+ # @param entries [Array<Entry>] the normalized entries
194
+ # @param signup_path [String, nil] the path, or nil for none
195
+ # @return [Array<Entry>] the entries, the host's paths extended when it needs a rule
196
+ def with_signup(entries, signup_path)
197
+ return entries unless signup_path
198
+
199
+ entries.map do |entry|
200
+ entry.host && !entry.default ? entry.with(paths: (entry.paths + [signup_path]).uniq) : entry
201
+ end
202
+ end
203
+
204
+ # Marks the default container when no entry does.
205
+ #
206
+ # @param entries [Array<Entry>] the normalized entries
207
+ # @return [Array<Entry>] the entries with exactly one default
208
+ def with_default(entries)
209
+ return entries if entries.any?(&:default)
210
+
211
+ chosen = entries.find(&:host) || entries.first
212
+ entries.map { |entry| entry.equal?(chosen) ? entry.with(default: true) : entry }
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end