hecks 2.6.0 → 2.7.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/adapters/driven/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +59 -5
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +2 -1
- metadata +45 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Projections
|
|
5
|
+
module Deploy
|
|
6
|
+
module Preview
|
|
7
|
+
# Text helpers the preview template's sections share: indentation and the
|
|
8
|
+
# rendering of a value as a YAML scalar or CloudFormation intrinsic.
|
|
9
|
+
module YamlText
|
|
10
|
+
PREVIEW_URL_TOKEN = "{{preview_url}}".freeze
|
|
11
|
+
PREVIEW_URL_SUB = "https://${PreviewDistribution.DomainName}".freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Indents every non-empty line of a block.
|
|
16
|
+
#
|
|
17
|
+
# @param text [String] the block
|
|
18
|
+
# @param width [Integer] spaces to add
|
|
19
|
+
# @return [String] the indented block
|
|
20
|
+
def indent(text, width)
|
|
21
|
+
pad = " " * width
|
|
22
|
+
text.each_line.map { |line| line.strip.empty? ? line : "#{pad}#{line}" }.join
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Renders one environment or property value as a YAML scalar or intrinsic.
|
|
26
|
+
#
|
|
27
|
+
# @param value [String] a literal, optionally holding the `{{preview_url}}` token
|
|
28
|
+
# @return [String] a double-quoted scalar, or a `!Sub` when the token is present
|
|
29
|
+
def scalar(value)
|
|
30
|
+
return JSON.generate(value) unless value.include?(PREVIEW_URL_TOKEN)
|
|
31
|
+
|
|
32
|
+
"!Sub #{JSON.generate(value.gsub(PREVIEW_URL_TOKEN, PREVIEW_URL_SUB))}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Names the logical id of a container's generated secret.
|
|
36
|
+
#
|
|
37
|
+
# @param container [Containers::Entry] the container that declares the secret
|
|
38
|
+
# @param name [String] the environment variable name
|
|
39
|
+
# @return [String] the logical id, ending in `Secret`
|
|
40
|
+
def secret_id(container, name)
|
|
41
|
+
stem = "#{container.logical}#{name.sub(/_ARN\z/, '').split('_').map(&:capitalize).join}"
|
|
42
|
+
stem.end_with?("Secret") ? stem : "#{stem}Secret"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require_relative "preview/settings"
|
|
2
|
+
require_relative "preview/template"
|
|
3
|
+
require_relative "preview/script"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Deploy
|
|
8
|
+
# Per-branch preview stacks for a `deployed_to("AwsFargate")` domain: one
|
|
9
|
+
# isolated copy of the stack per git branch, created and torn down by a
|
|
10
|
+
# generated script.
|
|
11
|
+
#
|
|
12
|
+
# Opt-in. A domain with no `preview` setting gets exactly the files it
|
|
13
|
+
# got before; `Fargate.call` asks `requested?` and merges in `call`'s
|
|
14
|
+
# files only when the answer is yes.
|
|
15
|
+
#
|
|
16
|
+
# ## What is generated
|
|
17
|
+
#
|
|
18
|
+
# - `preview.yaml`: a separate CloudFormation template, so a preview never
|
|
19
|
+
# appears in a change set against the live stack (see `Template`).
|
|
20
|
+
# - `preview.sh`: `deploy` (create or update), `destroy`, `list`, `url`,
|
|
21
|
+
# `name`, `ensure-database` and `login` (see `Script`).
|
|
22
|
+
# - The database step is a one-shot task declared in `preview.yaml`
|
|
23
|
+
# and run by `preview.sh`; there is no separate file, because the
|
|
24
|
+
# instance is reachable only from inside the VPC.
|
|
25
|
+
#
|
|
26
|
+
# ## Settings
|
|
27
|
+
#
|
|
28
|
+
# ```ruby
|
|
29
|
+
# deployed_to("AwsFargate") do
|
|
30
|
+
# region "us-east-1"
|
|
31
|
+
# preview do
|
|
32
|
+
# containers [{ name: "web", port: 8080, host: true, default: true }]
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
# ```
|
|
36
|
+
#
|
|
37
|
+
# `preview true` and a block with no settings accept every default. Keys:
|
|
38
|
+
#
|
|
39
|
+
# - `prefix`: stack, cluster, service and image repository name prefix.
|
|
40
|
+
# Default `<stack_prefix>-<stack>-preview`.
|
|
41
|
+
# - `alb_prefix`: load balancer name prefix, at most 11 characters.
|
|
42
|
+
# Default the first 8 letters of the stack name plus `pv`.
|
|
43
|
+
# - `owner_stack`: the stack whose VPC, subnets and compute security group
|
|
44
|
+
# the preview attaches to. Default the `owner_stack` of a Shared main
|
|
45
|
+
# stack, else the main stack.
|
|
46
|
+
# - `database_stack`: the stack whose outputs name the shared database
|
|
47
|
+
# instance. Default `owner_stack`.
|
|
48
|
+
# - `database_endpoint_output`, `database_secret_output`: the output keys
|
|
49
|
+
# holding the instance host and the master secret ARN. Defaults
|
|
50
|
+
# `DatabaseEndpoint` and `DatabaseSecretArn`.
|
|
51
|
+
# - `db_prefix`: a preview's database is `<db_prefix>_pv_<env>`. Default
|
|
52
|
+
# the stack name, lowercased, at most 30 characters.
|
|
53
|
+
# - `protected_databases`: names the database task refuses, added to the
|
|
54
|
+
# main database, `postgres` and the two template databases. Default `[]`.
|
|
55
|
+
# - `protected_branches`: branches `preview.sh` refuses. Default
|
|
56
|
+
# `["main", "master"]`.
|
|
57
|
+
# - `cpu`, `memory`: task size. Default the main stack's.
|
|
58
|
+
# - `log_retention_days`: a value CloudWatch accepts. Default `7`.
|
|
59
|
+
# - `session_cookie`: cookie name; sets `HECKS_SESSION_COOKIE` on the host
|
|
60
|
+
# and is what `login` writes. Default the host's own default.
|
|
61
|
+
# - `first_admin`: whether `preview.sh` signs the deployer up and offers
|
|
62
|
+
# `login`. Default `true`.
|
|
63
|
+
# - `signup_path`: the host route the signup posts to. Default `/signups`.
|
|
64
|
+
# - `landing_path`: the page `login` points the browser at. Default `/`.
|
|
65
|
+
# - `db_init_image`: image of the database task; needs `sh` and `psql`.
|
|
66
|
+
# Default the public Postgres 16 alpine image.
|
|
67
|
+
# - `containers`: the containers the preview task runs. Default the main
|
|
68
|
+
# stack's container list; see `Containers` for the entry keys.
|
|
69
|
+
#
|
|
70
|
+
# ## Why a preview mints no era and needs no bastion
|
|
71
|
+
#
|
|
72
|
+
# The host mints era 1 itself on an empty database, and the database
|
|
73
|
+
# task runs inside the VPC, so neither the main stack's bastion nor a
|
|
74
|
+
# tunnel is involved.
|
|
75
|
+
module Preview
|
|
76
|
+
module_function
|
|
77
|
+
|
|
78
|
+
# Answers whether a domain opted in to previews.
|
|
79
|
+
#
|
|
80
|
+
# @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings
|
|
81
|
+
# @return [Boolean] true when a `preview` setting is present and not `false`
|
|
82
|
+
def requested?(deploy_settings)
|
|
83
|
+
value = deploy_settings[:preview]
|
|
84
|
+
!value.nil? && value != false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Generates the preview files for one domain.
|
|
88
|
+
#
|
|
89
|
+
# @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` settings
|
|
90
|
+
# @param main [Hash{Symbol => Object}] the main stack's facts; see `Settings.build`
|
|
91
|
+
# @return [Hash{String => String}] `"preview.yaml"` and `"preview.sh"`, or `{}` when
|
|
92
|
+
# the domain did not opt in
|
|
93
|
+
# @raise [ArgumentError] if a preview setting is unknown or malformed
|
|
94
|
+
def call(deploy_settings:, main:)
|
|
95
|
+
return {} unless requested?(deploy_settings)
|
|
96
|
+
|
|
97
|
+
settings = Settings.build(deploy_settings[:preview], deploy_settings: deploy_settings, main: main)
|
|
98
|
+
{ "preview.yaml" => Template.render(settings), "preview.sh" => Script.render(settings) }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GENERATED by bin/project_deploy -- re-run it to refresh this file rather
|
|
3
|
+
# than hand-editing. Settings come from the domain's deployed_to("AwsFargate")
|
|
4
|
+
# block; see Hecks::Projections::Deploy::Scripts for each key.
|
|
5
|
+
#
|
|
6
|
+
# Deploys ONE container's freshly built local image to the live ECS service,
|
|
7
|
+
# under a fresh, unique ECR tag.
|
|
8
|
+
#
|
|
9
|
+
# bash deploy-service.sh <service>
|
|
10
|
+
#
|
|
11
|
+
# Why it exists: `docker push` of a floating tag plus
|
|
12
|
+
# `aws ecs update-service --force-new-deployment` looks like a deploy, but the
|
|
13
|
+
# task definition still names the old tag, so the new image never runs. This
|
|
14
|
+
# script reads the CURRENTLY ACTIVE task definition (the real source of truth,
|
|
15
|
+
# not the template's parameters), pushes the local image under a never-reused
|
|
16
|
+
# tag (<service>-<UTC timestamp>), swaps only that one container's image, and
|
|
17
|
+
# registers and deploys the result. Every other container keeps its image.
|
|
18
|
+
#
|
|
19
|
+
# It then syncs the CloudFormation stack's matching *ImageTag parameter, with
|
|
20
|
+
# --use-previous-template and UsePreviousValue for every other parameter, and
|
|
21
|
+
# checks that only that one parameter changed. The template stays the source of
|
|
22
|
+
# truth for what is live instead of drifting from it.
|
|
23
|
+
#
|
|
24
|
+
# Environment:
|
|
25
|
+
# LOCAL_IMAGE local image to push, default <ecr repository>:latest
|
|
26
|
+
# SETTLE_TIMEOUT_SECS default 1200, how long to wait for a rollout or stack update
|
|
27
|
+
# SKIP_POST_DEPLOY_SMOKE=1 passed through to smoke-after-deploy.sh
|
|
28
|
+
set -euo pipefail
|
|
29
|
+
|
|
30
|
+
#@@SETTINGS@@
|
|
31
|
+
|
|
32
|
+
#@@SERVICES@@
|
|
33
|
+
|
|
34
|
+
SERVICE_NAME="${1:?usage: deploy-service.sh <service> (one of: ${SERVICES})}"
|
|
35
|
+
resolve_service "$SERVICE_NAME"
|
|
36
|
+
|
|
37
|
+
SETTLE_TIMEOUT_SECS="${SETTLE_TIMEOUT_SECS:-1200}"
|
|
38
|
+
LOCAL_IMAGE="${LOCAL_IMAGE:-${ECR_REPOSITORY}:latest}"
|
|
39
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
40
|
+
|
|
41
|
+
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
|
|
42
|
+
ECR_HOST="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"
|
|
43
|
+
TAG="${SERVICE_NAME}-$(date -u +%Y%m%d%H%M%S)"
|
|
44
|
+
IMAGE_URI="${ECR_HOST}/${ECR_REPOSITORY}:${TAG}"
|
|
45
|
+
SCRATCH_DIR="$(mktemp -d)"
|
|
46
|
+
trap 'rm -rf "$SCRATCH_DIR"' EXIT
|
|
47
|
+
|
|
48
|
+
# Seconds elapsed since $1 (a value of $SECONDS) exceeds the settle timeout.
|
|
49
|
+
timed_out() { [ $((SECONDS - $1)) -gt "$SETTLE_TIMEOUT_SECS" ]; }
|
|
50
|
+
|
|
51
|
+
echo "==> tagging ${LOCAL_IMAGE} as ${IMAGE_URI}"
|
|
52
|
+
docker tag "$LOCAL_IMAGE" "$IMAGE_URI"
|
|
53
|
+
|
|
54
|
+
# A private docker config with no credential helper: a helper that blocks on a
|
|
55
|
+
# keychain prompt hangs forever in any unattended shell.
|
|
56
|
+
echo "==> ecr login"
|
|
57
|
+
DOCKER_CONFIG_DIR="${SCRATCH_DIR}/docker-config"
|
|
58
|
+
mkdir -p "$DOCKER_CONFIG_DIR"
|
|
59
|
+
echo '{}' > "${DOCKER_CONFIG_DIR}/config.json"
|
|
60
|
+
aws ecr get-login-password --region "$REGION" \
|
|
61
|
+
| docker --config "$DOCKER_CONFIG_DIR" login --username AWS --password-stdin "$ECR_HOST"
|
|
62
|
+
|
|
63
|
+
echo "==> pushing ${IMAGE_URI}"
|
|
64
|
+
docker --config "$DOCKER_CONFIG_DIR" push "$IMAGE_URI"
|
|
65
|
+
|
|
66
|
+
echo "==> reading the ACTIVE task definition (the real source of truth)"
|
|
67
|
+
CURRENT_TASKDEF="$(aws ecs describe-services --cluster "$CLUSTER" --services "$ECS_SERVICE" \
|
|
68
|
+
--region "$REGION" --query 'services[0].taskDefinition' --output text)"
|
|
69
|
+
aws ecs describe-task-definition --task-definition "$CURRENT_TASKDEF" --region "$REGION" \
|
|
70
|
+
--query 'taskDefinition' --output json > "${SCRATCH_DIR}/current.json"
|
|
71
|
+
|
|
72
|
+
SERVICE_NAME="$SERVICE_NAME" IMAGE_URI="$IMAGE_URI" python3 - "${SCRATCH_DIR}/current.json" "${SCRATCH_DIR}/new.json" <<'PYEOF'
|
|
73
|
+
import json, os, sys
|
|
74
|
+
|
|
75
|
+
service_name = os.environ["SERVICE_NAME"]
|
|
76
|
+
image_uri = os.environ["IMAGE_URI"]
|
|
77
|
+
with open(sys.argv[1]) as f:
|
|
78
|
+
td = json.load(f)
|
|
79
|
+
found = False
|
|
80
|
+
for c in td["containerDefinitions"]:
|
|
81
|
+
if c["name"] == service_name:
|
|
82
|
+
c["image"] = image_uri
|
|
83
|
+
found = True
|
|
84
|
+
if not found:
|
|
85
|
+
raise SystemExit("no container named %r in the active task definition" % service_name)
|
|
86
|
+
for key in ["taskDefinitionArn", "revision", "status", "requiresAttributes",
|
|
87
|
+
"compatibilities", "registeredAt", "registeredBy"]:
|
|
88
|
+
td.pop(key, None)
|
|
89
|
+
with open(sys.argv[2], "w") as f:
|
|
90
|
+
json.dump(td, f)
|
|
91
|
+
PYEOF
|
|
92
|
+
|
|
93
|
+
echo "==> registering a new task definition revision (only the ${SERVICE_NAME} image changes)"
|
|
94
|
+
NEW_TASKDEF_ARN="$(aws ecs register-task-definition --region "$REGION" \
|
|
95
|
+
--cli-input-json "file://${SCRATCH_DIR}/new.json" \
|
|
96
|
+
--query 'taskDefinition.taskDefinitionArn' --output text)"
|
|
97
|
+
echo " -> ${NEW_TASKDEF_ARN}"
|
|
98
|
+
|
|
99
|
+
echo "==> updating the service"
|
|
100
|
+
aws ecs update-service --cluster "$CLUSTER" --service "$ECS_SERVICE" --region "$REGION" \
|
|
101
|
+
--task-definition "$NEW_TASKDEF_ARN" --query 'service.taskDefinition' --output text
|
|
102
|
+
|
|
103
|
+
echo "==> waiting for the rollout to settle to a single deployment"
|
|
104
|
+
started=$SECONDS
|
|
105
|
+
while true; do
|
|
106
|
+
count="$(aws ecs describe-services --cluster "$CLUSTER" --services "$ECS_SERVICE" \
|
|
107
|
+
--region "$REGION" --query 'length(services[0].deployments)' --output text)"
|
|
108
|
+
[ "$count" = "1" ] && break
|
|
109
|
+
if timed_out "$started"; then
|
|
110
|
+
echo "==> the rollout did not settle within ${SETTLE_TIMEOUT_SECS}s; the service is still deploying" >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
sleep 10
|
|
114
|
+
done
|
|
115
|
+
|
|
116
|
+
echo "==> done. now running:"
|
|
117
|
+
aws ecs describe-task-definition --task-definition "$NEW_TASKDEF_ARN" --region "$REGION" \
|
|
118
|
+
--query "taskDefinition.containerDefinitions[?name=='${SERVICE_NAME}'].image" --output text
|
|
119
|
+
|
|
120
|
+
echo "==> syncing CloudFormation's ${CFN_PARAM_KEY} so the stack stops drifting from reality"
|
|
121
|
+
stack_parameters() {
|
|
122
|
+
aws cloudformation describe-stacks --stack-name "$STACK" --region "$REGION" \
|
|
123
|
+
--query 'Stacks[0].Parameters[].[ParameterKey,ParameterValue]' --output json
|
|
124
|
+
}
|
|
125
|
+
stack_parameters > "${SCRATCH_DIR}/params-before.json"
|
|
126
|
+
ALL_PARAM_KEYS="$(aws cloudformation describe-stacks --stack-name "$STACK" --region "$REGION" \
|
|
127
|
+
--query 'Stacks[0].Parameters[].ParameterKey' --output text)"
|
|
128
|
+
read -r -a PARAM_KEYS <<< "$ALL_PARAM_KEYS"
|
|
129
|
+
CFN_ARGS=()
|
|
130
|
+
found=0
|
|
131
|
+
for key in "${PARAM_KEYS[@]}"; do
|
|
132
|
+
if [ "$key" = "$CFN_PARAM_KEY" ]; then
|
|
133
|
+
CFN_ARGS+=("ParameterKey=${key},ParameterValue=${TAG}")
|
|
134
|
+
found=1
|
|
135
|
+
else
|
|
136
|
+
CFN_ARGS+=("ParameterKey=${key},UsePreviousValue=true")
|
|
137
|
+
fi
|
|
138
|
+
done
|
|
139
|
+
if [ "$found" != "1" ]; then
|
|
140
|
+
echo "stack ${STACK} has no parameter named ${CFN_PARAM_KEY}; the service is deployed but the stack was not updated" >&2
|
|
141
|
+
exit 1
|
|
142
|
+
fi
|
|
143
|
+
aws cloudformation update-stack --stack-name "$STACK" --region "$REGION" \
|
|
144
|
+
--use-previous-template --capabilities CAPABILITY_IAM --parameters "${CFN_ARGS[@]}" \
|
|
145
|
+
--query 'StackId' --output text
|
|
146
|
+
|
|
147
|
+
echo "==> waiting for the CloudFormation stack update to settle"
|
|
148
|
+
started=$SECONDS
|
|
149
|
+
while true; do
|
|
150
|
+
stack_status="$(aws cloudformation describe-stacks --stack-name "$STACK" --region "$REGION" \
|
|
151
|
+
--query 'Stacks[0].StackStatus' --output text)"
|
|
152
|
+
case "$stack_status" in
|
|
153
|
+
UPDATE_COMPLETE) echo " -> ${stack_status}"; break ;;
|
|
154
|
+
*ROLLBACK*|*FAILED*)
|
|
155
|
+
echo " -> ${stack_status}: the CloudFormation update failed, investigate before relying on it" >&2
|
|
156
|
+
exit 1 ;;
|
|
157
|
+
esac
|
|
158
|
+
if timed_out "$started"; then
|
|
159
|
+
echo "==> the stack update did not settle within ${SETTLE_TIMEOUT_SECS}s (last: ${stack_status})" >&2
|
|
160
|
+
exit 1
|
|
161
|
+
fi
|
|
162
|
+
sleep 10
|
|
163
|
+
done
|
|
164
|
+
|
|
165
|
+
echo "==> checking that only ${CFN_PARAM_KEY} changed"
|
|
166
|
+
stack_parameters > "${SCRATCH_DIR}/params-after.json"
|
|
167
|
+
python3 - "${SCRATCH_DIR}/params-before.json" "${SCRATCH_DIR}/params-after.json" "$CFN_PARAM_KEY" "$TAG" <<'PYEOF'
|
|
168
|
+
import json, sys
|
|
169
|
+
|
|
170
|
+
before = dict(json.load(open(sys.argv[1])))
|
|
171
|
+
after = dict(json.load(open(sys.argv[2])))
|
|
172
|
+
key, tag = sys.argv[3], sys.argv[4]
|
|
173
|
+
changed = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k))
|
|
174
|
+
if changed != [key] or after.get(key) != tag:
|
|
175
|
+
raise SystemExit("expected only %s to change to %s, but changed: %s" % (key, tag, changed))
|
|
176
|
+
print(" -> %s is now %s; no other parameter changed" % (key, tag))
|
|
177
|
+
PYEOF
|
|
178
|
+
|
|
179
|
+
# The deploy itself is done. Finish with the smoke, whose own exit codes (20-23)
|
|
180
|
+
# are distinct from a failed deploy step's. SKIP_POST_DEPLOY_SMOKE=1 skips it.
|
|
181
|
+
echo "==> deploy succeeded"
|
|
182
|
+
bash "${SCRIPT_DIR}/smoke-after-deploy.sh"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# The domain eras the post-deploy check accepts as "the era that should be
|
|
2
|
+
# running", one era id per line. Blank lines and lines starting with # are
|
|
3
|
+
# ignored. While no era is listed the check only verifies that the host reports
|
|
4
|
+
# one, and does not compare it.
|
|
5
|
+
#
|
|
6
|
+
# Changing the era on a roll:
|
|
7
|
+
# 1. Add the NEW era id below and merge that BEFORE the roll, so a scheduled
|
|
8
|
+
# check does not fail the moment the new era boots.
|
|
9
|
+
# 2. Roll. The check then passes on the new era.
|
|
10
|
+
# 3. Once the roll has settled, delete the OLD era id, so a rollback to it, or
|
|
11
|
+
# a fork of the journal back onto it, fails the check instead of passing.
|
|
12
|
+
#
|
|
13
|
+
# The id is what the host reports at GET /version, the same id the era mint
|
|
14
|
+
# prints. Compare a live host with this file:
|
|
15
|
+
#
|
|
16
|
+
# bin/check_era https://host.example expected-era
|
|
17
|
+
# make check-era URL=https://host.example
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# GENERATED by bin/project_deploy -- re-run it to refresh this file rather
|
|
2
|
+
# than hand-editing. Included by the Makefile beside it; settings come from the
|
|
3
|
+
# domain's deployed_to("AwsFargate") block (see
|
|
4
|
+
# Hecks::Projections::Deploy::Scripts for each key).
|
|
5
|
+
#
|
|
6
|
+
# HECKS_ROOT is a checkout of one pinned Hecks release, fetched once into a
|
|
7
|
+
# cache directory, so the domain image is built from the same Hecks on every
|
|
8
|
+
# machine. It replaces any path to a working copy: ROOT below points the
|
|
9
|
+
# Makefile's build and mint-era steps at it. Override HECKS_VERSION to build
|
|
10
|
+
# from another release, or HECKS_ROOT to use a checkout you manage yourself.
|
|
11
|
+
|
|
12
|
+
HECKS_VERSION ?= @@RELEASE@@
|
|
13
|
+
HECKS_SOURCE ?= @@SOURCE@@
|
|
14
|
+
HECKS_CACHE_DIR ?= @@CACHE_DIR@@
|
|
15
|
+
HECKS_ROOT ?= $(HECKS_CACHE_DIR)/hecks-$(HECKS_VERSION)
|
|
16
|
+
ROOT := $(HECKS_ROOT)
|
|
17
|
+
SERVICE ?= @@SERVICE@@
|
|
18
|
+
URL ?= @@URL@@
|
|
19
|
+
|
|
20
|
+
.PHONY: hecks-release deploy-service smoke-after-deploy check-era
|
|
21
|
+
|
|
22
|
+
build: hecks-release
|
|
23
|
+
|
|
24
|
+
# 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.
|
|
26
|
+
hecks-release:
|
|
27
|
+
@if [ -f "$(HECKS_ROOT)/bin/project_wasm" ]; then \
|
|
28
|
+
echo "hecks v$(HECKS_VERSION) is already at $(HECKS_ROOT)"; \
|
|
29
|
+
else \
|
|
30
|
+
echo "Fetching hecks v$(HECKS_VERSION) from $(HECKS_SOURCE) into $(HECKS_ROOT)..."; \
|
|
31
|
+
mkdir -p "$(HECKS_CACHE_DIR)" && \
|
|
32
|
+
git clone --quiet --depth 1 --branch "v$(HECKS_VERSION)" "$(HECKS_SOURCE)" "$(HECKS_ROOT)"; \
|
|
33
|
+
fi
|
|
34
|
+
@tag="$$(git -C "$(HECKS_ROOT)" describe --tags --exact-match 2>/dev/null)"; \
|
|
35
|
+
if [ "$$tag" != "v$(HECKS_VERSION)" ]; then \
|
|
36
|
+
echo "$(HECKS_ROOT) is at '$$tag', not v$(HECKS_VERSION). Remove it and re-run, or set HECKS_ROOT."; \
|
|
37
|
+
exit 1; \
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Pushes one container's already-built local image under a fresh tag and rolls
|
|
41
|
+
# it out. Build the image first (docker-build); see deploy-service.sh.
|
|
42
|
+
deploy-service:
|
|
43
|
+
@echo "Deploying $(SERVICE) with deploy-service.sh (pushes its local image, rolls the ECS service)..."
|
|
44
|
+
bash ./deploy-service.sh $(SERVICE)
|
|
45
|
+
|
|
46
|
+
# Waits for the last roll to settle, then dispatches and follows the smoke.
|
|
47
|
+
# SKIP_POST_DEPLOY_SMOKE=1 opts out, DRY_RUN=1 dispatches nothing.
|
|
48
|
+
smoke-after-deploy:
|
|
49
|
+
bash ./smoke-after-deploy.sh
|
|
50
|
+
|
|
51
|
+
# Compares the era a live host reports at GET /version with expected-era.
|
|
52
|
+
check-era:
|
|
53
|
+
@test -n "$(URL)" || { echo "usage: make check-era URL=https://host.example"; exit 1; }
|
|
54
|
+
ruby "$(HECKS_ROOT)/bin/check_era" "$(URL)" expected-era
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Projections
|
|
3
|
+
module Deploy
|
|
4
|
+
module Scripts
|
|
5
|
+
# The `deployed_to("AwsFargate")` settings the hosting scripts read,
|
|
6
|
+
# with their defaults and the check each value must pass.
|
|
7
|
+
#
|
|
8
|
+
# Every value ends up inside a shell script or a Makefile, so each one
|
|
9
|
+
# is refused unless it matches a conservative pattern for what it names
|
|
10
|
+
# (a region, a cluster, a GitHub repository). That check is what lets
|
|
11
|
+
# the renderers write values without per-context escaping.
|
|
12
|
+
#
|
|
13
|
+
# The containers, their ECR repositories and image-tag parameters, and
|
|
14
|
+
# the default cluster and service names are not settings here. They come
|
|
15
|
+
# from the template generator's own resolved plan
|
|
16
|
+
# (`Fargate::Settings::Plan`), so a script can only name what the
|
|
17
|
+
# generated `template.yaml` defines.
|
|
18
|
+
#
|
|
19
|
+
# The keys are documented on `Scripts`.
|
|
20
|
+
class Settings
|
|
21
|
+
RESOURCE = /\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/
|
|
22
|
+
REGION = /\A[a-z]{2}(-[a-z]+)+-\d\z/
|
|
23
|
+
GITHUB_REPO = %r{\A[\w.-]+/[\w.-]+\z}
|
|
24
|
+
WORKFLOW = /\A[\w.-]+\.ya?ml\z/
|
|
25
|
+
REF = %r{\A[\w./-]+\z}
|
|
26
|
+
ERA = /\A[0-9a-zA-Z._-]+\z/
|
|
27
|
+
RELEASE = /\Av?\d+\.\d+\.\d+([.-][0-9A-Za-z.-]+)?\z/
|
|
28
|
+
SOURCE = %r{\A[\w.@:/~-]+\z}
|
|
29
|
+
MAKE_PATH = /\A[^\s#]+\z/
|
|
30
|
+
|
|
31
|
+
DEFAULT_SOURCE = "https://github.com/heckslabs/hecks.git".freeze
|
|
32
|
+
DEFAULT_CACHE_DIR = "$(HOME)/.cache/hecks".freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :region, :stack, :cluster, :service, :containers, :hecks_release, :hecks_source,
|
|
35
|
+
:hecks_cache_dir, :smoke_repo, :smoke_workflow, :smoke_ref, :expected_eras, :public_url
|
|
36
|
+
|
|
37
|
+
# Reads and checks every hosting setting.
|
|
38
|
+
#
|
|
39
|
+
# @param deploy_settings [Hash{Symbol => Object}] the world's
|
|
40
|
+
# `deployed_to("AwsFargate")` settings
|
|
41
|
+
# @param plan [Fargate::Settings::Plan] the template generator's resolved settings:
|
|
42
|
+
# its `layout` holds the containers, its `names` the cluster and service
|
|
43
|
+
# @param stack_name [String] the CloudFormation stack
|
|
44
|
+
# @param region [String] the validated AWS region
|
|
45
|
+
# @raise [ArgumentError] if `hecks_release` is missing, or a value
|
|
46
|
+
# does not match the pattern for what it names
|
|
47
|
+
def initialize(deploy_settings:, plan:, stack_name:, region:)
|
|
48
|
+
@raw = deploy_settings
|
|
49
|
+
@region = check(:region, region, REGION)
|
|
50
|
+
@stack = check(:stack_name, stack_name, RESOURCE)
|
|
51
|
+
@cluster = check(:ecs_cluster, fetch(:ecs_cluster, plan.names.fetch(:cluster)), RESOURCE)
|
|
52
|
+
@service = check(:ecs_service, fetch(:ecs_service, plan.names.fetch(:service)), RESOURCE)
|
|
53
|
+
@containers = plan.layout.all
|
|
54
|
+
@hecks_release = read_release
|
|
55
|
+
@hecks_source = check(:hecks_source, fetch(:hecks_source, DEFAULT_SOURCE), SOURCE)
|
|
56
|
+
@hecks_cache_dir = check(:hecks_cache_dir, fetch(:hecks_cache_dir, DEFAULT_CACHE_DIR), MAKE_PATH)
|
|
57
|
+
read_smoke
|
|
58
|
+
@expected_eras = list(:expected_eras, []).each { |era| check(:expected_eras, era, ERA) }
|
|
59
|
+
@public_url = optional(:public_url, %r{\Ahttps?://[^\s'"$`\\]+\z})
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def read_release
|
|
65
|
+
release = fetch(:hecks_release, nil)
|
|
66
|
+
raise ArgumentError, missing_release_message unless release
|
|
67
|
+
|
|
68
|
+
check(:hecks_release, release.to_s, RELEASE).delete_prefix("v")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def read_smoke
|
|
72
|
+
@smoke_repo = optional(:smoke_repo, GITHUB_REPO)
|
|
73
|
+
@smoke_workflow = optional(:smoke_workflow, WORKFLOW)
|
|
74
|
+
@smoke_ref = check(:smoke_ref, fetch(:smoke_ref, "main"), REF)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def missing_release_message
|
|
78
|
+
<<~MSG
|
|
79
|
+
deployed_to("AwsFargate") enables hosting_scripts but names no hecks_release. Pin one, e.g.:
|
|
80
|
+
|
|
81
|
+
deployed_to("AwsFargate") do
|
|
82
|
+
...
|
|
83
|
+
hosting_scripts true
|
|
84
|
+
hecks_release "2.5.1"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
so the domain image is built from a fixed Hecks release, not from a checkout on one machine.
|
|
88
|
+
MSG
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def fetch(key, default)
|
|
92
|
+
@raw.key?(key) ? @raw[key] : default
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def optional(key, pattern)
|
|
96
|
+
value = fetch(key, nil)
|
|
97
|
+
value.nil? ? nil : check(key, value.to_s, pattern)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def list(key, default)
|
|
101
|
+
Array(fetch(key, default)).map(&:to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def check(key, value, pattern)
|
|
105
|
+
return value if value.is_a?(String) && value.match?(pattern)
|
|
106
|
+
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"deployed_to(\"AwsFargate\") #{key} #{value.inspect} is not a valid value (expected #{pattern.inspect})"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|