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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
  4. data/lib/hecks/deploy/oidc.json +225 -0
  5. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  6. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  7. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
  8. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  9. data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
  10. data/lib/hecks/hecks/custodian.bluebook +12 -2
  11. data/lib/hecks/hecks/hecks.hecksagon +57 -0
  12. data/lib/hecks/hecks/hecks.world +5 -1
  13. data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
  14. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  15. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  16. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  17. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  18. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  19. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  20. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
  21. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  22. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  23. data/lib/hecks/projections/deploy/box.rb +8 -14
  24. data/lib/hecks/projections/site/admin.rb +140 -0
  25. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  26. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  27. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  28. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  29. data/lib/hecks/projections/site/payload/model.rb +128 -0
  30. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  31. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  32. data/lib/hecks/projections/site/root_rows.rb +59 -0
  33. data/lib/hecks/projections/site/site_admin.rb +292 -0
  34. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  35. data/lib/hecks/projections/site/site_host.rb +58 -0
  36. data/lib/hecks/projections/site/site_root.rb +179 -0
  37. data/lib/hecks/site/bluebook/site.bluebook +7 -1
  38. data/lib/hecks/tools/site_routes.rb +78 -8
  39. data/lib/hecks/version.rb +1 -1
  40. data/rust/host/HECKS_RELEASE +1 -1
  41. 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 1; }
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 1; }
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 1; }
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 1; }
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 1; }
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 1; }
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 1
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 1
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 1 ;;
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 1
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 1
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 1
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 1
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 1 ;;
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
- # Build the image first; see deploy-service.sh.
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
- bash ./deploy-service.sh $(SERVICE)
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 1; }
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 1; fi
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 1; fi
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 1
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: createdb hecks, or set HECKS_DATABASE to a Postgres URL" >&2; \\',
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
- \tbash ./deploy-box.sh $(#{plan.task_definition ? 'TASKDEF' : 'TAGS'})
596
+ #{RollRecipe.deploy(plan)}
592
597
  MAKE
593
- "#{base}#{hosting_makefile_tail(plan)}"
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