hecks 3.1.0 → 3.1.2

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 (47) 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 +87 -4
  20. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +2 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +18 -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 +216 -20
  28. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
  29. data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
  30. data/lib/hecks/projector/cli_projector.rb +38 -14
  31. data/lib/hecks/runtime/loader.rb +31 -8
  32. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  33. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  34. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  35. data/lib/hecks/version.rb +1 -1
  36. data/lib/hecks.rb +1 -0
  37. data/rust/codegen/src/commands.rs +4 -32
  38. data/rust/codegen/src/json_codec.rs +3 -0
  39. data/rust/codegen/src/mutations.rs +74 -5
  40. data/rust/codegen/src/naming.rs +4 -0
  41. data/rust/codegen/src/types.rs +45 -0
  42. data/rust/host/HECKS_RELEASE +1 -1
  43. data/rust/host/src/web/registration_receipt/tests.rs +39 -0
  44. data/rust/host/src/web/registration_receipt.rs +122 -0
  45. data/rust/host/src/web/registrations.rs +14 -2
  46. data/rust/host/src/web.rs +1 -0
  47. metadata +17 -2
@@ -0,0 +1,91 @@
1
+ #!/bin/bash
2
+ # Copy @@STACK@@'s schemas from the old database into the new RDS instance through a bastion that can
3
+ # reach both, then verify the copy. Piped dump | restore, so nothing is written to disk. Never writes to
4
+ # the source.
5
+ #
6
+ # restore-to-rds.sh <bastion-instance-id> <source-host> <source-secret-arn> <rds-host> <rds-secret-arn>
7
+ #
8
+ # FORCE=1 drop the target schemas first: a re-load before cutover, or a rollback copy in the other
9
+ # direction (swap the hosts and secrets, and SRC_DB and DST_DB)
10
+ # SRC_DB the source database (default @@SOURCE_DATABASE@@)
11
+ # DST_DB the target database (default @@DATABASE@@)
12
+ #
13
+ # Schemas copied: @@SCHEMAS@@
14
+ #
15
+ # Why not a plain pg_restore: a Hecks schema's materialized views call hecks_tr_extract() unqualified, and
16
+ # pg_restore runs with an empty search_path, so the restore's REFRESH step errors on them. This restores
17
+ # tolerating exactly that error, then refreshes the views with the schema on the search_path, and fails on
18
+ # any other error.
19
+ #
20
+ # Needs pg_dump, pg_restore and psql 16 or newer, the SSM plugin, jq and AWS credentials that can read both
21
+ # secrets and start SSM sessions.
22
+ set -euo pipefail
23
+ HERE=$(cd "$(dirname "$0")" && pwd)
24
+
25
+ BASTION=${1:?bastion instance id}; SRC_HOST=${2:?source host}; SRC_SECRET=${3:?source secret arn}
26
+ DST_HOST=${4:?target host}; DST_SECRET=${5:?target secret arn}
27
+ SCHEMAS="@@SCHEMAS@@"
28
+ SRC_DB=${SRC_DB:-@@SOURCE_DATABASE@@}; DST_DB=${DST_DB:-@@DATABASE@@}
29
+ SRC_PORT=15432; DST_PORT=15433
30
+
31
+ pg_major() { "$1" --version 2>/dev/null | sed -E 's/.* ([0-9]+)[.].*/\1/'; }
32
+ # The default client on the PATH is often older than the server (Homebrew's is 14); prefer postgresql@16.
33
+ if [ "$(pg_major pg_dump || echo 0)" -lt 16 ] && [ -x /opt/homebrew/opt/postgresql@16/bin/pg_dump ]; then
34
+ export PATH=/opt/homebrew/opt/postgresql@16/bin:$PATH
35
+ fi
36
+ for t in pg_dump pg_restore psql; do
37
+ [ "$(pg_major $t || echo 0)" -ge 16 ] || { echo "need $t 16 or newer on the PATH" >&2; exit 1; }
38
+ done
39
+
40
+ WORK=$(mktemp -d)
41
+ PIDS=()
42
+ cleanup() { for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null || true; done; rm -rf "$WORK"; }
43
+ trap cleanup EXIT
44
+
45
+ secret_field() { aws secretsmanager get-secret-value --secret-id "$1" --query SecretString --output text | jq -r "$2"; }
46
+ SRC_PW=$(secret_field "$SRC_SECRET" .password); DST_PW=$(secret_field "$DST_SECRET" .password)
47
+ SRC_USER=$(secret_field "$SRC_SECRET" '.username // "postgres"'); DST_USER=$(secret_field "$DST_SECRET" '.username // "postgres"')
48
+
49
+ tunnel() { # host local-port
50
+ aws ssm start-session --target "$BASTION" --document-name AWS-StartPortForwardingSessionToRemoteHost \
51
+ --parameters "{\"host\":[\"$1\"],\"portNumber\":[\"5432\"],\"localPortNumber\":[\"$2\"]}" >"$WORK/tunnel_$2.log" 2>&1 &
52
+ PIDS+=($!)
53
+ disown # so shutting the tunnel down at exit does not print a "Terminated" line
54
+ }
55
+ tunnel "$SRC_HOST" $SRC_PORT; tunnel "$DST_HOST" $DST_PORT
56
+ for i in $(seq 1 20); do
57
+ grep -q "Waiting for connections" "$WORK/tunnel_$SRC_PORT.log" 2>/dev/null && grep -q "Waiting for connections" "$WORK/tunnel_$DST_PORT.log" 2>/dev/null && break
58
+ sleep 1
59
+ done
60
+
61
+ SRC() { PGPASSWORD=$SRC_PW psql -h localhost -p $SRC_PORT -U "$SRC_USER" -d "$SRC_DB" -Atc "$1"; }
62
+ DST() { PGPASSWORD=$DST_PW psql -h localhost -p $DST_PORT -U "$DST_USER" -d "$DST_DB" -Atc "$1"; }
63
+ echo "source $(SRC 'show server_version'), target $(DST 'show server_version')"
64
+
65
+ # Refuse to clobber a target that already holds the schemas.
66
+ for S in $SCHEMAS; do
67
+ if [ "$(DST "select count(*) from pg_namespace where nspname='$S'")" != 0 ]; then
68
+ if [ "${FORCE:-}" = 1 ]; then DST "drop schema \"$S\" cascade" >/dev/null; echo "dropped target schema $S"
69
+ else echo "target already has schema $S; re-run with FORCE=1 to replace it" >&2; exit 1; fi
70
+ fi
71
+ done
72
+
73
+ for S in $SCHEMAS; do
74
+ echo "== copying schema $S"
75
+ PGPASSWORD=$SRC_PW pg_dump -h localhost -p $SRC_PORT -U "$SRC_USER" -d "$SRC_DB" --schema="$S" -Fc --no-owner --no-privileges \
76
+ | PGPASSWORD=$DST_PW pg_restore -h localhost -p $DST_PORT -U "$DST_USER" -d "$DST_DB" --no-owner --no-privileges 2>"$WORK/err_$S.txt" || true
77
+ TOTAL=$(grep -c 'error:' "$WORK/err_$S.txt" || true)
78
+ UNEXPECTED=$(grep 'error:' "$WORK/err_$S.txt" | grep -vc 'hecks_tr_extract' || true)
79
+ echo " restore errors: $TOTAL (unexpected: $UNEXPECTED)"
80
+ if [ "$UNEXPECTED" != 0 ]; then grep 'error:' "$WORK/err_$S.txt" | grep -v 'hecks_tr_extract' | head -5 >&2; exit 1; fi
81
+ done
82
+
83
+ # Refresh what pg_restore could not (with the schema on the search_path).
84
+ for S in $SCHEMAS; do
85
+ for MV in $(DST "select matviewname from pg_matviews where schemaname='$S' and not ispopulated"); do
86
+ PGPASSWORD=$DST_PW psql -h localhost -p $DST_PORT -U "$DST_USER" -d "$DST_DB" -qAtc "set search_path=\"$S\",public; refresh materialized view \"$S\".\"$MV\""
87
+ done
88
+ done
89
+
90
+ # Same structure, same exact row counts in every table, nothing unpopulated.
91
+ A_DB=$SRC_DB B_DB=$DST_DB bash "$HERE/verify-copy.sh" "$BASTION" "$SRC_HOST" "$SRC_SECRET" "$DST_HOST" "$DST_SECRET"
@@ -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)
@@ -108,9 +196,42 @@ module Hecks
108
196
  "VOLUME_GB" => plan.volume_gb.to_s, "SNAPSHOTS_KEEP" => plan.snapshots_keep.to_s,
