hecks 3.4.1 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/memory/shared_elements.rb +121 -17
  3. data/lib/hecks/deploy/bluebook/deploy.bluebook +1630 -0
  4. data/lib/hecks/deploy/bluebook/deploy.hecksagon +65 -0
  5. data/lib/hecks/deploy/oidc.json +240 -0
  6. data/lib/hecks/doors/cli_runner.rb +13 -4
  7. data/lib/hecks/doors/launcher_options.rb +14 -2
  8. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  9. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  10. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +379 -8
  11. data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
  12. data/lib/hecks/hecks/adapters/git.rb +29 -0
  13. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  14. data/lib/hecks/hecks/adapters/site_toolchain.rb +8 -8
  15. data/lib/hecks/hecks/custodian.bluebook +23 -2
  16. data/lib/hecks/hecks/hecks.hecksagon +66 -0
  17. data/lib/hecks/hecks/hecks.world +6 -1
  18. data/lib/hecks/hecks/site.world +4 -2
  19. data/lib/hecks/projections/deploy/box/hosting.rb +13 -3
  20. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  22. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  23. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  24. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  25. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  26. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +11 -5
  27. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  28. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  29. data/lib/hecks/projections/deploy/box.rb +26 -13
  30. data/lib/hecks/projections/site/admin.rb +140 -0
  31. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  32. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  33. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  34. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  35. data/lib/hecks/projections/site/payload/model.rb +128 -0
  36. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  37. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  38. data/lib/hecks/projections/site/root_rows.rb +59 -0
  39. data/lib/hecks/projections/site/site_admin.rb +292 -0
  40. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  41. data/lib/hecks/projections/site/site_host.rb +58 -0
  42. data/lib/hecks/projections/site/site_root.rb +179 -0
  43. data/lib/hecks/runtime/value.rb +7 -0
  44. data/lib/hecks/site/bluebook/site.bluebook +13 -2
  45. data/lib/hecks/tools/site_routes.rb +78 -8
  46. data/lib/hecks/version.rb +1 -1
  47. data/rust/host/HECKS_RELEASE +1 -1
  48. metadata +18 -2
@@ -105,6 +105,7 @@ Hecks.hecksagon "Hecks" do
105
105
  end
106
106
 
107
107
  answers_query "Check"
108
+ answers_query "Digest"
108
109
  end
109
110
 
110
111
  Hecks::Door.port "Workspace" do
@@ -400,6 +401,71 @@ Hecks.hecksagon "Hecks" do
400
401
  end
401
402
  end
402
403
 
404
+ Deploy::SmokeRun.port "DeployToolchain" do
405
+ asks "Smoke", to: SmokeRun do
406
+ attribute :run, RunKey
407
+ attribute :after, Origin, default: { value: "alone" }
408
+ answers "SmokeAnswered"
409
+ refuses "SmokeRefused"
410
+ end
411
+ end
412
+
413
+ Deploy::ServiceRoll.port "DeployToolchain" do
414
+ asks "RollService", to: ServiceRoll do
415
+ attribute :run, RunKey
416
+ answers "ServiceRollAnswered"
417
+ refuses "ServiceRollRefused"
418
+ end
419
+ end
420
+
421
+ Deploy::BoxRoll.port "DeployToolchain" do
422
+ asks "RollBox", to: BoxRoll do
423
+ attribute :run, RunKey
424
+ answers "BoxRollAnswered"
425
+ refuses "BoxRollRefused"
426
+ end
427
+ end
428
+
429
+ Deploy::DataCopy.port "DeployToolchain" do
430
+ asks "CopyData", to: DataCopy do
431
+ attribute :run, RunKey
432
+ answers "DataCopyAnswered"
433
+ refuses "DataCopyRefused"
434
+ end
435
+ end
436
+
437
+ Deploy::CopyVerification.port "DeployToolchain" do
438
+ asks "CompareCopy", to: CopyVerification do
439
+ attribute :run, RunKey
440
+ answers "CopyVerificationAnswered"
441
+ refuses "CopyVerificationRefused"
442
+ end
443
+ end
444
+
445
+ Deploy::BluebookDiff.port "DeployToolchain" do
446
+ asks "CompareBluebooks", to: BluebookDiff do
447
+ attribute :run, RunKey
448
+ answers "BluebookDiffAnswered"
449
+ refuses "BluebookDiffRefused"
450
+ end
451
+ end
452
+
453
+ Deploy::PreviewRun.port "DeployToolchain" do
454
+ asks "RunPreview", to: PreviewRun do
455
+ attribute :run, RunKey
456
+ answers "PreviewRunAnswered"
457
+ refuses "PreviewRunRefused"
458
+ end
459
+ end
460
+
461
+ Deploy::CompanionRoll.port "DeployToolchain" do
462
+ asks "RollCompanion", to: CompanionRoll do
463
+ attribute :run, RunKey
464
+ answers "CompanionRollAnswered"
465
+ refuses "CompanionRollRefused"
466
+ end
467
+ end
468
+
403
469
  Deploy::TemplateComparison.port "DeployToolchain" do
