hecks 3.0.5 → 3.1.1

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 (170) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +24 -12
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +32 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  12. data/lib/hecks/cli/doc_coverage.rb +1 -1
  13. data/lib/hecks/cli/domain_stub.rb +128 -0
  14. data/lib/hecks/cli/domain_writer.rb +39 -0
  15. data/lib/hecks/cli/interview_agent.rb +59 -0
  16. data/lib/hecks/cli/interview_draft.rb +246 -0
  17. data/lib/hecks/cli/interview_run.rb +87 -0
  18. data/lib/hecks/cli/interview_session.rb +299 -0
  19. data/lib/hecks/cli/model_check.rb +1 -1
  20. data/lib/hecks/cli/project_cli.rb +51 -29
  21. data/lib/hecks/cli/reference.rb +2 -1
  22. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  23. data/lib/hecks/cli/seed_semantics_corpus.rb +3 -3
  24. data/lib/hecks/cli/spec_example.rb +1 -1
  25. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  26. data/lib/hecks/corpus.rb +12 -8
  27. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  28. data/lib/hecks/deploy/oidc.json +5 -0
  29. data/lib/hecks/doc/reference.rb +4 -3
  30. data/lib/hecks/doors/cli_door.rb +13 -13
  31. data/lib/hecks/doors/cli_runner.rb +53 -33
  32. data/lib/hecks/doors/launcher_options.rb +5 -5
  33. data/lib/hecks/doors/mcp_door.rb +40 -18
  34. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  35. data/lib/hecks/doors/usage_cache.rb +123 -0
  36. data/lib/hecks/doors.rb +1 -1
  37. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  38. data/lib/hecks/forms/banking_presentation.rb +1 -1
  39. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  40. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  41. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  42. data/lib/hecks/framework.rb +1 -1
  43. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  44. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  45. data/lib/hecks/fuzzing/differential.rb +4 -2
  46. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  47. data/lib/hecks/fuzzing/form_census.rb +2 -2
  48. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  49. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  50. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  51. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  52. data/lib/hecks/gate/stages.yml +5 -5
  53. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  54. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  55. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  56. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  57. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  58. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  59. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  60. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  61. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  62. data/lib/hecks/hecks/codebase.bluebook +4 -3
  63. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  64. data/lib/hecks/hecks/custodian.bluebook +81 -3
  65. data/lib/hecks/hecks/hecks.bluebook +18 -0
  66. data/lib/hecks/hecks/hecks.hecksagon +16 -4
  67. data/lib/hecks/hecks/hecks.world +7 -6
  68. data/lib/hecks/hecks/site.behaviors +17 -0
  69. data/lib/hecks/hecks/site.world +10 -0
  70. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  71. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  72. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  73. data/lib/hecks/projections/deploy/box/settings.rb +324 -0
  74. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +268 -0
  75. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +68 -0
  76. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  77. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +181 -0
  78. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  79. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  80. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  81. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  82. data/lib/hecks/projections/deploy/box.rb +553 -0
  83. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  84. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  85. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  86. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  87. data/lib/hecks/projections/diagrams.rb +2 -2
  88. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  89. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  90. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  91. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  92. data/lib/hecks/projections/site/edge.rb +136 -0
  93. data/lib/hecks/projections/site/regions.rb +44 -0
  94. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  95. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  96. data/lib/hecks/projections/site/table/checks.rb +124 -0
  97. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  98. data/lib/hecks/projections/site/table.rb +98 -0
  99. data/lib/hecks/projections.rb +2 -0
  100. data/lib/hecks/projector/cli_projector.rb +153 -111
  101. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  102. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  103. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  104. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  105. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  106. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  107. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  108. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  109. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  110. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  111. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  112. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  113. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  114. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  115. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  116. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  117. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  118. data/lib/hecks/runtime/loader.rb +31 -8
  119. data/lib/hecks/runtime/registry/verification.rb +15 -29
  120. data/lib/hecks/runtime/registry.rb +14 -20
  121. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  122. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  123. data/lib/hecks/rust_build/domain_name.rb +26 -0
  124. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  125. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  126. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  127. data/lib/hecks/rust_build/project_rust.rb +57 -24
  128. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  129. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  130. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  131. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  132. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  133. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  134. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  135. data/lib/hecks/storehouse.rb +37 -7
  136. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  137. data/lib/hecks/three_zero/forms.yml +88 -88
  138. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  139. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  140. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  141. data/lib/hecks/tools/ci_gates.rb +1 -1
  142. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  143. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  144. data/lib/hecks/tools/sequence_script.rb +2 -2
  145. data/lib/hecks/tools/site_routes.rb +152 -0
  146. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  147. data/lib/hecks/tools.rb +1 -0
  148. data/lib/hecks/version.rb +1 -1
  149. data/lib/hecks.rb +1 -0
  150. data/rust/build/src/cargo_sync.rs +1 -1
  151. data/rust/build/src/pipeline.rs +23 -16
  152. data/rust/build/src/resolve.rs +39 -6
  153. data/rust/codegen/src/json_codec.rs +3 -0
  154. data/rust/codegen/src/naming.rs +4 -0
  155. data/rust/codegen/src/types.rs +45 -0
  156. data/rust/host/HECKS_RELEASE +1 -1
  157. data/rust/host/src/api.rs +1 -1
  158. data/rust/host/src/dispatch.rs +87 -7
  159. data/rust/host/src/needs.rs +111 -0
  160. data/rust/parser/src/keywords.rs +7 -6
  161. data/rust/parser/src/main.rs +4 -3
  162. data/rust/parser/src/parse/chapter.rs +1 -1
  163. data/rust/parser/src/parse/hecksagon.rs +25 -10
  164. data/rust/project/mutations.rb +4 -30
  165. data/rust/project/naming.rb +8 -28
  166. data/rust/project/write_if_changed.rb +4 -80
  167. data/rust/project_rust_pipeline.rb +16 -12
  168. data/rust/src/kernel/cli.rs +2 -0
  169. data/rust/src/kernel/needs.rs +74 -1
  170. metadata +51 -2