109
197
  "AMI_PARAMETER" => ami_parameter(plan.instance_type),
110
198
  "COMPUTE_DOMAIN" => compute_domain(region),
111
- "SECRET_RESOURCES" => secret_resources(plan), "TUNNEL_EGRESS" => tunnel_egress(plan),
199
+ "SECRET_RESOURCES" => secret_resources(plan), "WRITABLE_SECRETS" => writable_secrets(plan),
200
+ "TUNNEL_EGRESS" => tunnel_egress(plan),
112
201
  "SWAP_COMMANDS" => swap_commands(plan), "ECR_REPOSITORIES" => ecr_repositories(plan),
113
- "ECR_OUTPUTS" => ecr_outputs(plan))
202
+ "ECR_OUTPUTS" => ecr_outputs(plan), "S3_POLICY" => s3_policy(plan))
203
+ end
204
+
205
+ # The role's S3 policy: every declared bucket readable, and writable only on a production
206
+ # box, so a rehearsal never changes the real objects.
207
+ #
208
+ # @param plan [Settings::Plan] the resolved settings
209
+ # @return [String] a policy list item, indented into the role's `Policies`, or nothing
210
+ def s3_policy(plan)
211
+ return "" if plan.s3_buckets.empty?
212
+
213
+ arn = ->(path) { "arn:${AWS::Partition}:s3:::#{path}" }
214
+ reads = plan.s3_buckets.flat_map { |b| [arn.call(b.name), arn.call("#{b.name}/*")] }
215
+ writes = plan.s3_buckets.select(&:write).map { |b| arn.call("#{b.name}/*") }
216
+ rows = s3_read_rows(reads)
217
+ rows += s3_write_rows(writes) unless writes.empty?
218
+ "#{rows.map { |row| " #{row}" }.join("\n")}\n"
219
+ end
220
+
221
+ # @param resources [Array<String>] the bucket and object ARNs a box may read
222
+ # @return [Array<String>] the policy's head and its read statement, relative to `Policies`
223
+ def s3_read_rows(resources)
224
+ ["- PolicyName: s3-access", " PolicyDocument:", " Version: \"2012-10-17\"", " Statement:",
225
+ " - Effect: Allow", " Action: [s3:GetObject, s3:ListBucket]", " Resource:"] +
226
+ resources.map { |r| " - !Sub \"#{r}\"" }
227
+ end
228
+
229
+ # @param resources [Array<String>] the object ARNs a production box may write
230
+ # @return [Array<String>] the production-only write statement, relative to `Policies`
231
+ def s3_write_rows(resources)
232
+ [" - !If", " - IsProduction", " - Effect: Allow",
233
+ " Action: [s3:PutObject, s3:DeleteObject]", " Resource:"] +
234
+ resources.map { |r| " - !Sub \"#{r}\"" } + [" - !Ref AWS::NoValue"]
114
235
  end
