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.
- 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 +87 -4
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +18 -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 +216 -20
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
- data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
- 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/commands.rs +4 -32
- data/rust/codegen/src/json_codec.rs +3 -0
- data/rust/codegen/src/mutations.rs +74 -5
- data/rust/codegen/src/naming.rs +4 -0
- data/rust/codegen/src/types.rs +45 -0
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/web/registration_receipt/tests.rs +39 -0
- data/rust/host/src/web/registration_receipt.rs +122 -0
- data/rust/host/src/web/registrations.rs +14 -2
- data/rust/host/src/web.rs +1 -0
- 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" =>
|
|
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), "
|
|
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
|
-
|
|
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\
|
|
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
|
|
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 "
|
|
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" != "
|
|
36
|
-
echo "$(HECKS_ROOT) is at '$$tag', not
|
|
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
|
|