404
470
  asks "Compare", to: TemplateComparison do
405
471
  attribute :run, RunKey
@@ -40,9 +40,14 @@ Hecks.world "Hecks" do
40
40
  failure_states: %w[flagged failed drifted unreachable refused faulted halted stopped
41
41
  abandoned red needs_fix],
42
42
  settled: %w[package.vendor package.revendor package.release
43
- operation.bootstrap_admin],
43
+ operation.bootstrap_admin smoke_run.run
44
+ service_roll.run box_roll.run data_copy.restore
45
+ data_copy.verify bluebook_diff.run preview_run.name
46
+ preview_run.url preview_run.list preview_run.deploy
47
+ preview_run.destroy preview_run.login companion_roll.run],
44
48
  names: { "mcp" => "door.serve_mcp", "console" => "operation.open_console", "init" => "door.init",
45
49
  "interview" => "door.interview", "package.check" => "registry.check",
50
+ "package.digest" => "registry.digest",
46
51
  "package.release" => "registry.release" },
47
52
  streams: %w[Follow],
48
53
  executable: "exe/hecks",
@@ -5,9 +5,11 @@
5
5
  # `run_keys` mints the `run` key of a projection, and `--wait` reports a faulted projection (a
6
6
  # routes.ts out of date under `--check`, or a route table the projector refused) as exit 1, and
7
7
  # `settled` makes `site_projection.check_live` wait for its reactions without the flag, so a live
8
- # distribution that differs from the project is an exit status.
8
+ # distribution that differs from the project is an exit status. `report` prints that command's
9
+ # answer as the report alone: the differences, or the line saying the distribution matches.
9
10
  Hecks.world "Site" do
10
11
  launcher "Launcher", run_keys: true,
11
12
  failure_states: %w[faulted refused halted stopped abandoned red needs_fix],
12
- settled: %w[site_projection.check_live]
13
+ settled: %w[site_projection.check_live],
14
+ report: %w[site_projection.check_live]
13
15
  end
@@ -33,8 +33,10 @@ module Hecks
33
33
  # @param plan [Settings::Plan] the resolved settings
34
34
  # @return [String] the Makefile fragment the box `Makefile` includes
35
35
  def hosting_mk(plan)
36
- Box.template("hosting.mk.tmpl", "SERVICE" => plan.containers.first.name,
37
- "URL" => plan.hosting.public_url.to_s)
36
+ Box.template("hosting.mk.tmpl", "SERVICE" => plan.containers.first.name,
37
+ "URL" => plan.hosting.public_url.to_s,
38
+ "SMOKE_RECIPE" => Box::SMOKE_RECIPE,
39
+ "SERVICE_RECIPE" => service_recipe)
38
40
  end
39
41
 
40
42
  # @param plan [Settings::Plan] the resolved settings
@@ -49,6 +51,14 @@ module Hecks
49
51
  "ROLL_STEPS" => part(roll))
50
52
  end
51
53
 
54
+ # @return [String] the `deploy-service` recipe: the `service_roll.run` command
55
+ def service_recipe
56
+ args = "service=$(SERVICE) $(if $(EXISTING_TAG),existing_tag=$(EXISTING_TAG)) " \
57
+ "$(if $(LOCAL_IMAGE),local_image=$(LOCAL_IMAGE))"
58
+ direct = "bash ./deploy-service.sh $(SERVICE)"
59
+ Box::RollRecipe.call(command: "service_roll.run", args: args, direct: direct)
60
+ end
61
+
52
62
  # @param plan [Settings::Plan] the resolved settings
53
63
  # @param region [String] the validated AWS region
54
64
  # @return [String] `smoke-after-deploy.sh`