115
236
 
116
237
  # Graviton families end their generation digit with `g` (`t4g`, `m7gd`, `c6gn`).
@@ -141,6 +262,28 @@ module Hecks
141
262
  end.join.chomp
142
263
  end
143
264
 
265
+ # The statement that lets a production box overwrite the secrets named as writable.
266
+ #
267
+ # @param plan [Settings::Plan] the resolved settings
268
+ # @return [String] a policy statement for the role, or nothing when none are declared
269
+ def writable_secrets(plan)
270
+ return "" if plan.writable_secrets.empty?
271
+
272
+ arns = plan.writable_secrets.map { |name| name.end_with?("*") ? name : "#{name}-*" }.uniq
273
+ resources = arns.map do |pattern|
274
+ "#{' ' * 20}- !Sub \"arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:#{pattern}\"\n"
275
+ end.join
276
+ <<~YAML.chomp
277
+ #{' ' * 14}- !If
278
+ #{' ' * 16}- IsProduction
279
+ #{' ' * 16}- Effect: Allow
280
+ #{' ' * 18}Action: secretsmanager:PutSecretValue
281
+ #{' ' * 18}Resource:
282
+ #{resources.chomp}
283
+ #{' ' * 16}- !Ref AWS::NoValue
284
+ YAML
285
+ end
286
+
144
287
  # @param plan [Settings::Plan] the resolved settings
145
288
  # @return [String] the outbound rules a Cloudflare Tunnel sidecar needs, or nothing
