hecks 3.1.0 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +1 -1
  3. data/lib/hecks/chapters.rb +4 -3
  4. data/lib/hecks/cli/domain_writer.rb +39 -0
  5. data/lib/hecks/cli/interview_agent.rb +59 -0
  6. data/lib/hecks/cli/interview_run.rb +87 -0
  7. data/lib/hecks/cli/interview_session.rb +299 -0
  8. data/lib/hecks/corpus.rb +1 -0
  9. data/lib/hecks/doors/cli_runner.rb +1 -1
  10. data/lib/hecks/doors/usage_cache.rb +123 -0
  11. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  12. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  13. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  14. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  15. data/lib/hecks/hecks/custodian.bluebook +36 -0
  16. data/lib/hecks/hecks/hecks.bluebook +18 -0
  17. data/lib/hecks/hecks/hecks.hecksagon +8 -2
  18. data/lib/hecks/hecks/hecks.world +3 -2
  19. data/lib/hecks/projections/deploy/box/settings.rb +80 -4
  20. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -5
  22. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
  23. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  24. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
  25. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  26. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  27. data/lib/hecks/projections/deploy/box.rb +185 -19
  28. data/lib/hecks/projector/cli_projector.rb +38 -14
  29. data/lib/hecks/runtime/loader.rb +31 -8
  30. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  31. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  32. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  33. data/lib/hecks/version.rb +1 -1
  34. data/lib/hecks.rb +1 -0
  35. data/rust/codegen/src/json_codec.rs +3 -0
  36. data/rust/codegen/src/naming.rs +4 -0
  37. data/rust/codegen/src/types.rs +45 -0
  38. data/rust/host/HECKS_RELEASE +1 -1
  39. metadata +15 -2
