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.
- checksums.yaml +4 -4
- data/exe/hecks +1 -1
- data/lib/hecks/chapters.rb +4 -3
- data/lib/hecks/cli/domain_writer.rb +39 -0
- data/lib/hecks/cli/interview_agent.rb +59 -0
- data/lib/hecks/cli/interview_run.rb +87 -0
- data/lib/hecks/cli/interview_session.rb +299 -0
- data/lib/hecks/corpus.rb +1 -0
- data/lib/hecks/doors/cli_runner.rb +1 -1
- data/lib/hecks/doors/usage_cache.rb +123 -0
- data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
- data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
- data/lib/hecks/hecks/adapters/terminal.rb +15 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.bluebook +36 -0
- data/lib/hecks/hecks/hecks.bluebook +18 -0
- data/lib/hecks/hecks/hecks.hecksagon +8 -2
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/projections/deploy/box/settings.rb +80 -4
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -5
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
- data/lib/hecks/projections/deploy/box.rb +185 -19
- data/lib/hecks/projector/cli_projector.rb +38 -14
- data/lib/hecks/runtime/loader.rb +31 -8
- data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
- data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
- data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/codegen/src/json_codec.rs +3 -0
- data/rust/codegen/src/naming.rs +4 -0
- data/rust/codegen/src/types.rs +45 -0
- data/rust/host/HECKS_RELEASE +1 -1
- 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" =>
|
|
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
|
-
|
|
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\
|
|
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
|
|
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
|
|
408
|
+
out.concat(listing(commands) { |spec| spec[:summary] })
|
|
410
409
|
out << ""
|
|
411
410
|
out << "queries (nothing here changes anything):"
|
|
412
|
-
out.concat(listing(questions
|
|
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
|
|
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
|
|
423
|
-
|
|
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
|
|
431
|
+
if grouped
|
|
428
432
|
groups.each do |group, members|
|
|
429
433
|
lines << " #{heading(group)}" if group
|
|
430
|
-
members.each { |spec| lines << " #{
|
|
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 << " #{
|
|
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
|
-
#
|
|
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
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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)
|
data/lib/hecks/runtime/loader.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
registry = Registry.new(root: File.dirname(directory))
|
|
88
|
+
overlay = selected_environment(environment)
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
loading.
|
|
74
|
-
|
|
75
|
-
|
|
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
|