146
289
  def tunnel_egress(plan)
@@ -174,11 +317,14 @@ module Hecks
174
317
  SH
175
318
  end
176
319
 
177
- # One ECR repository per container, keeping the newest 30 images.
320
+ # One ECR repository per container, keeping the newest 30 images. A task definition names
321
+ # images that already have repositories, so none are made.
178
322
  #
179
323
  # @param plan [Settings::Plan] the resolved settings
180
324
  # @return [String] CloudFormation resources, each preceded by a blank line
181
325
  def ecr_repositories(plan)
326
+ return "" if plan.task_definition
327
+
182
328
  plan.containers.map do |container|
183
329
  id = "#{logical(container.name)}Repository"
184
330
  <<~YAML.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
@@ -198,6 +344,8 @@ module Hecks
198
344
  # @param plan [Settings::Plan] the resolved settings
199
345
  # @return [String] one repository URI output per container
200
346
  def ecr_outputs(plan)
347
+ return "" if plan.task_definition
348
+
201
349
  plan.containers.map do |container|
202
350
  " #{logical(container.name)}RepositoryUri:\n Value: !GetAtt #{logical(container.name)}Repository.RepositoryUri\n"
203
351
  end.join.chomp
@@ -223,7 +371,7 @@ module Hecks
223
371
  else
224
372
  indent(route_blocks(plan))
225
373
  end
226
- "#{caddy_header(plan)}#{caddy_global(guarded)}\n:80 {\n#{site}}\n"
374
+ "#{caddy_header(plan)}#{caddy_global(guarded)}\n:80 {\n#{site}}\n\n#{CADDY_EXTRA}"
227
375
  end
228
376
 
229
377
  # @param plan [Settings::Plan] the resolved settings
@@ -256,9 +404,12 @@ module Hecks
256
404
  # @param guarded [Boolean] whether an origin secret guards the site
257
405
  # @return [String] the global options block
258
406
  def caddy_global(guarded)
259
- return "{\n\tauto_https off\n\tadmin off\n}\n" unless guarded
407
+ options = "\t# No certificate for the :80 site. disable_redirects, not off, so a listener a rehearsal\n" \
408
+ "\t# mounts under caddy-extra can still ask for `tls internal`.\n" \
409
+ "\tauto_https disable_redirects\n\tadmin off\n"
410
+ return "{\n#{options}}\n" unless guarded
260
411
 
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"
412
+ "{\n#{options}\tservers {\n\t\ttrusted_proxies static 0.0.0.0/0 ::/0\n\t}\n}\n"
262
413
  end
263
414
 
264
415
  # @param text [String] lines to indent one level with a tab
@@ -272,18 +423,30 @@ module Hecks
272
423
  # @param plan [Settings::Plan] the resolved settings
273
424
  # @return [String] `services.json`
274
425
  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
426
+ services = plan.containers.to_h { |c| [c.name, service_entry(plan, c)] }
279
427
  origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
280
428
  document = { "services" => services, "origin" => origin }
429
+ document["task_definition"] = plan.task_definition if plan.task_definition
281
430
  document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
282
431
  json = JSON.pretty_generate(document)
283
432
  # An empty object prints as `{}` or `{` newline `}`, depending on the json gem.
284
433
  "#{json.gsub(/\{\s*\}/, '{}')}\n"
285
434
  end
286
435
 
436
+ # A container's entry. With a task definition the image, environment and secrets are read
437
+ # from it at deploy time, so only the name and port are written here.
438
+ #
439
+ # @param plan [Settings::Plan] the resolved settings
440
+ # @param container [Settings::Container] the container
441
+ # @return [Hash{String => Object}] its entry in `services.json`
442
+ def service_entry(plan, container)
443
+ entry = { "name" => container.name, "port" => container.port }
444
+ return entry if plan.task_definition
445
+
446
+ { "name" => container.name, "repository" => container.repository, "port" => container.port,
447
+ "env" => container.env, "secrets" => container.secrets }
448
+ end
449
+
287
450
  # @param tunnel [Settings::Tunnel] the declared tunnel service
288
451
  # @return [Hash{String => Object}] its entry in `services.json`
289
452
  def tunnel_entry(tunnel)
@@ -296,7 +459,36 @@ module Hecks
296
459
  def deploy_box_sh(plan, region)
297
460
  template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