@@ -147,7 +157,7 @@ module Hecks
147
157
  resolve_service() {
148
158
  case "$1" in
149
159
  #{arms.join("\n")}
150
- *) echo "unknown service '$1', must be one of: ${SERVICES}" >&2; exit 1 ;;
160
+ *) echo "unknown service '$1', must be one of: ${SERVICES}" >&2; exit 2 ;;
151
161
  esac
152
162
  }
153
163
  BASH
@@ -0,0 +1,62 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Box
5
+ # The Makefile recipes that run a roll as `box_roll.run` or `service_roll.run`.
6
+ #
7
+ # The command rolls with the generated script and, through its policy, requests the
8
+ # smoke, so one deploy leaves a `BoxRoll` or `ServiceRoll` (which also records the smoke's
9
+ # outcome) and a `SmokeRun` in the Hecks database. A failed roll or smoke is the command's
10
+ # exit 1, with the script's status in its message. With no database the command never
11
+ # starts, so the recipe runs the script itself, states the database error apart from the
12
+ # deploy's result, and fails (`Error 24` if all passed).
13
+ module RollRecipe
14
+ module_function
15
+
16
+ # @param plan [Settings::Plan] the resolved settings
17
+ # @return [String] the `deploy` target's recipe, tab-indented: the `box_roll.run` command
18
+ def deploy(plan)
19
+ variable = plan.task_definition ? "TASKDEF" : "TAGS"
20
+ args = plan.task_definition ? "$(if $(TASKDEF),taskdef=$(TASKDEF))" : "$(if $(TAGS),tags=\"$(TAGS)\")"
21
+ direct = "bash ./deploy-box.sh $(#{variable})"
22
+ direct += " || exit $$?; TASKDEF=\"$(TASKDEF)\" bash ./smoke-after-deploy.sh" if plan.hosting
23
+ "\t#{call(command: 'box_roll.run', args: args, direct: direct)}"
24
+ end
25
+
26
+ # @param command [String] the Deploy command, such as `box_roll.run`
27
+ # @param args [String] the Makefile text that turns variables into the arguments
28
+ # @param direct [String] the shell that runs the script when there is no database
29
+ # @return [String] the recipe lines, joined for a Makefile rule
30
+ def call(command:, args:, direct:)
31
+ [*run_lines(command, args), *no_database_lines(direct), *outcome_lines].join("\n\t").freeze
32
+ end
33
+
34
+ def run_lines(command, args)
35
+ ["@err=$$(mktemp); run=deploy-$$(date -u +%Y%m%d%H%M%S); \\",
36
+ "$(HECKS) deploy #{command} project=\"$(CURDIR)\" run=\"$$run\" #{args} \\",
37
+ ' $(if $(SKIP_POST_DEPLOY_SMOKE),skip_smoke=true) --wait 2>"$$err"; rc=$$?; \\']
38
+ end
39
+
40
+ def no_database_lines(direct)
41
+ ['if [ $$rc -ne 0 ] && grep -q \'^cannot open Hecks\' "$$err"; then \\',
42
+ ' echo "==> the deploy record was NOT written: $$(grep -m1 \'^cannot open Hecks\' "$$err")" >&2; \\',
43
+ ' echo " one-time setup on this machine: a database AND a non-superuser role that owns it" >&2; \\',
44
+ ' echo " (a superuser skips the era write-fence, so createdb alone fails), then set" >&2; \\',
45
+ ' echo " HECKS_DATABASE=postgres://<role>@localhost/<db>" >&2; \\',
46
+ ' echo " deploying anyway so the deploy is not lost" >&2; \\',
47
+ " rm -f \"$$err\"; #{direct}; rc=$$?; \\",
48
+ ' echo "==> deploy exit $$rc. The deploy record was NOT written: the database is unavailable (see above)." >&2; \\',
49
+ " [ $$rc -ne 0 ] || rc=24; exit $$rc; \\",
50
+ "fi; \\"]
51
+ end
52
+
53
+ def outcome_lines
54
+ ['cat "$$err" >&2; rm -f "$$err"; exit $$rc']
55
+ end
56
+
57
+ private_class_method :run_lines, :no_database_lines, :outcome_lines
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -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,15 +8,21 @@ 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
- # Waits for the last roll to settle, then dispatches and follows the smoke.
19
+ # Waits for the last roll to settle, then dispatches and follows the smoke, as the smoke_run.run
20
+ # command, which runs smoke-after-deploy.sh and records how it ended in the Hecks database
21
+ # (HECKS_DATABASE, default postgres://hecks@localhost/hecks). Without that database the smoke still
22
+ # runs and its result is printed, and the target exits non-zero (24 if the smoke passed).
17
23
  # SKIP_POST_DEPLOY_SMOKE=1 opts out, DRY_RUN=1 dispatches nothing.