@@ -0,0 +1,58 @@
1
+ #!/bin/bash
2
+ # Compare two databases holding @@STACK@@'s schemas (structure and exact row counts of every table)
3
+ # through a bastion that can reach both. Read-only on both. Used at the end of restore-to-rds.sh, for a
4
+ # backup restore drill (production vs a database restored from its backup) and for checking a rollback copy.
5
+ #
6
+ # verify-copy.sh <bastion-instance-id> <host-a> <secret-arn-a> <host-b> <secret-arn-b>
7
+ #
8
+ # A_DB, B_DB the database on each side (default @@DATABASE@@)
9
+ #
10
+ # Schemas compared: @@SCHEMAS@@
11
+ # Exit 0 only if both are identical and no materialized view is unpopulated in either.
12
+ set -euo pipefail
13
+
14
+ BASTION=${1:?bastion instance id}; A_HOST=${2:?}; A_SECRET=${3:?}; B_HOST=${4:?}; B_SECRET=${5:?}
15
+ SCHEMAS="@@SCHEMAS_CSV@@"
16
+ A_DB=${A_DB:-@@DATABASE@@}; B_DB=${B_DB:-@@DATABASE@@}
17
+ A_PORT=15434; B_PORT=15435
18
+
19
+ pg_major() { "$1" --version 2>/dev/null | sed -E 's/.* ([0-9]+)[.].*/\1/'; }
20
+ if [ "$(pg_major psql || echo 0)" -lt 16 ] && [ -x /opt/homebrew/opt/postgresql@16/bin/psql ]; then
21
+ export PATH=/opt/homebrew/opt/postgresql@16/bin:$PATH
22
+ fi
23
+
24
+ WORK=$(mktemp -d); PIDS=()
25
+ cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null || true; done; rm -rf "$WORK"; }
26
+ trap cleanup EXIT
27
+
28
+ secret_field() { aws secretsmanager get-secret-value --secret-id "$1" --query SecretString --output text | jq -r "$2"; }
29
+ A_PW=$(secret_field "$A_SECRET" .password); B_PW=$(secret_field "$B_SECRET" .password)
30
+ A_USER=$(secret_field "$A_SECRET" '.username // "postgres"'); B_USER=$(secret_field "$B_SECRET" '.username // "postgres"')
31
+ tunnel() {
32
+ aws ssm start-session --target "$BASTION" --document-name AWS-StartPortForwardingSessionToRemoteHost \
33
+ --parameters "{\"host\":[\"$1\"],\"portNumber\":[\"5432\"],\"localPortNumber\":[\"$2\"]}" >"$WORK/t_$2.log" 2>&1 &
34
+ PIDS+=($!); disown
35
+ }
36
+ tunnel "$A_HOST" $A_PORT; tunnel "$B_HOST" $B_PORT
37
+ for i in $(seq 1 20); do
38
+ grep -q "Waiting for connections" "$WORK/t_$A_PORT.log" 2>/dev/null && grep -q "Waiting for connections" "$WORK/t_$B_PORT.log" 2>/dev/null && break
39
+ sleep 1
40
+ done
41
+ A() { PGPASSWORD=$A_PW psql -h localhost -p $A_PORT -U "$A_USER" -d "$A_DB" -Atc "$1"; }
42
+ B() { PGPASSWORD=$B_PW psql -h localhost -p $B_PORT -U "$B_USER" -d "$B_DB" -Atc "$1"; }
43
+
44
+ SHAPE="select n.nspname||' '||c.relkind::text, count(*) from pg_class c join pg_namespace n on n.oid=c.relnamespace where n.nspname = any(string_to_array('$SCHEMAS', ',')) group by 1
45
+ union all select 'policies', count(*) from pg_policies where schemaname = any(string_to_array('$SCHEMAS', ','))
46
+ union all select 'functions', count(*) from pg_proc p join pg_namespace n on n.oid=p.pronamespace where n.nspname = any(string_to_array('$SCHEMAS', ',')) order by 1"
47
+ COUNTS="select table_schema||'.'||table_name, (xpath('/row/c/text()', query_to_xml(format('select count(*) as c from %I.%I', table_schema, table_name), false, true, '')))[1]::text from information_schema.tables where table_type='BASE TABLE' and table_schema = any(string_to_array('$SCHEMAS', ',')) order by 1"
48
+ UNPOP="select count(*) from pg_matviews where schemaname = any(string_to_array('$SCHEMAS', ',')) and not ispopulated"
49
+
50
+ BAD=0
51
+ A "$SHAPE" >"$WORK/shape_a"; B "$SHAPE" >"$WORK/shape_b"
52
+ A "$COUNTS" >"$WORK/counts_a"; B "$COUNTS" >"$WORK/counts_b"
53
+ [ -s "$WORK/counts_a" ] || { echo "FAIL: first database has no tables to compare" >&2; BAD=1; }
54
+ [ "$(A "$UNPOP")" = 0 ] && [ "$(B "$UNPOP")" = 0 ] || { echo "FAIL: unpopulated materialized views" >&2; BAD=1; }
55
+ diff "$WORK/shape_a" "$WORK/shape_b" >/dev/null || { echo "FAIL: structure differs" >&2; diff "$WORK/shape_a" "$WORK/shape_b" >&2 || true; BAD=1; }
56
+ diff "$WORK/counts_a" "$WORK/counts_b" >/dev/null || { echo "FAIL: row counts differ" >&2; diff "$WORK/counts_a" "$WORK/counts_b" >&2 || true; BAD=1; }
57
+ [ "$BAD" = 0 ] || exit 1
58
+ echo "OK: $(wc -l <"$WORK/counts_a" | tr -d ' ') tables, identical row counts and structure"
@@ -20,10 +20,18 @@ module Hecks
20
20
  # the legacy `compute-1` form.
21
21
  LEGACY_COMPUTE_REGION = "us-east-1".freeze
22
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
+
23
30
  module_function
24
31
 
25
32
  # Generates `rds.yaml`, `box.yaml`, `Caddyfile`, `services.json`, `render-compose.sh`,