@@ -0,0 +1,553 @@
1
+ require "json"
2
+ require_relative "../../projector"
3
+ require_relative "box/settings"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Deploy
8
+ # The AWS single-box deploy target for `deployed_to("AwsBox")`: one EC2 instance that runs the
9
+ # domain's containers with Docker Compose behind Caddy, reading a plain RDS Postgres instance.
10
+ # It renders two CloudFormation stacks (the database, the box), the proxy's `Caddyfile`, the
11
+ # `services.json` compose is rendered from, and the scripts that roll the box.
12
+ module Box
13
+ extend Projector::Target
14
+
15
+ projects_as :aws_box, needs_world: true, emits: :files
16
+
17
+ TEMPLATE_DIR = File.join(__dir__, "box", "templates").freeze
18
+
19
+ # Region suffixes of an Elastic address's public DNS name; us-east-1 is the only region with
20
+ # the legacy `compute-1` form.
21
+ LEGACY_COMPUTE_REGION = "us-east-1".freeze
22
+
23
+ # The end of every Caddyfile: sites a rehearsal mounts under `caddy-extra`, such as a
24
+ # loopback listener that adds the origin secret so a smoke test runs without the CDN. A
25
+ # glob that matches nothing is not an error, so production, which mounts none, is unchanged.
26
+ CADDY_EXTRA = "# Rehearsal-only sites are mounted here; nothing matches in production. Restart the proxy\n" \
27
+ "# after adding one: the admin API is off, so a reload cannot reach it.\n" \
28
+ "import /etc/caddy/extra/*\n".freeze
29
+
30
+ module_function
31
+
32
+ # Generates `rds.yaml`, `box.yaml`, `Caddyfile`, `services.json`, `render-compose.sh`,
33
+ # `fetch-secrets.sh`, `deploy-box.sh` and a `Makefile`, and for a world that declares a
34
+ # `migration`, `restore-to-rds.sh`, `verify-copy.sh` and `MIGRATION.md`.
35
+ #
36
+ # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
37
+ # @param options [Hash] generation options; same shape as `Fargate.call`'s
38
+ # @return [Hash{String => String}] the generated file contents, keyed by filename
39
+ # @raise [ArgumentError] if the domain's deploy settings are invalid or conflict
40
+ def call(bluebook:, options: {})
41
+ world, domain, world_file = options.values_at(:world, :domain_dir, :world_file)
42
+ tenant = options[:tenant] || {}
43
+ settings = tenant_settings(world.for_verb("deployed_to"), tenant, File.basename(domain))
44
+ infra_name = (settings[:stack_name] || File.basename(domain)).to_s.tr("_", "-").downcase
45
+
46
+ target = declare(world.domain, settings, world_file)
47
+ plan = Settings.resolve(deploy_settings: settings, target: target, infra_name: infra_name)
48
+ render_all(plan, target.state[:region].value)
49
+ end
50
+
51
+ # Applies the `--tenant` suffix to the stack name, as `Fargate.call` does.
52
+ #
53
+ # @param settings [Hash{Symbol => Object}] the world's `deployed_to` settings
54
+ # @param tenant [Hash] the `--tenant` and `--schema` options
55
+ # @param domain_name [String] the domain directory's name
56
+ # @return [Hash{Symbol => Object}] the settings with the tenant's stack name
57
+ def tenant_settings(settings, tenant, domain_name)
58
+ return settings unless tenant[:tenant]
59
+
60
+ settings.merge(stack_name: "#{settings[:stack_name] || domain_name}-#{tenant[:tenant]}")
61
+ end
62
+
63
+ # Validates the target through the Deploy bluebook's own `BoxTarget.Declare`.
64
+ #
65
+ # @param domain [String] the world's domain name
66
+ # @param settings [Hash{Symbol => Object}] the world's `deployed_to` settings
67
+ # @param world_file [String] the `.world` file, named in a refusal
68
+ # @return [Object] the declared target aggregate
69
+ # @raise [ArgumentError] when the bluebook refuses a value
70
+ def declare(domain, settings, world_file)
71
+ dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
72
+ dispatcher.dispatch(
73
+ "Deploy::BoxTarget.Declare",
74
+ with: {
75
+ domain: { value: domain }, region: { value: settings[:region] },
76
+ instance_type: { value: settings.fetch(:instance_type, "t4g.medium") },
77
+ volume_gb: { value: settings.fetch(:volume_gb, 30) },
78
+ database_class: { value: settings.fetch(:database_class, "db.t4g.small") },
79
+ storage_gb: { value: settings.fetch(:storage_gb, 20) }
80
+ }
81
+ ).instance
82
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
83
+ raise ArgumentError, "#{world_file}'s deployed_to(\"AwsBox\") is invalid: #{e.message}"
84
+ end
85
+
86
+ # @param plan [Settings::Plan] the resolved settings
87
+ # @param region [String] the validated region
88
+ # @return [Hash{String => String}] every generated file
89
+ def render_all(plan, region)
90
+ {
91
+ "rds.yaml" => rds_yaml(plan), "box.yaml" => box_yaml(plan, region),
92
+ "Caddyfile" => caddyfile(plan), "services.json" => services_json(plan),
93
+ "render-compose.sh" => render_compose_sh(plan, region),
94
+ "fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
95
+ "deploy-box.sh" => deploy_box_sh(plan, region),
96
+ "Makefile" => makefile(plan)
97
+ }.merge(migration_files(plan))
98
+ end
99
+
100
+ # The tooling that moves a project's data from its old database into the RDS instance, for
101
+ # a world that declares a `migration`.
102
+ #
103
+ # @param plan [Settings::Plan] the resolved settings
104
+ # @return [Hash{String => String}] `restore-to-rds.sh`, `verify-copy.sh` and `MIGRATION.md`,
105
+ # or nothing
106
+ def migration_files(plan)
107
+ migration = plan.migration
108
+ return {} unless migration
109
+
110
+ values = { "STACK" => plan.infra_name, "SCHEMAS" => migration.schemas.join(" "),
111
+ "SCHEMAS_CSV" => migration.schemas.join(","), "DATABASE" => migration.database,
112
+ "SOURCE_DATABASE" => migration.source_database }
113
+ {
114
+ "restore-to-rds.sh" => template("restore-to-rds.sh.tmpl", values),
115
+ "verify-copy.sh" => template("verify-copy.sh.tmpl", values),
116
+ "MIGRATION.md" => migration_md(plan)
117
+ }
118
+ end
119
+
120
+ # @param plan [Settings::Plan] the resolved settings
121
+ # @return [String] the runbook for moving onto the box, in the order the steps are run
122
+ def migration_md(plan)
123
+ migration = plan.migration
124
+ deploy = plan.task_definition ? "make deploy TASKDEF=#{plan.task_definition}:<revision>" : "make deploy"
125
+ <<~MD
126
+ # Moving #{plan.infra_name} onto the box and RDS
127
+
128
+ Generated from the world's `migration` setting. Nothing here has been run for you.
129
+
130
+ Schemas to copy: #{migration.schemas.map { |name| "`#{name}`" }.join(', ')}, in database
131
+ `#{migration.source_database}` on the old server and `#{migration.database}` on RDS.
132
+
133
+ ## Before cutover
134
+
135
+ 1. Create the stacks: `make stacks VPC=... PRIVATE_SUBNETS=... PUBLIC_SUBNET=...`. To rehearse first,
136
+ deploy `rds.yaml` and `box.yaml` under other stack names with `Rehearsal=true`: the database is then
137
+ deleted with its stack and the box gets no stable public address. Delete the rehearsal stacks after.
138
+ 2. Copy the data. The bastion is any instance that can reach both databases.
139
+
140
+ ```
141
+ RDS_HOST=$(aws cloudformation describe-stacks --stack-name #{plan.rds_stack} --query "Stacks[0].Outputs[?OutputKey=='DbEndpoint'].OutputValue" --output text)
142
+ RDS_SECRET=$(aws cloudformation describe-stacks --stack-name #{plan.rds_stack} --query "Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue" --output text)
143
+ bash restore-to-rds.sh <bastion-instance-id> <old-host> <old-secret-arn> "$RDS_HOST" "$RDS_SECRET"
144
+ ```
145
+
146
+ It copies each schema, refreshes the materialized views a plain `pg_restore` cannot, then runs
147
+ `verify-copy.sh`, which compares structure and the exact row count of every table and prints `OK`
148
+ only if both match. Re-run with `FORCE=1` to reload.
149
+ 3. Deploy the app onto the box: `#{deploy}`. The deploy ends with health checks on the box.
150
+ 4. Run the project's own smoke test against the box before any traffic moves.
151
+
152
+ ## Cutover
153
+
154
+ 1. Stop writes on the old stack, then run `restore-to-rds.sh` again with `FORCE=1` for a final copy and
155
+ wait for `OK`.
156
+ 2. Point the CDN's origin at the box stack's `AppOriginDomain` output.
157
+ 3. Keep the old database untouched for several days, and take a final snapshot before deleting it.
158
+
159
+ ## Rollback
160
+
161
+ Until the first write lands on RDS, point the origin back. After that, writes taken by both databases
162
+ cannot be merged: choose one side and copy it over the other (swap the hosts and secrets, and set
163
+ `SRC_DB` and `DST_DB`, with `FORCE=1`). Do not change the domain's era in the same window, so a rollback
164
+ only has to move data.
165
+ MD
166
+ end
167
+
168
+ # @param plan [Settings::Plan] the resolved settings
169
+ # @param region [String] the validated region
170
+ # @return [String] the script that renders the Compose file, from `services.json` or from
171
+ # an ECS task definition when the world names one
172
+ def render_compose_sh(plan, region)
173
+ if plan.task_definition
174
+ template("render-compose-taskdef.sh.tmpl", "STACK" => plan.infra_name, "FAMILY" => plan.task_definition,
175
+ "PROXY_IMAGE" => plan.proxy_image)
176
+ else
177
+ template("render-compose.sh.tmpl", "STACK" => plan.infra_name, "REGION" => region,
178
+ "DB_NAME" => plan.database_name, "PROXY_IMAGE" => plan.proxy_image)
179
+ end
180
+ end
181
+
182
+ # @param plan [Settings::Plan] the resolved settings
183
+ # @return [String] the database stack
184
+ def rds_yaml(plan)
185
+ template("rds.yaml.tmpl", "STACK" => plan.infra_name, "DB_CLASS" => plan.database_class,
186
+ "STORAGE_GB" => plan.storage_gb.to_s, "DB_NAME" => plan.database_name,
187
+ "ENGINE_VERSION" => plan.engine_version, "BACKUP_DAYS" => plan.backup_days.to_s)
188
+ end
189
+
190
+ # @param plan [Settings::Plan] the resolved settings
191
+ # @param region [String] the validated region
192
+ # @return [String] the box stack
193
+ def box_yaml(plan, region)
194
+ template("box.yaml.tmpl",
195
+ "STACK" => plan.infra_name, "INSTANCE_TYPE" => plan.instance_type,
196
+ "VOLUME_GB" => plan.volume_gb.to_s, "SNAPSHOTS_KEEP" => plan.snapshots_keep.to_s,
197
+ "AMI_PARAMETER" => ami_parameter(plan.instance_type),
198
+ "COMPUTE_DOMAIN" => compute_domain(region),
199
+ "SECRET_RESOURCES" => secret_resources(plan), "TUNNEL_EGRESS" => tunnel_egress(plan),
200
+ "SWAP_COMMANDS" => swap_commands(plan), "ECR_REPOSITORIES" => ecr_repositories(plan),
201
+ "ECR_OUTPUTS" => ecr_outputs(plan), "S3_POLICY" => s3_policy(plan))
202
+ end
203
+
204
+ # The role's S3 policy: every declared bucket readable, and writable only on a production
205
+ # box, so a rehearsal never changes the real objects.
206
+ #
207
+ # @param plan [Settings::Plan] the resolved settings
208
+ # @return [String] a policy list item, indented into the role's `Policies`, or nothing
209
+ def s3_policy(plan)
210
+ return "" if plan.s3_buckets.empty?
211
+
212
+ arn = ->(path) { "arn:${AWS::Partition}:s3:::#{path}" }
213
+ reads = plan.s3_buckets.flat_map { |b| [arn.call(b.name), arn.call("#{b.name}/*")] }
214
+ writes = plan.s3_buckets.select(&:write).map { |b| arn.call("#{b.name}/*") }
215
+ rows = s3_read_rows(reads)
216
+ rows += s3_write_rows(writes) unless writes.empty?
217
+ "#{rows.map { |row| " #{row}" }.join("\n")}\n"
218
+ end
219
+
220
+ # @param resources [Array<String>] the bucket and object ARNs a box may read
221
+ # @return [Array<String>] the policy's head and its read statement, relative to `Policies`
222
+ def s3_read_rows(resources)
223
+ ["- PolicyName: s3-access", " PolicyDocument:", " Version: \"2012-10-17\"", " Statement:",
224
+ " - Effect: Allow", " Action: [s3:GetObject, s3:ListBucket]", " Resource:"] +
225
+ resources.map { |r| " - !Sub \"#{r}\"" }
226
+ end
227
+
228
+ # @param resources [Array<String>] the object ARNs a production box may write
229
+ # @return [Array<String>] the production-only write statement, relative to `Policies`
230
+ def s3_write_rows(resources)
231
+ [" - !If", " - IsProduction", " - Effect: Allow",
232
+ " Action: [s3:PutObject, s3:DeleteObject]", " Resource:"] +
233
+ resources.map { |r| " - !Sub \"#{r}\"" } + [" - !Ref AWS::NoValue"]
234
+ end
235
+
236
+ # Graviton families end their generation digit with `g` (`t4g`, `m7gd`, `c6gn`).
237
+ #
238
+ # @param instance_type [String] an EC2 instance type
239
+ # @return [String] the SSM parameter of the matching Amazon Linux image
240
+ def ami_parameter(instance_type)
241
+ arch = instance_type.split(".").first.match?(/\d+g[a-z]*\z/) ? "arm64" : "x86_64"
242
+ "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-#{arch}"
243
+ end
244
+
245
+ # @param region [String] the AWS region
246
+ # @return [String] the DNS suffix an Elastic address's public name ends with
247
+ def compute_domain(region)
248
+ region == LEGACY_COMPUTE_REGION ? "compute-1.amazonaws.com" : "#{region}.compute.amazonaws.com"
249
+ end
250
+
251
+ # One Secrets Manager statement resource per prefix. A name without a trailing `*` gets `-*`
252
+ # for the random suffix AWS appends to every secret ARN.
253
+ #
254
+ # @param plan [Settings::Plan] the resolved settings
255
+ # @return [String] YAML list items, one per prefix, ending in a newline
256
+ def secret_resources(plan)
257
+ named = [plan.origin_secret, plan.tunnel_service&.token_secret].compact
258
+ arns = (plan.secret_prefixes + named).map { |name| name.end_with?("*") ? name : "#{name}-*" }
259
+ arns.uniq.map do |pattern|
260
+ " - !Sub \"arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{pattern}\"\n"
261
+ end.join.chomp
262
+ end
263
+
264
+ # @param plan [Settings::Plan] the resolved settings
265
+ # @return [String] the outbound rules a Cloudflare Tunnel sidecar needs, or nothing
266
+ def tunnel_egress(plan)
267
+ return "" unless plan.tunnel
268
+
269
+ <<~YAML
270
+ #{' ' * 8}# cloudflared dials out to the tunnel edge on 7844. Outbound only: nothing opens inbound.
271
+ #{' ' * 8}- IpProtocol: tcp
272
+ #{' ' * 8} FromPort: 7844
273
+ #{' ' * 8} ToPort: 7844
274
+ #{' ' * 8} CidrIp: 0.0.0.0/0
275
+ #{' ' * 8} Description: tunnel to the edge
276
+ #{' ' * 8}- IpProtocol: udp
277
+ #{' ' * 8} FromPort: 7844
278
+ #{' ' * 8} ToPort: 7844
279
+ #{' ' * 8} CidrIp: 0.0.0.0/0
280
+ #{' ' * 8} Description: tunnel to the edge (QUIC)
281
+ YAML
282
+ end
283
+
284
+ # @param plan [Settings::Plan] the resolved settings
285
+ # @return [String] the user-data lines that add swap, or nothing when `swap_gb` is 0
286
+ def swap_commands(plan)
287
+ return "" if plan.swap_gb.zero?
288
+
289
+ pad = " " * 10
290
+ <<~SH.lines.map { |line| "#{pad}#{line}" }.join
291
+ # swap so a memory spike at boot cannot take the box down
292
+ fallocate -l #{plan.swap_gb}G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
293
+ echo '/swapfile none swap sw 0 0' >> /etc/fstab
294
+ SH
295
+ end
296
+
297
+ # One ECR repository per container, keeping the newest 30 images. A task definition names
298
+ # images that already have repositories, so none are made.
299
+ #
300
+ # @param plan [Settings::Plan] the resolved settings
301
+ # @return [String] CloudFormation resources, each preceded by a blank line
302
+ def ecr_repositories(plan)
303
+ return "" if plan.task_definition
304
+
305
+ plan.containers.map do |container|
306
+ id = "#{logical(container.name)}Repository"
307
+ <<~YAML.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
308
+
309
+ #{id}:
310
+ Type: AWS::ECR::Repository
311
+ Properties:
312
+ RepositoryName: #{container.repository}
313
+ ImageScanningConfiguration:
314
+ ScanOnPush: true
315
+ LifecyclePolicy:
316
+ LifecyclePolicyText: '{"rules":[{"rulePriority":1,"description":"keep the newest 30","selection":{"tagStatus":"any","countType":"imageCountMoreThan","countNumber":30},"action":{"type":"expire"}}]}'
317
+ YAML
318
+ end.join.chomp
319
+ end
320
+
321
+ # @param plan [Settings::Plan] the resolved settings
322
+ # @return [String] one repository URI output per container
323
+ def ecr_outputs(plan)
324
+ return "" if plan.task_definition
325
+
326
+ plan.containers.map do |container|
327
+ " #{logical(container.name)}RepositoryUri:\n Value: !GetAtt #{logical(container.name)}Repository.RepositoryUri\n"
328
+ end.join.chomp
329
+ end
330
+
331
+ # @param name [String] a container name such as `web-app`
332
+ # @return [String] a CloudFormation logical id fragment, such as `WebApp`
333
+ def logical(name)
334
+ name.split("-").map(&:capitalize).join
335
+ end
336
+
337
+ # The proxy's configuration: an optional origin-secret guard, one block per route and a
338
+ # default for everything else.
339
+ #
340
+ # @param plan [Settings::Plan] the resolved settings
341
+ # @return [String] the Caddyfile
342
+ def caddyfile(plan)
343
+ guarded = !plan.origin_header.nil?
344
+ site =
345
+ if guarded
346
+ "\t@origin header #{plan.origin_header} {$ORIGIN_SECRET}\n\n\thandle @origin {\n" \
347
+ "#{indent(indent(route_blocks(plan)))}\t}\n\n\thandle {\n\t\trespond \"Forbidden\" 403\n\t}\n"
348
+ else
349
+ indent(route_blocks(plan))
350
+ end
351
+ "#{caddy_header(plan)}#{caddy_global(guarded)}\n:80 {\n#{site}}\n\n#{CADDY_EXTRA}"
352
+ end
353
+
354
+ # @param plan [Settings::Plan] the resolved settings
355
+ # @return [String] the `handle` blocks for each route, then the default
356
+ def route_blocks(plan)
357
+ routes = plan.routes.each_with_index.map do |route, i|
358
+ port = plan.containers.find { |c| c.name == route.container }.port
359
+ "@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n\treverse_proxy 127.0.0.1:#{port}\n}\n\n"
360
+ end
361
+ "#{routes.join}handle {\n\treverse_proxy 127.0.0.1:#{plan.default.port}\n}\n"
362
+ end
363
+
364
+ # @param plan [Settings::Plan] the resolved settings
365
+ # @return [String] the comment that opens the Caddyfile
366
+ def caddy_header(plan)
367
+ origin =
368
+ if plan.origin_header
369
+ "Only requests that carry #{plan.origin_header} with the origin secret are proxied;\n" \
370
+ "# everything else gets a flat 403.\n"
371
+ else
372
+ "Every request is proxied.\n"
373
+ end
374
+ "# #{plan.infra_name}'s proxy. #{origin}"
375
+ end
376
+
377
+ # With a guard, the proxy trusts every source: a request that reaches a `handle` block has
378
+ # already proved it came through the CDN by presenting the secret, and everything else is
379
+ # refused. Caddy then keeps the client address the CDN put in X-Forwarded-For.
380
+ #
381
+ # @param guarded [Boolean] whether an origin secret guards the site
382
+ # @return [String] the global options block
383
+ def caddy_global(guarded)
384
+ options = "\t# No certificate for the :80 site. disable_redirects, not off, so a listener a rehearsal\n" \
385
+ "\t# mounts under caddy-extra can still ask for `tls internal`.\n" \
386
+ "\tauto_https disable_redirects\n\tadmin off\n"
387
+ return "{\n#{options}}\n" unless guarded
388
+
389
+ "{\n#{options}\tservers {\n\t\ttrusted_proxies static 0.0.0.0/0 ::/0\n\t}\n}\n"
390
+ end
391
+
392
+ # @param text [String] lines to indent one level with a tab
393
+ # @return [String] the text, each non-blank line prefixed with a tab
394
+ def indent(text)
395
+ text.lines.map { |line| line.strip.empty? ? line : "\t#{line}" }.join
396
+ end
397
+
398
+ # The compose source: one entry per container, plus the origin secret the proxy checks.
399
+ #
400
+ # @param plan [Settings::Plan] the resolved settings
401
+ # @return [String] `services.json`
402
+ def services_json(plan)
403
+ services = plan.containers.to_h { |c| [c.name, service_entry(plan, c)] }
404
+ origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
405
+ document = { "services" => services, "origin" => origin }
406
+ document["task_definition"] = plan.task_definition if plan.task_definition
407
+ document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
408
+ json = JSON.pretty_generate(document)
409
+ # An empty object prints as `{}` or `{` newline `}`, depending on the json gem.
410
+ "#{json.gsub(/\{\s*\}/, '{}')}\n"
411
+ end
412
+
413
+ # A container's entry. With a task definition the image, environment and secrets are read
414
+ # from it at deploy time, so only the name and port are written here.
415
+ #
416
+ # @param plan [Settings::Plan] the resolved settings
417
+ # @param container [Settings::Container] the container
418
+ # @return [Hash{String => Object}] its entry in `services.json`
419
+ def service_entry(plan, container)
420
+ entry = { "name" => container.name, "port" => container.port }
421
+ return entry if plan.task_definition
422
+
423
+ { "name" => container.name, "repository" => container.repository, "port" => container.port,
424
+ "env" => container.env, "secrets" => container.secrets }
425
+ end
426
+
427
+ # @param tunnel [Settings::Tunnel] the declared tunnel service
428
+ # @return [Hash{String => Object}] its entry in `services.json`
429
+ def tunnel_entry(tunnel)
430
+ { "url" => "http://127.0.0.1:#{tunnel.port}", "token_secret" => tunnel.token_secret, "image" => tunnel.image }
431
+ end
432
+
433
+ # @param plan [Settings::Plan] the resolved settings
434
+ # @param region [String] the validated region
435
+ # @return [String] `deploy-box.sh`
436
+ def deploy_box_sh(plan, region)
437
+ template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
438
+ "RDS_STACK" => plan.rds_stack, "REGION" => region,
439
+ "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan),
440
+ "USAGE" => deploy_usage(plan))
441
+ end
442
+
443
+ # @param plan [Settings::Plan] the resolved settings
444
+ # @return [String] the comment lines that say how to call `deploy-box.sh`
445
+ def deploy_usage(plan)
446
+ if plan.task_definition
447
+ <<~USAGE
448
+ # deploy-box.sh [task-definition]
449
+ #
450
+ # The task definition (a family or family:revision) defaults to the latest active revision of
451
+ # #{plan.task_definition}. Secret values are resolved on the box by fetch-secrets.sh and never
452
+ # pass through the SSM command.
453
+ USAGE
454
+ else
455
+ <<~USAGE
456
+ # deploy-box.sh [name=tag ...]
457
+ #
458
+ # A container named without a tag runs its "latest" image. Secret values are resolved on the box by
459
+ # fetch-secrets.sh and never pass through the SSM command.
460
+ USAGE
461
+ end
462
+ end
463
+
464
+ # The probes the post-roll check runs on the box, against the proxy on localhost.
465
+ #
466
+ # @param plan [Settings::Plan] the resolved settings
467
+ # @return [String] bash lines that set `BAD=1` on a failed probe
468
+ def health_checks(plan)
469
+ write_out = '-w "%{http_code}"' # rubocop:disable Style/FormatStringToken -- curl's own token
470
+ probe = lambda do |label, args, wanted|
471
+ <<~SH.chomp
472
+ code=$(curl -s -o /dev/null #{write_out} --max-time 40 localhost/#{args})
473
+ #{wanted} && echo "ok #{label} -> $code" || { echo "FAIL #{label} -> $code"; BAD=1; }
474
+ SH
475
+ end
476
+ probes =
477
+ if plan.origin_header
478
+ [
479
+ "S=$(grep ^ORIGIN_SECRET= caddy.secrets.env | cut -d= -f2-)",
480
+ probe.call("a request without the origin secret is refused", "", '[ "$code" = 403 ]'),
481
+ probe.call("a request with the origin secret is served", %( -H "#{plan.origin_header}: $S"),
482
+ '[ "${code#5}" = "$code" ]')
483
+ ]
484
+ else
485
+ [probe.call("the proxy serves /", "", '[ "${code#5}" = "$code" ]')]
486
+ end
487
+ (probes + tunnel_probe(plan)).join("\n")
488
+ end
489
+
490
+ # @param plan [Settings::Plan] the resolved settings
491
+ # @return [Array<String>] the probe that waits for the tunnel to register, when one runs
492
+ def tunnel_probe(plan)
493
+ return [] unless plan.tunnel_service
494
+
495
+ [<<~SH.chomp]
496
+ for _ in 1 2 3 4 5 6; do
497
+ n=$(docker compose -f compose.json logs --no-color cloudflared 2>&1 | grep -ci "registered tunnel connection" || true)
498
+ [ "$n" -gt 0 ] && break
499
+ sleep 5
500
+ done
501
+ [ "$n" -gt 0 ] && echo "ok the tunnel registered $n connections" || { echo "FAIL the tunnel has no connection"; BAD=1; }
502
+ SH
503
+ end
504
+
505
+ # @param plan [Settings::Plan] the resolved settings
506
+ # @return [String] a Makefile whose targets create the two stacks and roll the box
507
+ def makefile(plan)
508
+ <<~MAKE
509
+ # #{plan.infra_name}: one app box and one RDS instance.
510
+ # make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c [REHEARSAL=true]
511
+ # make deploy #{plan.task_definition ? '[TASKDEF=family:revision]' : '[TAGS="web=20260101 worker=20260101"]'}
512
+ RDS_STACK = #{plan.rds_stack}
513
+ BOX_STACK = #{plan.box_stack}
514
+ # true makes a throwaway pair: the database is deleted with its stack and the box has no
515
+ # Elastic IP. The default is a production pair, with deletion protection.
516
+ REHEARSAL ?= false
517
+
518
+ .PHONY: stacks deploy
519
+ stacks:
520
+ \taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\
521
+ \t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL)
522
+ \taws cloudformation deploy --template-file box.yaml --stack-name $(BOX_STACK) --capabilities CAPABILITY_IAM \\
523
+ \t\t--parameter-overrides VpcId=$(VPC) SubnetId=$(PUBLIC_SUBNET) \\
524
+ \t\tDbSecurityGroupId=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecurityGroupId'].OutputValue" --output text) \\
525
+ \t\tDbSecretArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue" --output text) \\
526
+ \t\tAlertTopicArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].OutputValue" --output text) \\
527
+ \t\tRehearsal=$(REHEARSAL)
528
+
529
+ deploy:
530
+ \tbash ./deploy-box.sh $(#{plan.task_definition ? 'TASKDEF' : 'TAGS'})
531
+ MAKE
532
+ end
533
+
534
+ # Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,
535
+ # so an empty value leaves no blank line behind; one inside a line is replaced in place.
536
+ #
537
+ # @param file [String] the template file's name in `TEMPLATE_DIR`
538
+ # @param values [Hash{String => String}] marker name => text
539
+ # @return [String] the rendered file
540
+ def template(file, values)
541
+ values.reduce(File.read(File.join(TEMPLATE_DIR, file))) do |text, (marker, value)|
542
+ own_line = /^@@#{marker}@@\n/
543
+ if text.match?(own_line)
544
+ text.sub(own_line) { value.empty? || value.end_with?("\n") ? value : "#{value}\n" }
545
+ else
546
+ text.gsub("@@#{marker}@@") { value }
547
+ end
548
+ end
549
+ end
550
+ end
551
+ end
552
+ end
553
+ end
@@ -241,12 +241,21 @@ module Hecks
241
241
  end
242
242
  private_class_method :s3_origin_lines
243
243
 
244
+ # Renders one cache behaviour: the default one under `key`, or a list item led by its
245
+ # path.
246
+ #
247
+ # @param key [String, nil] `"DefaultCacheBehavior"`, or nil for a `CacheBehaviors` item
248
+ # @param entry [Hash{Symbol => Object}] the behaviour as `normalize` checks it (`:origin`,
249
+ # `:path` for an item, `:viewer_protocol`, `:methods`, `:compress`, `:cache_policy`,
250
+ # `:origin_request_policy`), and optionally `:response_headers_policy`, each policy as
251
+ # `[id_or_intrinsic, comment_or_nil]`
252
+ # @param base [String] the indentation of `key`, or of the list item's dash
253
+ # @return [Array<String>] the lines
244
254
  def behavior_lines(key, entry, base)
245
255
  pad = "#{base} "
246
256
  head = key ? "#{base}#{key}:" : "#{base}- PathPattern: #{Yaml.string(entry[:path])}"
247
257
  [head, "#{pad}TargetOriginId: #{entry[:origin]}", *behavior_property_lines(entry, pad)]
248
258
  end
249
- private_class_method :behavior_lines
250
259
 
251
260
  def behavior_property_lines(entry, pad)
252
261
  lines = ["#{pad}ViewerProtocolPolicy: #{entry[:viewer_protocol]}",
@@ -255,6 +264,9 @@ module Hecks
255
264
  lines << "#{pad}Compress: #{entry[:compress]}" if entry.key?(:compress)
256
265
  lines << policy_line(pad, "CachePolicyId", entry[:cache_policy])
257
266
  lines << policy_line(pad, "OriginRequestPolicyId", entry[:origin_request_policy]) if entry[:origin_request_policy]
267
+ if entry[:response_headers_policy]
268
+ lines << policy_line(pad, "ResponseHeadersPolicyId", entry[:response_headers_policy])
269
+ end
258
270
  lines
259
271
  end
260
272
  private_class_method :behavior_property_lines
@@ -634,7 +634,7 @@ module Hecks
634
634
  # generator the Lambda deploy path already uses to produce
635
635
  # rust/dist/#{domain_name}.wasm/.ir.json from this domain's own
636
636
  # .bluebook.
637
- \tcd $(ROOT) && HECKS_ENVIRONMENT=memory ruby exe/hecks build_wasm domain=$(DOMAIN) --wait
637
+ \tcd $(ROOT) && HECKS_ENVIRONMENT=memory ruby exe/hecks build.build_wasm domain=$(DOMAIN) --wait
638
638
  \tcd $(ROOT)/rust/host && CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc rustup run stable cargo build --release --target aarch64-unknown-linux-gnu --bin bootstrap
639
639
  \tcp $(ROOT)/rust/host/target/aarch64-unknown-linux-gnu/release/bootstrap #{domain_name}-host
640
640
  \tcp $(ROOT)/rust/dist/#{domain_name}.wasm #{domain_name}.wasm