18
24
  smoke-after-deploy:
19
- bash ./smoke-after-deploy.sh
25
+ @@SMOKE_RECIPE@@
20
26
 
21
27
  # Compares the era a live host reports at GET /version with expected-era.
22
28
  check-era:
@@ -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
@@ -28,6 +29,27 @@ module Hecks
28
29
  "# after adding one: the admin API is off, so a reload cannot reach it.\n" \
29
30
  "import /etc/caddy/extra/*\n".freeze
30
31
 
32
+ # The `smoke-after-deploy` recipe: the smoke runs as `smoke_run.run`, which finds
33
+ # `smoke-after-deploy.sh` beside the Makefile and records how it ended in the Hecks
34
+ # database. When that database cannot be opened the command never starts, so the recipe
35
+ # runs the script itself, states the database error apart from the smoke's result, and
36
+ # exits non-zero (24 when the smoke passed).
37
+ SMOKE_RECIPE = [
38
+ "@err=$$(mktemp); \\",
39
+ '$(HECKS) deploy smoke_run.run project="$(CURDIR)" --wait $(if $(TASKDEF),taskdef=$(TASKDEF)) 2>"$$err"; rc=$$?; \\',
40
+ 'if [ $$rc -ne 0 ] && grep -q \'^cannot open Hecks\' "$$err"; then \\',
41
+ ' echo "==> the deploy record was NOT written: $$(grep -m1 \'^cannot open Hecks\' "$$err")" >&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; \\',
45
+ ' echo " running the smoke anyway so its result is not lost" >&2; \\',
46
+ ' rm -f "$$err"; TASKDEF="$(TASKDEF)" bash ./smoke-after-deploy.sh; rc=$$?; \\',
47
+ ' echo "==> smoke exit $$rc. The deploy record was NOT written: the database is unavailable (see above)." >&2; \\',
48
+ " [ $$rc -ne 0 ] || rc=24; exit $$rc; \\",
49
+ "fi; \\",
50
+ 'cat "$$err" >&2; rm -f "$$err"; exit $$rc'
51
+ ].join("\n\t").freeze
52
+
31
53
  module_function
32
54
 
33
55
  # Generates `rds.yaml`, `box.yaml`, `Caddyfile`, `services.json`, `render-compose.sh`,
@@ -557,6 +579,8 @@ module Hecks
557
579
  # Elastic IP. The default is a production pair, with deletion protection.
558
580
  REHEARSAL ?= false
559
581
 
582
+ HECKS ?= hecks
583
+
560
584
  .PHONY: stacks deploy
561
585
  stacks:
562
586
  \taws cloudformation deploy --template-file rds.yaml --stack-name $(RDS_STACK) --capabilities CAPABILITY_IAM \\
@@ -569,20 +593,9 @@ module Hecks
569
593
  \t\tRehearsal=$(REHEARSAL)
570
594
 
571
595
  deploy:
572
- \tbash ./deploy-box.sh $(#{plan.task_definition ? 'TASKDEF' : 'TAGS'})
596
+ #{RollRecipe.deploy(plan)}
573
597
  MAKE
574
- "#{base}#{hosting_makefile_tail(plan)}"
575
- end
576
-
577
- # With hosting scripts, a deploy ends with the post-deploy smoke and the Makefile includes
578
- # the fragment that adds the hosting targets.
579
- #
580
- # @param plan [Settings::Plan] the resolved settings
581
- # @return [String] the Makefile lines that follow the `deploy` recipe, or nothing
582
- def hosting_makefile_tail(plan)
583
- return "" unless plan.hosting
584
-
585
- "\t#{'TASKDEF="$(TASKDEF)" ' if plan.task_definition}bash ./smoke-after-deploy.sh\n\ninclude hosting.mk\n"
598
+ plan.hosting ? "#{base}\ninclude hosting.mk\n" : base
586
599
  end
587
600
 
588
601
  # Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,