26
- # `fetch-secrets.sh`, `deploy-box.sh` and a `Makefile`.
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`.
27
35
  #
28
36
  # @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
29
37
  # @param options [Hash] generation options; same shape as `Fargate.call`'s
@@ -82,15 +90,95 @@ module Hecks
82
90
  {
83
91
  "rds.yaml" => rds_yaml(plan), "box.yaml" => box_yaml(plan, region),
84
92
  "Caddyfile" => caddyfile(plan), "services.json" => services_json(plan),
85
- "render-compose.sh" => template("render-compose.sh.tmpl", "STACK" => plan.infra_name, "REGION" => region,
86
- "DB_NAME" => plan.database_name,
87
- "PROXY_IMAGE" => plan.proxy_image),
93
+ "render-compose.sh" => render_compose_sh(plan, region),
88
94
  "fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
89
95
  "deploy-box.sh" => deploy_box_sh(plan, region),
90
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)
91
117
  }
92
118
  end
93
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
+
94
182
  # @param plan [Settings::Plan] the resolved settings
95
183
  # @return [String] the database stack
96
184
  def rds_yaml(plan)
@@ -110,7 +198,39 @@ module Hecks
110
198
  "COMPUTE_DOMAIN" => compute_domain(region),
111
199
  "SECRET_RESOURCES" => secret_resources(plan), "TUNNEL_EGRESS" => tunnel_egress(plan),
112
200
  "SWAP_COMMANDS" => swap_commands(plan), "ECR_REPOSITORIES" => ecr_repositories(plan),
113
- "ECR_OUTPUTS" => ecr_outputs(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"]
114
234
  end
115
235
 
116
236
  # Graviton families end their generation digit with `g` (`t4g`, `m7gd`, `c6gn`).
@@ -174,11 +294,14 @@ module Hecks
174
294
  SH
175
295
  end
176
296
 
177
- # One ECR repository per container, keeping the newest 30 images.
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.
178
299
  #
179
300
  # @param plan [Settings::Plan] the resolved settings
180
301
  # @return [String] CloudFormation resources, each preceded by a blank line
181
302
  def ecr_repositories(plan)
303
+ return "" if plan.task_definition
304
+
182
305
  plan.containers.map do |container|
183
306
  id = "#{logical(container.name)}Repository"
184
307
  <<~YAML.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
@@ -198,6 +321,8 @@ module Hecks
198
321
  # @param plan [Settings::Plan] the resolved settings
199
322
  # @return [String] one repository URI output per container
200
323
  def ecr_outputs(plan)
324
+ return "" if plan.task_definition
325
+
201
326
  plan.containers.map do |container|
202
327
  " #{logical(container.name)}RepositoryUri:\n Value: !GetAtt #{logical(container.name)}Repository.RepositoryUri\n"
203
328
  end.join.chomp
@@ -223,7 +348,7 @@ module Hecks
223
348
  else
224
349
  indent(route_blocks(plan))
225
350
  end
226
- "#{caddy_header(plan)}#{caddy_global(guarded)}\n:80 {\n#{site}}\n"
351
+ "#{caddy_header(plan)}#{caddy_global(guarded)}\n:80 {\n#{site}}\n\n#{CADDY_EXTRA}"
227
352
  end
228
353
 
229
354
  # @param plan [Settings::Plan] the resolved settings
@@ -256,9 +381,12 @@ module Hecks
256
381
  # @param guarded [Boolean] whether an origin secret guards the site
257
382
  # @return [String] the global options block
258
383
  def caddy_global(guarded)
259
- return "{\n\tauto_https off\n\tadmin off\n}\n" unless 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
260
388
 
261
- "{\n\tauto_https off\n\tadmin off\n\tservers {\n\t\ttrusted_proxies static 0.0.0.0/0 ::/0\n\t}\n}\n"
389
+ "{\n#{options}\tservers {\n\t\ttrusted_proxies static 0.0.0.0/0 ::/0\n\t}\n}\n"
262
390
  end
263
391
 
264
392
  # @param text [String] lines to indent one level with a tab
@@ -272,18 +400,30 @@ module Hecks
272
400
  # @param plan [Settings::Plan] the resolved settings
273
401
  # @return [String] `services.json`
274
402
  def services_json(plan)
275
- services = plan.containers.to_h do |c|
276
- [c.name, { "name" => c.name, "repository" => c.repository, "port" => c.port,
277
- "env" => c.env, "secrets" => c.secrets }]
278
- end
403
+ services = plan.containers.to_h { |c| [c.name, service_entry(plan, c)] }
279
404
  origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
280
405
  document = { "services" => services, "origin" => origin }
406
+ document["task_definition"] = plan.task_definition if plan.task_definition
281
407
  document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
282
408
  json = JSON.pretty_generate(document)
283
409
  # An empty object prints as `{}` or `{` newline `}`, depending on the json gem.
284
410
  "#{json.gsub(/\{\s*\}/, '{}')}\n"
285
411
  end
286
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
+
287
427
  # @param tunnel [Settings::Tunnel] the declared tunnel service
288
428
  # @return [Hash{String => Object}] its entry in `services.json`
289
429
  def tunnel_entry(tunnel)
@@ -296,7 +436,29 @@ module Hecks
296
436
  def deploy_box_sh(plan, region)
297
437
  template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
298
438
  "RDS_STACK" => plan.rds_stack, "REGION" => region,
299
- "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan))
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
300
462
  end
301
463
 
302
464
  # The probes the post-roll check runs on the box, against the proxy on localhost.
@@ -345,23 +507,27 @@ module Hecks
345
507
  def makefile(plan)
346
508
  <<~MAKE
347
509
  # #{plan.infra_name}: one app box and one RDS instance.
348
- # make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c
349
- # make deploy [TAGS="web=20260101 worker=20260101"]
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"]'}
350
512
  RDS_STACK = #{plan.rds_stack}
351
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
352
517
 
353
518
  .PHONY: stacks deploy
354
519
  stacks:
355
520
  \taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\
356
- \t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS)
521
+ \t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL)
357
522
  \taws cloudformation deploy --template-file box.yaml --stack-name $(BOX_STACK) --capabilities CAPABILITY_IAM \\
358
523
  \t\t--parameter-overrides VpcId=$(VPC) SubnetId=$(PUBLIC_SUBNET) \\
359
524
  \t\tDbSecurityGroupId=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecurityGroupId'].OutputValue" --output text) \\
360
525
  \t\tDbSecretArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecretArn'].OutputValue" --output text) \\
361
- \t\tAlertTopicArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].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)
362
528
 
363
529
  deploy:
364
- \tbash ./deploy-box.sh $(TAGS)
530
+ \tbash ./deploy-box.sh $(#{plan.task_definition ? 'TASKDEF' : 'TAGS'})
365
531
  MAKE
366
532
  end
367
533
 
@@ -400,16 +400,15 @@ module Hecks
400
400
  return command_help(program, spec[:short], spec, ask: options[:ask]) if spec
401
401
  end
402
402
 
403
- width = (commands.values + questions.values).map { |spec| label(spec).length }.max.to_i
404
403
  out = ["#{bluebook.name} — #{bluebook.vision}", "",
405
404
  " #{program} <command>! [name=value …] do something",
406
405
  " #{program} query <query> [name=value …] read something", ""]
407
406
 
408
407
  out << "commands:"
409
- out.concat(listing(commands, width) { |spec| spec[:summary] })
408
+ out.concat(listing(commands) { |spec| spec[:summary] })
410
409
  out << ""
411
410
  out << "queries (nothing here changes anything):"
412
- out.concat(listing(questions, width) { |spec| first_sentence(spec[:summary]) })
411
+ out.concat(listing(questions) { |spec| first_sentence(spec[:summary]) })
413
412
  out << ""
414
413
  out << " #{program} <command> --help what one command wants, and every way it refuses"
415
414
  out << " a command is called with its aggregate — #{example_qualified(commands)}"
@@ -417,34 +416,59 @@ module Hecks
417
416
  end
418
417
 
419
418
  # The command or question lines of the help. A domain with more than one aggregate is listed
420
- # under a heading per aggregate, so related commands sit together; the bookkeeping a run
419
+ # under a heading per aggregate, so related commands sit together; the heading is the prefix
420
+ # every call to them carries, so the lines under it leave it out. The bookkeeping a run
421
421
  # records about itself (`internal`: system-role commands and port operations) is set apart as
422
- # names only, since a person never types them. A single-aggregate domain keeps the plain list.
423
- def listing(specs, width)
422
+ # names only, since a person never types them. A single-aggregate domain keeps the plain
423
+ # list, each name in full.
424
+ def listing(specs)
424
425
  shown, internal = specs.values.partition { |spec| !spec[:internal] }
425
426
  groups = shown.group_by { |spec| spec[:group] }
427
+ grouped = groups.length > 1
428
+ named = shown.to_h { |spec| [spec, entry_name(spec, grouped)] }
429
+ width = named.values.map(&:length).max.to_i
426
430
  lines = []
427
- if groups.length > 1
431
+ if grouped
428
432
  groups.each do |group, members|
429
433
  lines << " #{heading(group)}" if group
430
- members.each { |spec| lines << " #{label(spec).ljust(width)} #{yield(spec)}" }
434
+ members.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
431
435
  end
432
436
  else
433
- shown.each { |spec| lines << " #{label(spec).ljust(width)} #{yield(spec)}" }
437
+ shown.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
434
438
  end
435
439
  lines.concat(internal_lines(internal)) unless internal.empty?
436
440
  lines
437
441
  end
438
442
 
439
- # "LanguageRun" reads "Language": the Run suffix names the journaled-command shape all of
440
- # these aggregates share, so it adds nothing under a heading ("Test suite", "Model check").
443
+ # The aggregate's own name as a heading: the prefix of every call to the lines under it.
441
444
  def heading(group)
442
- words = Naming.snake(group.sub(/Run\z/, "")).tr("_", " ")
443
- "#{words.capitalize}:"
445
+ "#{Naming.snake(group)}:"
446
+ end
447
+
448
+ # A spec's name as listed: under its aggregate's heading the aggregate prefix is left out.
449
+ # A command the chapter gives a short name (`mcp`) is listed by its real name, so the
450
+ # heading and the line still spell a call; `alias_note` says the short name.
451
+ def entry_name(spec, grouped)
452
+ name = label(spec, real: true)
453
+ return name unless grouped && spec[:group]
454
+
455
+ name.delete_prefix("#{Naming.snake(spec[:group])}.")
456
+ end
457
+
458
+ # " (also: mcp!)" for a spec the chapter gave a short name, else nothing. A short name that
459
+ # is only the command's own name (`init` for `door.init`) is already in the line.
460
+ def alias_note(spec)
461
+ return "" if spec[:short_was].nil? || spec[:short_was].split(".").last == spec[:short]
462
+
463
+ " (also: #{label(spec)})"
444
464
  end
445
465
 
446
466
  # A command is written with the `!` that marks it; a query without one.
447
- def label(spec) = spec[:kind] == :command ? "#{spec[:short]}!" : spec[:short]
467
+ # With `real:`, a short name the chapter gave it gives way to the name it was given for.
468
+ def label(spec, real: false)
469
+ name = real && spec.key?(:short_was) ? spec[:short_was] : spec[:short]
470
+ spec[:kind] == :command ? "#{name}!" : name
471
+ end
448
472
 
449
473
  # The internal commands as bare names, wrapped, under one line saying what they are.
450
474
  def internal_lines(specs)
@@ -50,7 +50,26 @@ module Hecks
50
50
  end
51
51
 
52
52
  # What `describe` answers: the loaded declarations and nothing bound to run them.
53
- Described = Struct.new(:registry, :directory)
53
+ #
54
+ # The declarations load the first time `registry` is asked for, not when `describe` returns,
55
+ # so a caller that can answer from `directory` alone (a launcher with its help already
56
+ # remembered) never pays for the load. The directory is checked at once.
57
+ class Described
58
+ # @return [String] the domain directory that was described
59
+ attr_reader :directory
60
+
61
+ # @param directory [String] the domain directory
62
+ # @yield loads the declarations; answers the registry
63
+ def initialize(directory, &load)
64
+ @directory = directory
65
+ @load = load
66
+ end
67
+
68
+ # @return [Registry] the declarations, loaded on first use
69
+ def registry
70
+ @registry ||= @load.call
71
+ end
72
+ end
54
73
 
55
74
  # Loads `path`'s declarations into a fresh Registry and stops: no boot gate runs, no
56
75
  # persistence adapter is resolved or bound, nothing connects to a database.
@@ -66,15 +85,19 @@ module Hecks
66
85
  def self.describe(path, shared: nil, environment: FROM_ENV)
67
86
  loading = Ports::Loading.bootstrap
68
87
  directory = loading.bluebook_directory(path)
69
- root = loading.shared_root(shared, directory)
70
- registry = Registry.new(root: File.dirname(directory))
88
+ overlay = selected_environment(environment)
71
89
 
72
- Hecks.with_registry(registry) do
73
- loading.load_library
74
- loading.load_project(root)
75
- loading.load_domain(directory, environment: selected_environment(environment))
90
+ Described.new(directory) do
91
+ root = loading.shared_root(shared, directory)
92
+ registry = Registry.new(root: File.dirname(directory))
93
+
94
+ Hecks.with_registry(registry) do
95
+ loading.load_library
96
+ loading.load_project(root)
97
+ loading.load_domain(directory, environment: overlay)
98
+ end
99
+ registry
76
100
  end
77
- Described.new(registry, directory)
78
101
  end
79
102
 
80
103
  # The overlay a boot loads: the caller's own choice (nil meaning none), else the