298
461
  "RDS_STACK" => plan.rds_stack, "REGION" => region,
299
- "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan))
462
+ "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan),
463
+ "SMOKE_HEADER" => smoke_header(plan), "USAGE" => deploy_usage(plan))
464
+ end
465
+
466
+ # @param plan [Settings::Plan] the resolved settings
467
+ # @return [String] the line that adds the origin secret to the smoke listener's requests, or
468
+ # nothing when the world has no origin guard
469
+ def smoke_header(plan)
470
+ plan.origin_header ? "\t\theader_up #{plan.origin_header} {$ORIGIN_SECRET}" : ""
471
+ end
472
+
473
+ # @param plan [Settings::Plan] the resolved settings
474
+ # @return [String] the comment lines that say how to call `deploy-box.sh`
475
+ def deploy_usage(plan)
476
+ if plan.task_definition
477
+ <<~USAGE
478
+ # deploy-box.sh [task-definition]
479
+ #
480
+ # The task definition (a family or family:revision) defaults to the latest active revision of
481
+ # #{plan.task_definition}. Secret values are resolved on the box by fetch-secrets.sh and never
482
+ # pass through the SSM command.
483
+ USAGE
484
+ else
485
+ <<~USAGE
486
+ # deploy-box.sh [name=tag ...]
487
+ #
488
+ # A container named without a tag runs its "latest" image. Secret values are resolved on the box by
489
+ # fetch-secrets.sh and never pass through the SSM command.
490
+ USAGE
491
+ end
300
492
  end
301
493
 
302
494
  # The probes the post-roll check runs on the box, against the proxy on localhost.
@@ -345,23 +537,27 @@ module Hecks
345
537
  def makefile(plan)
346
538
  <<~MAKE
347
539
  # #{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"]
540
+ # make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c [REHEARSAL=true]
541
+ # make deploy #{plan.task_definition ? '[TASKDEF=family:revision]' : '[TAGS="web=20260101 worker=20260101"]'}
350
542
  RDS_STACK = #{plan.rds_stack}
351
543
  BOX_STACK = #{plan.box_stack}
544
+ # true makes a throwaway pair: the database is deleted with its stack and the box has no
545
+ # Elastic IP. The default is a production pair, with deletion protection.
546
+ REHEARSAL ?= false
352
547
 
353
548
  .PHONY: stacks deploy
354
549
  stacks:
355
550
  \taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\
356
- \t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS)
551
+ \t\t--parameter-overrides VpcId=$(VPC) PrivateSubnetIds=$(PRIVATE_SUBNETS) Rehearsal=$(REHEARSAL)
357
552
  \taws cloudformation deploy --template-file box.yaml --stack-name $(BOX_STACK) --capabilities CAPABILITY_IAM \\
358
553
  \t\t--parameter-overrides VpcId=$(VPC) SubnetId=$(PUBLIC_SUBNET) \\
359
554
  \t\tDbSecurityGroupId=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='DbSecurityGroupId'].OutputValue" --output text) \\
360
555
  \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)
556
+ \t\tAlertTopicArn=$$(aws cloudformation describe-stacks --stack-name $(RDS_STACK) --query "Stacks[0].Outputs[?OutputKey=='AlertTopicArn'].OutputValue" --output text) \\
557
+ \t\tRehearsal=$(REHEARSAL)
362
558
 
363
559
  deploy:
364
- \tbash ./deploy-box.sh $(TAGS)
560
+ \tbash ./deploy-box.sh $(#{plan.task_definition ? 'TASKDEF' : 'TAGS'})
365
561
  MAKE
366
562
  end
367
563
 
@@ -8,11 +8,15 @@
8
8
  # machine. It replaces any path to a working copy: ROOT below points the
9
9
  # Makefile's build and mint-era steps at it. Override HECKS_VERSION to build
10
10
  # from another release, or HECKS_ROOT to use a checkout you manage yourself.
11
+ #
12
+ # HECKS_VERSION=edge is the one exception to "pinned": it follows the newest commit on main,
13
+ # is fetched afresh on every build, and prints the commit it got.
11
14
 
12
15
  HECKS_VERSION ?= @@RELEASE@@
13
16
  HECKS_SOURCE ?= @@SOURCE@@
14
17
  HECKS_CACHE_DIR ?= @@CACHE_DIR@@
15
18
  HECKS_ROOT ?= $(HECKS_CACHE_DIR)/hecks-$(HECKS_VERSION)
19
+ HECKS_TAG := $(if $(filter edge,$(HECKS_VERSION)),edge,v$(HECKS_VERSION))
16
20
  ROOT := $(HECKS_ROOT)
17
21
  SERVICE ?= @@SERVICE@@
18
22
  URL ?= @@URL@@
@@ -22,20 +26,32 @@ URL ?= @@URL@@
22
26
  build: hecks-release
23
27
 
24
28
  # Fetches the pinned release's tag into HECKS_ROOT unless it is already there,
25
- # and refuses a checkout that is not exactly that tag.
29
+ # and refuses a checkout that is not exactly that tag. The edge tag moves, so it is
30
+ # fetched afresh on every build and its commit is printed, not checked.
26
31
  hecks-release:
32
+ ifeq ($(HECKS_VERSION),edge)
33
+ @mkdir -p "$(HECKS_CACHE_DIR)"; \
34
+ if [ -d "$(HECKS_ROOT)/.git" ]; then \
35
+ git -C "$(HECKS_ROOT)" fetch --quiet --depth 1 origin "+refs/tags/edge:refs/tags/edge" && \
36
+ git -C "$(HECKS_ROOT)" checkout --quiet --detach edge; \
37
+ else \
38
+ git clone --quiet --depth 1 --branch edge "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
39
+ fi; \
40
+ echo "hecks edge at $$(git -C "$(HECKS_ROOT)" rev-parse --short HEAD) in $(HECKS_ROOT)"
41
+ else
27
42
  @if [ -f "$(HECKS_ROOT)/exe/hecks" ]; then \
28
43
  echo "hecks v$(HECKS_VERSION) is already at $(HECKS_ROOT)"; \
29
44
  else \
30
45
  echo "Fetching hecks v$(HECKS_VERSION) from $(HECKS_SOURCE) into $(HECKS_ROOT)..."; \
31
46
  mkdir -p "$(HECKS_CACHE_DIR)" && \
32
- git clone --quiet --depth 1 --branch "v$(HECKS_VERSION)" "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
47
+ git clone --quiet --depth 1 --branch "$(HECKS_TAG)" "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
33
48
  fi
34
49
  @tag="$$(git -C "$(HECKS_ROOT)" describe --tags --exact-match 2>/dev/null)"; \
35
- if [ "$$tag" != "v$(HECKS_VERSION)" ]; then \
36
- echo "$(HECKS_ROOT) is at '$$tag', not v$(HECKS_VERSION). Remove it and re-run, or set HECKS_ROOT."; \
50
+ if [ "$$tag" != "$(HECKS_TAG)" ]; then \
51
+ echo "$(HECKS_ROOT) is at '$$tag', not $(HECKS_TAG). Remove it and re-run, or set HECKS_ROOT."; \
37
52
  exit 1; \
38
53
  fi
54
+ endif
39
55
 
40
56
  # Pushes one container's already-built local image under a fresh tag and rolls
41
57
  # it out. Build the image first (docker-build); see deploy-service.sh.
@@ -12,6 +12,8 @@ module Hecks
12
12
  REF = %r{\A[\w./-]+\z}
13
13
  ERA = /\A[0-9a-zA-Z._-]+\z/
14
14
  RELEASE = /\Av?\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?\z/
15
+ # The moving tag on main; the one release name that is not a version.
16
+ EDGE = "edge".freeze
15
17
  SOURCE = %r{\A[\w.@:/~-]+\z}
16
18
  MAKE_PATH = /\A[^\s#]+\z/
17
19
 
@@ -43,6 +45,8 @@ module Hecks
43
45
  release = fetch(:hecks_release, nil)
44
46
  raise ArgumentError, missing_release_message unless release
45
47
 
48
+ return EDGE if release.to_s == EDGE
49
+
46
50
  check(:hecks_release, release.to_s, RELEASE).delete_prefix("v")
47
51
  end
48
52
 
@@ -63,6 +67,8 @@ module Hecks
63
67
  end
64
68
 
65
69
  so the domain image is built from a fixed Hecks release, not from a checkout on one machine.
70
+ `hecks_release "edge"` follows the newest commit on main instead: each build fetches it
71
+ afresh and prints the commit it got, so a build is not reproducible from the name alone.
66
72
  MSG
67
73
  end
68
74