hecks 3.4.2 → 3.5.0
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/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
- data/lib/hecks/deploy/oidc.json +225 -0
- data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
- data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
- data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
- data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
- data/lib/hecks/hecks/custodian.bluebook +12 -2
- data/lib/hecks/hecks/hecks.hecksagon +57 -0
- data/lib/hecks/hecks/hecks.world +5 -1
- data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
- data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
- data/lib/hecks/projections/deploy/box.rb +8 -14
- data/lib/hecks/projections/site/admin.rb +140 -0
- data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
- data/lib/hecks/projections/site/admin_cms.rb +72 -0
- data/lib/hecks/projections/site/payload/fields.rb +234 -0
- data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
- data/lib/hecks/projections/site/payload/model.rb +128 -0
- data/lib/hecks/projections/site/payload/specs.rb +179 -0
- data/lib/hecks/projections/site/payload_driver.rb +93 -0
- data/lib/hecks/projections/site/root_rows.rb +59 -0
- data/lib/hecks/projections/site/site_admin.rb +292 -0
- data/lib/hecks/projections/site/site_host/templates.rb +165 -0
- data/lib/hecks/projections/site/site_host.rb +58 -0
- data/lib/hecks/projections/site/site_root.rb +179 -0
- data/lib/hecks/site/bluebook/site.bluebook +7 -1
- data/lib/hecks/tools/site_routes.rb +78 -8
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- metadata +17 -1
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
# Roll @@STACK@@'s app box: render the compose files, push them over SSM, start the containers and check
|
|
3
3
|
# them. Exits non-zero if the roll fails or the box does not answer as expected.
|
|
4
4
|
#
|
|
5
|
+
# Exit codes (the ones `hecks deploy box_roll.run` names; a step that fails under `set -e` ends with its
|
|
6
|
+
# own status, 1 from bash and 254/255 from the aws CLI):
|
|
7
|
+
# 0 the roll succeeded and the box answered healthy
|
|
8
|
+
# 40 the box stack has no instance
|
|
9
|
+
# 41 the roll did not succeed on the box
|
|
10
|
+
# 42 the roll succeeded but the box is not healthy after it
|
|
11
|
+
#
|
|
5
12
|
@@USAGE@@
|
|
6
13
|
set -euo pipefail
|
|
7
14
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
@@ -14,7 +21,7 @@ out() { aws cloudformation describe-stacks --stack-name "$1" --query "Stacks[0].
|
|
|
14
21
|
BOX=$(out "$BOX_STACK" InstanceId)
|
|
15
22
|
DB_HOST=$(out "$RDS_STACK" DbEndpoint)
|
|
16
23
|
DB_SECRET=$(out "$RDS_STACK" DbSecretArn)
|
|
17
|
-
[ -n "$BOX" ] && [ "$BOX" != None ] || { echo "no instance in stack $BOX_STACK" >&2; exit
|
|
24
|
+
[ -n "$BOX" ] && [ "$BOX" != None ] || { echo "no instance in stack $BOX_STACK" >&2; exit 40; }
|
|
18
25
|
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
|
|
19
26
|
|
|
20
27
|
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
|
|
@@ -68,7 +75,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
|
|
|
68
75
|
executionTimeout: ["900"]}')
|
|
69
76
|
|
|
70
77
|
echo "==> rolling $BOX_STACK ($BOX) at $(date -u +%H:%M:%SZ)"
|
|
71
|
-
run_on_box "$ROLL" || { echo "==> the roll did not succeed" >&2; exit
|
|
78
|
+
run_on_box "$ROLL" || { echo "==> the roll did not succeed" >&2; exit 41; }
|
|
72
79
|
|
|
73
80
|
echo "==> checking the box"
|
|
74
81
|
CHECK=$(cat <<'CHECK_EOF'
|
|
@@ -81,5 +88,5 @@ if [ -z "$DOWN" ]; then echo "ok every container is up"; else echo "FAIL conta
|
|
|
81
88
|
CHECK_EOF
|
|
82
89
|
)
|
|
83
90
|
CHECK_JSON=$(jq -n --arg c "$(printf '%s' "$CHECK" | base64 | tr -d '\n')" '{commands: ["echo \($c) | base64 -d | bash"]}')
|
|
84
|
-
run_on_box "$CHECK_JSON" || { echo "==> the box is NOT healthy after the roll" >&2; exit
|
|
91
|
+
run_on_box "$CHECK_JSON" || { echo "==> the box is NOT healthy after the roll" >&2; exit 42; }
|
|
85
92
|
echo "==> box roll done ($(date -u +%H:%M:%SZ))"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# container runs now, read from the box's own Compose file, so rolling one service leaves the rest alone.
|
|
3
3
|
BOX_ID="$(aws cloudformation describe-stacks --stack-name "$BOX_STACK" --region "$REGION" \
|
|
4
4
|
--query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text)"
|
|
5
|
-
[ -n "$BOX_ID" ] && [ "$BOX_ID" != None ] || { echo "no instance in stack ${BOX_STACK}" >&2; exit
|
|
5
|
+
[ -n "$BOX_ID" ] && [ "$BOX_ID" != None ] || { echo "no instance in stack ${BOX_STACK}" >&2; exit 33; }
|
|
6
6
|
|
|
7
7
|
echo "==> reading the tags the box runs now"
|
|
8
8
|
READ_JSON="$(jq -n --arg c "cat ${BOX_DIR}/compose.json 2>/dev/null || echo '{}'" '{commands: [$c]}')"
|
|
@@ -14,7 +14,7 @@ for _ in $(seq 1 30); do
|
|
|
14
14
|
--query Status --output text 2>/dev/null || echo Pending)"
|
|
15
15
|
[ "$state" != InProgress ] && [ "$state" != Pending ] && break
|
|
16
16
|
done
|
|
17
|
-
[ "$state" = Success ] || { echo "could not read the box's Compose file (${state}); nothing was rolled" >&2; exit
|
|
17
|
+
[ "$state" = Success ] || { echo "could not read the box's Compose file (${state}); nothing was rolled" >&2; exit 32; }
|
|
18
18
|
aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$BOX_ID" --region "$REGION" \
|
|
19
19
|
--query StandardOutputContent --output text > "${SCRATCH_DIR}/compose.json"
|
|
20
20
|
|
|
@@ -14,13 +14,24 @@
|
|
|
14
14
|
@@MODE_SUMMARY@@
|
|
15
15
|
#
|
|
16
16
|
# It ends with smoke-after-deploy.sh, whose own exit codes (20-23) are distinct from a failed deploy
|
|
17
|
-
# step's. SKIP_POST_DEPLOY_SMOKE=1 skips it.
|
|
17
|
+
# step's. SKIP_POST_DEPLOY_SMOKE=1 skips it. `hecks deploy service_roll.run` runs this script with
|
|
18
|
+
# SMOKE_BY_COMMAND=1, which ends after the roll and leaves the smoke to the smoke_run.run its policy requests.
|
|
19
|
+
#
|
|
20
|
+
# Exit codes (the ones `service_roll.run` names; a step that fails under `set -e` ends with its own
|
|
21
|
+
# status, 1 from bash and 254/255 from the aws CLI):
|
|
22
|
+
# 2 unknown service
|
|
23
|
+
# 30 EXISTING_TAG is not in ECR 31 the fresh tag is already in ECR
|
|
24
|
+
# 32 the box's Compose file is unreadable 33 the box stack has no instance
|
|
25
|
+
# 34 the stack has no parameter for the container 35 the stack update failed or did not settle
|
|
26
|
+
# 36 a parameter other than the container's changed 37 the task definition lacks the pushed image
|
|
27
|
+
# 38 the task definition has no such container 40-42 deploy-box.sh's own
|
|
18
28
|
#
|
|
19
29
|
# Environment:
|
|
20
30
|
# LOCAL_IMAGE local image to push, default <ecr repository>:latest
|
|
21
31
|
# EXISTING_TAG redeploy this tag from ECR instead of building and pushing
|
|
22
32
|
# SETTLE_TIMEOUT_SECS default 1200, how long to wait for a stack update
|
|
23
33
|
# SKIP_POST_DEPLOY_SMOKE=1 passed through to smoke-after-deploy.sh
|
|
34
|
+
# SMOKE_BY_COMMAND=1 end after the roll and print `rolled taskdef=<td> tag=<tag>`; the caller smokes
|
|
24
35
|
set -euo pipefail
|
|
25
36
|
|
|
26
37
|
@@SETTINGS@@
|
|
@@ -49,12 +60,12 @@ if [ -n "${EXISTING_TAG:-}" ]; then
|
|
|
49
60
|
echo "==> reusing ${IMAGE_URI} (no push)"
|
|
50
61
|
aws ecr describe-images --repository-name "$ECR_REPOSITORY" --region "$REGION" \
|
|
51
62
|
--image-ids "imageTag=${TAG}" --query 'imageDetails[0].imageTags' --output text > /dev/null \
|
|
52
|
-
|| { echo "tag ${TAG} is not in ECR" >&2; exit
|
|
63
|
+
|| { echo "tag ${TAG} is not in ECR" >&2; exit 30; }
|
|
53
64
|
else
|
|
54
65
|
if aws ecr describe-images --repository-name "$ECR_REPOSITORY" --region "$REGION" \
|
|
55
66
|
--image-ids "imageTag=${TAG}" > /dev/null 2>&1; then
|
|
56
67
|
echo "tag ${TAG} is already in ECR; a tag is never reused. Run again." >&2
|
|
57
|
-
exit
|
|
68
|
+
exit 31
|
|
58
69
|
fi
|
|
59
70
|
echo "==> tagging ${LOCAL_IMAGE} as ${IMAGE_URI}"
|
|
60
71
|
docker tag "$LOCAL_IMAGE" "$IMAGE_URI"
|
|
@@ -74,6 +85,7 @@ fi
|
|
|
74
85
|
|
|
75
86
|
@@ROLL_STEPS@@
|
|
76
87
|
|
|
77
|
-
# The deploy itself is done. End with the smoke, which reports separately.
|
|
88
|
+
# The deploy itself is done. End with the smoke, which reports separately, unless the caller runs it.
|
|
78
89
|
echo "==> deploy succeeded"
|
|
90
|
+
[ -z "${SMOKE_BY_COMMAND:-}" ] || { echo "==> rolled taskdef=${TASKDEF:-} tag=${TAG}"; exit 0; }
|
|
79
91
|
bash ./smoke-after-deploy.sh
|
|
@@ -17,7 +17,7 @@ for key in $ALL_PARAM_KEYS; do
|
|
|
17
17
|
done
|
|
18
18
|
if [ "$found" != "1" ]; then
|
|
19
19
|
echo "stack ${IMAGE_STACK} has no parameter named ${CFN_PARAM_KEY}; the image was pushed but nothing was rolled" >&2
|
|
20
|
-
exit
|
|
20
|
+
exit 34
|
|
21
21
|
fi
|
|
22
22
|
|
|
23
23
|
if UPDATE_OUT="$(aws cloudformation update-stack --stack-name "$IMAGE_STACK" --region "$REGION" \
|
|
@@ -32,11 +32,11 @@ if UPDATE_OUT="$(aws cloudformation update-stack --stack-name "$IMAGE_STACK" --r
|
|
|
32
32
|
UPDATE_COMPLETE) echo " -> ${stack_status}"; break ;;
|
|
33
33
|
*ROLLBACK*|*FAILED*)
|
|
34
34
|
echo " -> ${stack_status}: the stack update failed, investigate before rolling anything" >&2
|
|
35
|
-
exit
|
|
35
|
+
exit 35 ;;
|
|
36
36
|
esac
|
|
37
37
|
if timed_out "$started"; then
|
|
38
38
|
echo "==> the stack update did not settle within ${SETTLE_TIMEOUT_SECS}s (last: ${stack_status})" >&2
|
|
39
|
-
exit
|
|
39
|
+
exit 35
|
|
40
40
|
fi
|
|
41
41
|
sleep 10
|
|
42
42
|
done
|
|
@@ -44,7 +44,7 @@ elif echo "$UPDATE_OUT" | grep -q "No updates are to be performed"; then
|
|
|
44
44
|
echo " -> the stack already has ${TAG}; nothing to update"
|
|
45
45
|
else
|
|
46
46
|
echo "$UPDATE_OUT" >&2
|
|
47
|
-
exit
|
|
47
|
+
exit 35
|
|
48
48
|
fi
|
|
49
49
|
|
|
50
50
|
echo "==> checking that only ${CFN_PARAM_KEY} changed"
|
|
@@ -56,7 +56,7 @@ CHANGED="$(jq -rn --slurpfile before "${SCRATCH_DIR}/params-before.json" --slurp
|
|
|
56
56
|
NOW="$(jq -r --arg k "$CFN_PARAM_KEY" '.[] | select(.k == $k) | .v' "${SCRATCH_DIR}/params-after.json")"
|
|
57
57
|
if [ "$NOW" != "$TAG" ] || { [ -n "$CHANGED" ] && [ "$CHANGED" != "$CFN_PARAM_KEY" ]; }; then
|
|
58
58
|
echo "expected only ${CFN_PARAM_KEY} to change to ${TAG}; it is ${NOW} and changed: ${CHANGED:-nothing}" >&2
|
|
59
|
-
exit
|
|
59
|
+
exit 36
|
|
60
60
|
fi
|
|
61
61
|
echo " -> ${CFN_PARAM_KEY} is ${TAG}; no other parameter changed"
|
|
62
62
|
|
|
@@ -69,7 +69,7 @@ RUNNING_IMAGE="$(aws ecs describe-task-definition --task-definition "$TD" --regi
|
|
|
69
69
|
--query "taskDefinition.containerDefinitions[?name=='${SERVICE_NAME}'].image | [0]" --output text)"
|
|
70
70
|
if [ "$RUNNING_IMAGE" != "$IMAGE_URI" ]; then
|
|
71
71
|
echo "task definition ${TD} has ${RUNNING_IMAGE}, not ${IMAGE_URI}; refusing to roll" >&2
|
|
72
|
-
exit
|
|
72
|
+
exit 37
|
|
73
73
|
fi
|
|
74
74
|
echo "==> task definition ${TD} carries ${SERVICE_NAME}=${TAG}"
|
|
75
75
|
|
|
@@ -6,7 +6,7 @@ active_image() {
|
|
|
6
6
|
}
|
|
7
7
|
CURRENT_IMAGE="$(active_image)"
|
|
8
8
|
case "$CURRENT_IMAGE" in
|
|
9
|
-
None|"") echo "task definition family ${FAMILY} has no container named ${SERVICE_NAME}" >&2; exit
|
|
9
|
+
None|"") echo "task definition family ${FAMILY} has no container named ${SERVICE_NAME}" >&2; exit 38 ;;
|
|
10
10
|
esac
|
|
11
11
|
ECR_REPOSITORY="${CURRENT_IMAGE#*/}"
|
|
12
12
|
ECR_REPOSITORY="${ECR_REPOSITORY%%[:@]*}"
|
|
@@ -8,10 +8,13 @@ URL ?= @@URL@@
|
|
|
8
8
|
|
|
9
9
|
.PHONY: deploy-service smoke-after-deploy check-era
|
|
10
10
|
|
|
11
|
-
# Pushes one container's already-built local image under a fresh tag and rolls it onto the box
|
|
12
|
-
#
|
|
11
|
+
# Pushes one container's already-built local image under a fresh tag and rolls it onto the box, as the
|
|
12
|
+
# service_roll.run command: it runs deploy-service.sh, records the roll in the Hecks database
|
|
13
|
+
# (HECKS_DATABASE, default postgres://hecks@localhost/hecks) and requests the smoke. Build the image
|
|
14
|
+
# first; see deploy-service.sh. EXISTING_TAG=<tag> redeploys a tag already in ECR. Without the
|
|
15
|
+
# database the script runs by itself and the target exits non-zero (24 if it all passed).
|
|
13
16
|
deploy-service:
|
|
14
|
-
|
|
17
|
+
@@SERVICE_RECIPE@@
|
|
15
18
|
|
|
16
19
|
# Waits for the last roll to settle, then dispatches and follows the smoke, as the smoke_run.run
|
|
17
20
|
# command, which runs smoke-after-deploy.sh and records how it ended in the Hecks database
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
# direction (swap the hosts and secrets, and SRC_DB and DST_DB)
|
|
10
10
|
# SRC_DB the source database (default @@SOURCE_DATABASE@@)
|
|
11
11
|
# DST_DB the target database (default @@DATABASE@@)
|
|
12
|
+
# VERIFY_BY_COMMAND=1 end after the copy and leave the comparison to `hecks deploy data_copy.restore`
|
|
13
|
+
#
|
|
14
|
+
# Ends 60 (a client older than 16), 61 (the target already has a schema), 62 (unexpected restore
|
|
15
|
+
# errors), or, run by hand, verify-copy.sh's 50 (the databases differ).
|
|
12
16
|
#
|
|
13
17
|
# Schemas copied: @@SCHEMAS@@
|
|
14
18
|
#
|
|
@@ -34,7 +38,7 @@ if [ "$(pg_major pg_dump || echo 0)" -lt 16 ] && [ -x /opt/homebrew/opt/postgres
|
|
|
34
38
|
export PATH=/opt/homebrew/opt/postgresql@16/bin:$PATH
|
|
35
39
|
fi
|
|
36
40
|
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
|
|
41
|
+
[ "$(pg_major $t || echo 0)" -ge 16 ] || { echo "need $t 16 or newer on the PATH" >&2; exit 60; }
|
|
38
42
|
done
|
|
39
43
|
|
|
40
44
|
WORK=$(mktemp -d)
|
|
@@ -66,7 +70,7 @@ echo "source $(SRC 'show server_version'), target $(DST 'show server_version')"
|
|
|
66
70
|
for S in $SCHEMAS; do
|
|
67
71
|
if [ "$(DST "select count(*) from pg_namespace where nspname='$S'")" != 0 ]; then
|
|
68
72
|
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
|
|
73
|
+
else echo "target already has schema $S; re-run with FORCE=1 to replace it" >&2; exit 61; fi
|
|
70
74
|
fi
|
|
71
75
|
done
|
|
72
76
|
|
|
@@ -77,7 +81,7 @@ for S in $SCHEMAS; do
|
|
|
77
81
|
TOTAL=$(grep -c 'error:' "$WORK/err_$S.txt" || true)
|
|
78
82
|
UNEXPECTED=$(grep 'error:' "$WORK/err_$S.txt" | grep -vc 'hecks_tr_extract' || true)
|
|
79
83
|
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
|
|
84
|
+
if [ "$UNEXPECTED" != 0 ]; then grep 'error:' "$WORK/err_$S.txt" | grep -v 'hecks_tr_extract' | head -5 >&2; exit 62; fi
|
|
81
85
|
done
|
|
82
86
|
|
|
83
87
|
# Refresh what pg_restore could not (with the schema on the search_path).
|
|
@@ -88,4 +92,5 @@ for S in $SCHEMAS; do
|
|
|
88
92
|
done
|
|
89
93
|
|
|
90
94
|
# Same structure, same exact row counts in every table, nothing unpopulated.
|
|
95
|
+
[ -z "${VERIFY_BY_COMMAND:-}" ] || exit 0
|
|
91
96
|
A_DB=$SRC_DB B_DB=$DST_DB bash "$HERE/verify-copy.sh" "$BASTION" "$SRC_HOST" "$SRC_SECRET" "$DST_HOST" "$DST_SECRET"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# A_DB, B_DB the database on each side (default @@DATABASE@@)
|
|
9
9
|
#
|
|
10
10
|
# Schemas compared: @@SCHEMAS@@
|
|
11
|
-
# Exit 0 only if both are identical and no materialized view is unpopulated in either.
|
|
11
|
+
# Exit 0 only if both are identical and no materialized view is unpopulated in either; 50 when they differ.
|
|
12
12
|
set -euo pipefail
|
|
13
13
|
|
|
14
14
|
BASTION=${1:?bastion instance id}; A_HOST=${2:?}; A_SECRET=${3:?}; B_HOST=${4:?}; B_SECRET=${5:?}
|
|
@@ -54,5 +54,5 @@ A "$COUNTS" >"$WORK/counts_a"; B "$COUNTS" >"$WORK/counts_b"
|
|
|
54
54
|
[ "$(A "$UNPOP")" = 0 ] && [ "$(B "$UNPOP")" = 0 ] || { echo "FAIL: unpopulated materialized views" >&2; BAD=1; }
|
|
55
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
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
|
|
57
|
+
[ "$BAD" = 0 ] || exit 50
|
|
58
58
|
echo "OK: $(wc -l <"$WORK/counts_a" | tr -d ' ') tables, identical row counts and structure"
|
|
@@ -2,6 +2,7 @@ require "json"
|
|
|
2
2
|
require_relative "../../projector"
|
|
3
3
|
require_relative "box/settings"
|
|
4
4
|
require_relative "box/hosting"
|
|
5
|
+
require_relative "box/roll_recipe"
|
|
5
6
|
|
|
6
7
|
module Hecks
|
|
7
8
|
module Projections
|
|
@@ -38,7 +39,9 @@ module Hecks
|
|
|
38
39
|
'$(HECKS) deploy smoke_run.run project="$(CURDIR)" --wait $(if $(TASKDEF),taskdef=$(TASKDEF)) 2>"$$err"; rc=$$?; \\',
|
|
39
40
|
'if [ $$rc -ne 0 ] && grep -q \'^cannot open Hecks\' "$$err"; then \\',
|
|
40
41
|
' echo "==> the deploy record was NOT written: $$(grep -m1 \'^cannot open Hecks\' "$$err")" >&2; \\',
|
|
41
|
-
' echo " one-time setup on this machine:
|
|
42
|
+
' echo " one-time setup on this machine: a database AND a non-superuser role that owns it" >&2; \\',
|
|
43
|
+
' echo " (a superuser skips the era write-fence, so createdb alone fails), then set" >&2; \\',
|
|
44
|
+
' echo " HECKS_DATABASE=postgres://<role>@localhost/<db>" >&2; \\',
|
|
42
45
|
' echo " running the smoke anyway so its result is not lost" >&2; \\',
|
|
43
46
|
' rm -f "$$err"; TASKDEF="$(TASKDEF)" bash ./smoke-after-deploy.sh; rc=$$?; \\',
|
|
44
47
|
' echo "==> smoke exit $$rc. The deploy record was NOT written: the database is unavailable (see above)." >&2; \\',
|
|
@@ -576,6 +579,8 @@ module Hecks
|
|
|
576
579
|
# Elastic IP. The default is a production pair, with deletion protection.
|
|
577
580
|
REHEARSAL ?= false
|
|
578
581
|
|
|
582
|
+
HECKS ?= hecks
|
|
583
|
+
|
|
579
584
|
.PHONY: stacks deploy
|
|
580
585
|
stacks:
|
|
581
586
|
\taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\
|
|
@@ -588,20 +593,9 @@ module Hecks
|
|
|
588
593
|
\t\tRehearsal=$(REHEARSAL)
|
|
589
594
|
|
|
590
595
|
deploy:
|
|
591
|
-
|
|
596
|
+
#{RollRecipe.deploy(plan)}
|
|
592
597
|
MAKE
|
|
593
|
-
"#{base}
|
|
594
|
-
end
|
|
595
|
-
|
|
596
|
-
# With hosting scripts, a deploy ends with the post-deploy smoke and the Makefile includes
|
|
597
|
-
# the fragment that adds the hosting targets.
|
|
598
|
-
#
|
|
599
|
-
# @param plan [Settings::Plan] the resolved settings
|
|
600
|
-
# @return [String] the Makefile lines that follow the `deploy` recipe, or nothing
|
|
601
|
-
def hosting_makefile_tail(plan)
|
|
602
|
-
return "" unless plan.hosting
|
|
603
|
-
|
|
604
|
-
"\t$(MAKE) smoke-after-deploy\n\ninclude hosting.mk\n"
|
|
598
|
+
plan.hosting ? "#{base}\ninclude hosting.mk\n" : base
|
|
605
599
|
end
|
|
606
600
|
|
|
607
601
|
# Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "table"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Site
|
|
8
|
+
# What a site's route table cannot say about who may use its admin pages: the session cookie,
|
|
9
|
+
# the host that vouches for it, the roles that count as an admin, and the two routes the
|
|
10
|
+
# sign-in is built from.
|
|
11
|
+
#
|
|
12
|
+
# A project declares one `member` row of a `value_object "Admin"` in the chapter that holds
|
|
13
|
+
# its route table. `docs/site-routes.md` lists the fields. A project that declares none has
|
|
14
|
+
# no admin sign-in to project.
|
|
15
|
+
class Admin
|
|
16
|
+
Setting = Struct.new(:session_cookie, :session_max_age, :host_env, :host_default, :roles,
|
|
17
|
+
:account_path, :members_path, :sso_token_path, :sso_target, :login,
|
|
18
|
+
:sso, :verdict_ttl_ms, :timeout_ms, :cms_base, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
# The value object a project declares its admin row in.
|
|
21
|
+
OBJECT = "Admin"
|
|
22
|
+
|
|
23
|
+
# The fields the row may carry, with the Ruby class each takes.
|
|
24
|
+
FIELDS = {
|
|
25
|
+
session_cookie: String, session_max_age: Integer, host_env: String, host_default: String,
|
|
26
|
+
roles: String, account_path: String, members_path: String, sso_token_path: String,
|
|
27
|
+
sso_target: String, login: String, sso: String, verdict_ttl_ms: Integer, timeout_ms: Integer,
|
|
28
|
+
cms_base: String
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# The fields the row must carry.
|
|
32
|
+
REQUIRED = %i[session_cookie host_env login sso].freeze
|
|
33
|
+
|
|
34
|
+
# What a field takes when the row does not carry it.
|
|
35
|
+
DEFAULTS = {
|
|
36
|
+
session_max_age: 14 * 24 * 60 * 60, host_default: "http://127.0.0.1:4322",
|
|
37
|
+
roles: "Admin,Owner", account_path: "/accounts/me", members_path: "/members",
|
|
38
|
+
sso_token_path: "/accounts/sso-token", sso_target: "/cms/api/sso",
|
|
39
|
+
verdict_ttl_ms: 10_000, timeout_ms: 5_000, cms_base: "/cms"
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# The fields that name a path on the host or the content system, which start with a slash.
|
|
43
|
+
PATH_FIELDS = %i[account_path members_path sso_token_path sso_target cms_base].freeze
|
|
44
|
+
|
|
45
|
+
# @return [Setting] the checked row, defaults filled
|
|
46
|
+
attr_reader :setting
|
|
47
|
+
|
|
48
|
+
# Reads and checks the admin row a chapter declares.
|
|
49
|
+
#
|
|
50
|
+
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
51
|
+
# @param table [Table] the checked route table
|
|
52
|
+
# @return [Admin, nil] the checked setting, or nil when the chapter declares none
|
|
53
|
+
# @raise [Table::Invalid] when the row is refused or contradicts the routes
|
|
54
|
+
def self.read(chapter, table:)
|
|
55
|
+
members = Table.rows_of(chapter, OBJECT)
|
|
56
|
+
return nil if members.empty?
|
|
57
|
+
|
|
58
|
+
new(members, table: table)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param members [Array<Hash{Symbol => Object}>] the declared rows
|
|
62
|
+
# @param table [Table] the checked route table
|
|
63
|
+
# @raise [Table::Invalid] naming every problem when there is one
|
|
64
|
+
def initialize(members, table:)
|
|
65
|
+
problems = []
|
|
66
|
+
problems << "a project declares one Admin row, not #{members.size}" if members.size > 1
|
|
67
|
+
row = members.first
|
|
68
|
+
typed = typed_fields(row, problems)
|
|
69
|
+
@setting = Setting.new(**DEFAULTS, **typed)
|
|
70
|
+
check(table.rows, problems) if problems.empty?
|
|
71
|
+
return if problems.empty?
|
|
72
|
+
|
|
73
|
+
raise Table::Invalid, "the admin sign-in is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Array<String>] the roles that count as an admin
|
|
77
|
+
def roles = setting.roles.split(",").map(&:strip).reject(&:empty?)
|
|
78
|
+
|
|
79
|
+
# @return [String] the path of the content system's sign-in endpoint inside its own API, the
|
|
80
|
+
# part of `sso_target` after `<cms_base>/api`
|
|
81
|
+
def cms_endpoint = setting.sso_target.delete_prefix("#{setting.cms_base}/api")
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def typed_fields(row, problems)
|
|
86
|
+
unknown = row.keys - FIELDS.keys
|
|
87
|
+
problems << "Admin row has no field #{unknown.join(', ')}; fields are #{FIELDS.keys.join(', ')}" if unknown.any?
|
|
88
|
+
(REQUIRED - row.keys).each { |field| problems << "Admin row needs #{field}" }
|
|
89
|
+
row.slice(*FIELDS.keys).select do |field, value|
|
|
90
|
+
value.is_a?(FIELDS.fetch(field)) ||
|
|
91
|
+
(problems << "Admin row has #{field} #{value.inspect}; #{field} is a #{FIELDS.fetch(field)}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def check(rows, problems)
|
|
96
|
+
check_paths(problems)
|
|
97
|
+
check_cms_base(problems)
|
|
98
|
+
problems << "Admin roles name no role" if roles.empty?
|
|
99
|
+
check_login(rows, problems)
|
|
100
|
+
check_sso(rows, problems)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def check_paths(problems)
|
|
104
|
+
(PATH_FIELDS + %i[login sso]).each do |field|
|
|
105
|
+
value = setting[field]
|
|
106
|
+
problems << "Admin #{field} #{value.inspect} must start with a slash" unless value.start_with?("/")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The sign-in endpoint is the content system's own API route, under its base path.
|
|
111
|
+
def check_cms_base(problems)
|
|
112
|
+
return if setting.sso_target.start_with?("#{setting.cms_base}/api/")
|
|
113
|
+
|
|
114
|
+
problems << "Admin sso_target #{setting.sso_target} must be under #{setting.cms_base}/api/"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The login page is a public row: a visitor with no session must reach it.
|
|
118
|
+
def check_login(rows, problems)
|
|
119
|
+
row = rows.find { |candidate| candidate.path == setting.login }
|
|
120
|
+
if row.nil?
|
|
121
|
+
problems << "Admin login #{setting.login} is not a route of the table"
|
|
122
|
+
elsif row.auth != "public"
|
|
123
|
+
problems << "Admin login #{setting.login} is #{row.auth}; the login page must be public"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The hand-off to the content system is an admin endpoint: it answers only an admin.
|
|
128
|
+
def check_sso(rows, problems)
|
|
129
|
+
row = rows.find { |candidate| candidate.path == setting.sso }
|
|
130
|
+
if row.nil?
|
|
131
|
+
problems << "Admin sso #{setting.sso} is not a route of the table"
|
|
132
|
+
else
|
|
133
|
+
problems << "Admin sso #{setting.sso} is #{row.auth}; it must be admin" unless row.auth == "admin"
|
|
134
|
+
problems << "Admin sso #{setting.sso} is a #{row.kind}; it must be an endpoint" unless row.kind == "endpoint"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|