hecks 3.2.0 → 3.3.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/bluebook/assembly/contracts.rb +2 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
- data/lib/hecks/bluebook/query.rb +8 -5
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/cli/interview_draft.rb +86 -31
- data/lib/hecks/cli/interview_session.rb +77 -26
- data/lib/hecks/cli/interview_shape.rb +179 -0
- data/lib/hecks/doors/cli_runner.rb +14 -1
- data/lib/hecks/doors/launcher_options.rb +13 -3
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +45 -5
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
- data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/fuzzing/qa_settings.rb +15 -34
- data/lib/hecks/gate/stages.yml +113 -8
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
- data/lib/hecks/hecks/adapters/git.rb +59 -0
- data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +217 -0
- data/lib/hecks/hecks/hecks.hecksagon +24 -0
- data/lib/hecks/hecks/hecks.world +8 -3
- data/lib/hecks/hecks/site.world +5 -2
- data/lib/hecks/language/bluebook/query.bluebook +25 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
- data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
- data/lib/hecks/projections/deploy/box/settings.rb +40 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
- data/lib/hecks/projections/deploy/box.rb +28 -4
- data/lib/hecks/projections/deploy/fargate.rb +3 -3
- data/lib/hecks/projections/deploy/lambda.rb +8 -8
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/live_edge.rb +162 -0
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
- data/lib/hecks/quality_control/quality_control.bluebook +76 -124
- data/lib/hecks/runtime/interpreting.rb +3 -22
- data/lib/hecks/runtime/needs.rb +48 -0
- data/lib/hecks/runtime/query_interpreter.rb +3 -0
- data/lib/hecks/runtime/registry/verification.rb +3 -3
- data/lib/hecks/rust_build/host.rb +147 -0
- data/lib/hecks/rust_build/kernel_input.rb +51 -17
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
- data/lib/hecks/rust_build/project_rust.rb +7 -140
- data/lib/hecks/rust_build.rb +1 -0
- data/lib/hecks/site/bluebook/site.bluebook +80 -14
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/gate.rb +1 -1
- data/lib/hecks/tools/rust_comment_style.rb +2 -4
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/tools/tools_doc.rb +204 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +152 -1
- data/lib/hecks.rb +1 -0
- data/qa/settings.yml +1 -2
- data/rust/build/src/lineage_pass.rs +31 -16
- data/rust/build/src/main.rs +4 -2
- data/rust/build/src/pipeline.rs +9 -20
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +8 -8
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json.rs +214 -23
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/literal.rs +6 -3
- data/rust/codegen/src/main.rs +23 -13
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +5 -3
- data/rust/codegen/src/optional_pass.rs +226 -0
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/{build → codegen}/src/sidecars.rs +9 -5
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +5 -0
- data/rust/host/src/needs.rs +193 -29
- data/rust/host/src/resend.rs +4 -4
- data/rust/host/src/ui_schema.rs +1 -1
- data/rust/parser/src/emit.rs +12 -0
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +2 -44
- data/rust/parser/src/parse/mod.rs +1 -0
- data/rust/parser/src/parse/needs.rs +60 -0
- data/rust/parser/src/parse/query.rs +3 -0
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +18 -8
- data/rust/src/kernel/needs.rs +104 -9
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +24 -30
- data/lib/hecks/rust_build/append_optionals.rb +0 -79
- data/lib/hecks/rust_build/rust_literal.rb +0 -33
- data/lib/hecks/three_zero/forms.yml +0 -127
- data/lib/hecks/three_zero.rb +0 -12
- data/rust/build/src/json.rs +0 -421
- data/rust/build/src/optional_pass.rs +0 -150
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative "hosting_settings"
|
|
2
|
+
|
|
1
3
|
module Hecks
|
|
2
4
|
module Projections
|
|
3
5
|
module Deploy
|
|
@@ -34,6 +36,8 @@ module Hecks
|
|
|
34
36
|
"@sha256:226d1f059b75399fe19182893c7184591c07b97afc8dfcf44eeb80c9a77a530f".freeze
|
|
35
37
|
TUNNEL_SHAPE = "tunnel: a hash needs `to` (the container it forwards to) and `token_secret` " \
|
|
36
38
|
"(the secret holding the tunnel token)".freeze
|
|
39
|
+
ORIGIN_ENV_SHAPE = "origin_env: the container environment variable names that hold the origin secret " \
|
|
40
|
+
"need an origin_secret and a task_definition to compare against".freeze
|
|
37
41
|
ORIGIN_PAIR = "origin_header and origin_secret go together: the header a CDN sends, " \
|
|
38
42
|
"and the secret that holds its value".freeze
|
|
39
43
|
|
|
@@ -45,7 +49,9 @@ module Hecks
|
|
|
45
49
|
# @!attribute [r] env [Hash{String => String}] plain environment variables
|
|
46
50
|
# @!attribute [r] secrets [Hash{String => String}] environment variable => secret name,
|
|
47
51
|
# resolved on the box at deploy time and never written into a template
|
|
48
|
-
|
|
52
|
+
# @!attribute [r] tag_parameter [String] the stack parameter that holds this container's
|
|
53
|
+
# image tag, which a hosting `deploy-service.sh` sets
|
|
54
|
+
Container = Struct.new(:name, :repository, :port, :env, :secrets, :tag_parameter, keyword_init: true)
|
|
49
55
|
|
|
50
56
|
# A set of URL paths one container serves.
|
|
51
57
|
#
|
|
@@ -79,8 +85,8 @@ module Hecks
|
|
|
79
85
|
:infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
|
|
80
86
|
:storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
|
|
81
87
|
:containers, :routes, :default_container, :origin_header, :origin_secret,
|
|
82
|
-
:secret_prefixes, :writable_secrets, :tunnel, :tunnel_service, :proxy_image, :task_definition,
|
|
83
|
-
:migration, :s3_buckets, keyword_init: true
|
|
88
|
+
:secret_prefixes, :writable_secrets, :origin_env, :tunnel, :tunnel_service, :proxy_image, :task_definition,
|
|
89
|
+
:migration, :s3_buckets, :hosting, keyword_init: true
|
|
84
90
|
) do
|
|
85
91
|
# @return [String] the CloudFormation stack that holds the database
|
|
86
92
|
def rds_stack = "#{stack_prefix}-#{infra_name}-rds"
|
|
@@ -110,6 +116,7 @@ module Hecks
|
|
|
110
116
|
task_definition = read_task_definition(s[:task_definition], listed)
|
|
111
117
|
containers = read_containers(listed, infra_name)
|
|
112
118
|
header, secret = read_origin(s)
|
|
119
|
+
origin_env = read_origin_env(s, secret, task_definition)
|
|
113
120
|
tunnel, tunnel_service = read_tunnel(s.fetch(:tunnel, false), containers)
|
|
114
121
|
|
|
115
122
|
Plan.new(
|
|
@@ -118,12 +125,13 @@ module Hecks
|
|
|
118
125
|
engine_version: check(:engine_version, s.fetch(:engine_version, "16").to_s, ENGINE),
|
|
119
126
|
containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
|
|
120
127
|
default_container: read_default(s[:default_container], containers), origin_header: header,
|
|
121
|
-
origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
|
|
128
|
+
origin_secret: secret, origin_env: origin_env, secret_prefixes: read_prefixes(s, infra_name),
|
|
122
129
|
writable_secrets: read_writable_secrets(s),
|
|
123
130
|
tunnel: tunnel, tunnel_service: tunnel_service,
|
|
124
131
|
proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
|
|
125
132
|
task_definition: task_definition, migration: read_migration(s[:migration], database_name),
|
|
126
|
-
s3_buckets: read_s3_access(s.fetch(:s3_access, []))
|
|
133
|
+
s3_buckets: read_s3_access(s.fetch(:s3_access, [])),
|
|
134
|
+
hosting: HostingSettings.read(s, task_definition)
|
|
127
135
|
)
|
|
128
136
|
end
|
|
129
137
|
|
|
@@ -221,10 +229,18 @@ module Hecks
|
|
|
221
229
|
name: name, repository: check(:repository, spec.fetch(:repository, "#{infra_name}-#{name}"), REPOSITORY),
|
|
222
230
|
port: integer(:port, spec.fetch(:port) { raise ArgumentError, "containers: #{name} has no port" }, 1, 65_535),
|
|
223
231
|
env: string_map(:env, spec.fetch(:env, {}), ENV_KEY, ENV_VALUE),
|
|
224
|
-
secrets: string_map(:secrets, spec.fetch(:secrets, {}), ENV_KEY, SECRET_NAME)
|
|
232
|
+
secrets: string_map(:secrets, spec.fetch(:secrets, {}), ENV_KEY, SECRET_NAME),
|
|
233
|
+
tag_parameter: check(:tag_parameter, spec.fetch(:tag_parameter, default_tag_parameter(name)),
|
|
234
|
+
HostingSettings::PARAMETER)
|
|
225
235
|
)
|
|
226
236
|
end
|
|
227
237
|
|
|
238
|
+
# @param name [String] a container name such as `web-app`
|
|
239
|
+
# @return [String] the image-tag parameter a stack names it by, such as `WebAppImageTag`
|
|
240
|
+
def default_tag_parameter(name)
|
|
241
|
+
"#{name.split('-').map(&:capitalize).join}ImageTag"
|
|
242
|
+
end
|
|
243
|
+
|
|
228
244
|
def read_routes(list, containers)
|
|
229
245
|
raise ArgumentError, "routes: expected a list" unless list.is_a?(Array)
|
|
230
246
|
|
|
@@ -263,6 +279,24 @@ module Hecks
|
|
|
263
279
|
[check(:origin_header, header, HEADER), check(:origin_secret, secret, SECRET_NAME)]
|
|
264
280
|
end
|
|
265
281
|
|
|
282
|
+
# The container environment variables that hold the origin secret in the task
|
|
283
|
+
# definition. Only Caddy reads the named secret; the containers keep the task
|
|
284
|
+
# definition's copy, so a copy that differs makes every request through the CDN
|
|
285
|
+
# fail. Naming them lets deploy refuse a mismatch.
|
|
286
|
+
#
|
|
287
|
+
# @param settings [Hash{Symbol => Object}] the world's `AwsBox` settings
|
|
288
|
+
# @param secret [String, nil] the declared origin secret
|
|
289
|
+
# @param task_definition [String, nil] the declared task definition family
|
|
290
|
+
# @return [Array<String>] the variable names, empty when none are declared
|
|
291
|
+
# @raise [ArgumentError] when named without an origin secret and a task definition
|
|
292
|
+
def read_origin_env(settings, secret, task_definition)
|
|
293
|
+
names = Array(settings.fetch(:origin_env, []))
|
|
294
|
+
return [] if names.empty?
|
|
295
|
+
raise ArgumentError, ORIGIN_ENV_SHAPE if secret.nil? || task_definition.nil?
|
|
296
|
+
|
|
297
|
+
names.map { |name| check(:origin_env, name, ENV_KEY) }.uniq
|
|
298
|
+
end
|
|
299
|
+
|
|
266
300
|
# `tunnel true` only opens the outbound port; a hash also runs cloudflared as a service.
|
|
267
301
|
#
|
|
268
302
|
# @param value [Boolean, Hash{Symbol => Object}] the world's `tunnel` setting
|
|
@@ -55,6 +55,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
|
|
|
55
55
|
--arg registry "$ACCOUNT.dkr.ecr.$REGION.amazonaws.com" --arg dir "$DIR" '
|
|
56
56
|
{commands: ["cloud-init status --wait >/dev/null 2>&1 || true",
|
|
57
57
|
"set -e", "mkdir -p \($dir)/caddy-extra && cd \($dir)", "umask 077",
|
|
58
|
+
"CADDY_BEFORE=$(cat Caddyfile 2>/dev/null | sha256sum)",
|
|
58
59
|
"echo \($compose) | base64 -d > compose.json; echo \($secrets) | base64 -d > secrets.json",
|
|
59
60
|
"echo \($caddy) | base64 -d > Caddyfile; echo \($fetch) | base64 -d > fetch-secrets.sh",
|
|
60
61
|
"bash fetch-secrets.sh",
|
|
@@ -62,6 +63,7 @@ ROLL=$(jq -n --arg compose "$(B64 "$WORK/compose.json")" --arg secrets "$(B64 "$
|
|
|
62
63
|
"aws ecr get-login-password --region '"$REGION"' | docker login --username AWS --password-stdin \($registry) >/dev/null",
|
|
63
64
|
"docker compose -f compose.json pull --quiet",
|
|
64
65
|
"docker compose -f compose.json up -d --remove-orphans",
|
|
66
|
+
"[ \"$(sha256sum < Caddyfile)\" = \"$CADDY_BEFORE\" ] || docker compose -f compose.json restart caddy",
|
|
65
67
|
"for i in $(seq 1 40); do docker compose -f compose.json ps --format \"{{.Status}}\" | grep -qE \"^Up (Less than a second|[0-4] seconds?)|Restarting|Created|Exited\" || break; sleep 1; done; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
|
|
66
68
|
executionTimeout: ["900"]}')
|
|
67
69
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# deploy-box.sh renders every container the roll does not name at "latest". Name the tag each other
|
|
2
|
+
# container runs now, read from the box's own Compose file, so rolling one service leaves the rest alone.
|
|
3
|
+
BOX_ID="$(aws cloudformation describe-stacks --stack-name "$BOX_STACK" --region "$REGION" \
|
|
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; }
|
|
6
|
+
|
|
7
|
+
echo "==> reading the tags the box runs now"
|
|
8
|
+
READ_JSON="$(jq -n --arg c "cat ${BOX_DIR}/compose.json 2>/dev/null || echo '{}'" '{commands: [$c]}')"
|
|
9
|
+
COMMAND_ID="$(aws ssm send-command --instance-ids "$BOX_ID" --document-name AWS-RunShellScript --region "$REGION" \
|
|
10
|
+
--parameters "$READ_JSON" --query 'Command.CommandId' --output text)"
|
|
11
|
+
for _ in $(seq 1 30); do
|
|
12
|
+
sleep 2
|
|
13
|
+
state="$(aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$BOX_ID" --region "$REGION" \
|
|
14
|
+
--query Status --output text 2>/dev/null || echo Pending)"
|
|
15
|
+
[ "$state" != InProgress ] && [ "$state" != Pending ] && break
|
|
16
|
+
done
|
|
17
|
+
[ "$state" = Success ] || { echo "could not read the box's Compose file (${state}); nothing was rolled" >&2; exit 1; }
|
|
18
|
+
aws ssm get-command-invocation --command-id "$COMMAND_ID" --instance-id "$BOX_ID" --region "$REGION" \
|
|
19
|
+
--query StandardOutputContent --output text > "${SCRATCH_DIR}/compose.json"
|
|
20
|
+
|
|
21
|
+
TAGS=()
|
|
22
|
+
for name in $SERVICES; do
|
|
23
|
+
if [ "$name" = "$SERVICE_NAME" ]; then
|
|
24
|
+
TAGS+=("${name}=${TAG}")
|
|
25
|
+
continue
|
|
26
|
+
fi
|
|
27
|
+
image="$(jq -r --arg n "$name" '.services[$n].image // empty' "${SCRATCH_DIR}/compose.json")"
|
|
28
|
+
# A container the box does not run yet starts at its "latest" image.
|
|
29
|
+
if [ -n "$image" ]; then TAGS+=("${name}=${image##*:}"); fi
|
|
30
|
+
done
|
|
31
|
+
echo "==> rolling ${TAGS[*]}"
|
|
32
|
+
|
|
33
|
+
bash ./deploy-box.sh "${TAGS[@]}"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GENERATED by hecks deploy project -- re-run it to refresh this file rather than hand-editing.
|
|
3
|
+
# Settings come from the domain's deployed_to("AwsBox") block (hosting_scripts true).
|
|
4
|
+
#
|
|
5
|
+
# Deploys ONE container's freshly built local image to @@STACK@@'s app box, under a fresh, unique ECR tag.
|
|
6
|
+
#
|
|
7
|
+
# bash deploy-service.sh <service>
|
|
8
|
+
# EXISTING_TAG=<tag> bash deploy-service.sh <service> redeploy a tag already in ECR (no push)
|
|
9
|
+
#
|
|
10
|
+
# Why it exists: pushing a floating tag and restarting a container looks like a deploy, but nothing
|
|
11
|
+
# records which image the box was told to run, and a box that pulls a tag it already has keeps the old
|
|
12
|
+
# image. This script pushes the local image under a never-reused tag (<service>-<UTC timestamp>),
|
|
13
|
+
# refuses a tag that is already in ECR, and names that tag in the roll.
|
|
14
|
+
@@MODE_SUMMARY@@
|
|
15
|
+
#
|
|
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.
|
|
18
|
+
#
|
|
19
|
+
# Environment:
|
|
20
|
+
# LOCAL_IMAGE local image to push, default <ecr repository>:latest
|
|
21
|
+
# EXISTING_TAG redeploy this tag from ECR instead of building and pushing
|
|
22
|
+
# SETTLE_TIMEOUT_SECS default 1200, how long to wait for a stack update
|
|
23
|
+
# SKIP_POST_DEPLOY_SMOKE=1 passed through to smoke-after-deploy.sh
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
@@SETTINGS@@
|
|
27
|
+
|
|
28
|
+
@@SERVICES@@
|
|
29
|
+
|
|
30
|
+
SERVICE_NAME="${1:?usage: deploy-service.sh <service> (one of: ${SERVICES})}"
|
|
31
|
+
resolve_service "$SERVICE_NAME"
|
|
32
|
+
|
|
33
|
+
cd "$(dirname "${BASH_SOURCE[0]}")"
|
|
34
|
+
SETTLE_TIMEOUT_SECS="${SETTLE_TIMEOUT_SECS:-1200}"
|
|
35
|
+
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
|
|
36
|
+
ECR_HOST="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com"
|
|
37
|
+
SCRATCH_DIR="$(mktemp -d)"
|
|
38
|
+
trap 'rm -rf "$SCRATCH_DIR"' EXIT
|
|
39
|
+
|
|
40
|
+
# True once $1 (a value of $SECONDS) is further in the past than the settle timeout.
|
|
41
|
+
timed_out() { [ $((SECONDS - $1)) -gt "$SETTLE_TIMEOUT_SECS" ]; }
|
|
42
|
+
|
|
43
|
+
@@REPOSITORY_STEP@@
|
|
44
|
+
TAG="${EXISTING_TAG:-${SERVICE_NAME}-$(date -u +%Y%m%d%H%M%S)}"
|
|
45
|
+
IMAGE_URI="${ECR_HOST}/${ECR_REPOSITORY}:${TAG}"
|
|
46
|
+
LOCAL_IMAGE="${LOCAL_IMAGE:-${ECR_REPOSITORY}:latest}"
|
|
47
|
+
|
|
48
|
+
if [ -n "${EXISTING_TAG:-}" ]; then
|
|
49
|
+
echo "==> reusing ${IMAGE_URI} (no push)"
|
|
50
|
+
aws ecr describe-images --repository-name "$ECR_REPOSITORY" --region "$REGION" \
|
|
51
|
+
--image-ids "imageTag=${TAG}" --query 'imageDetails[0].imageTags' --output text > /dev/null \
|
|
52
|
+
|| { echo "tag ${TAG} is not in ECR" >&2; exit 1; }
|
|
53
|
+
else
|
|
54
|
+
if aws ecr describe-images --repository-name "$ECR_REPOSITORY" --region "$REGION" \
|
|
55
|
+
--image-ids "imageTag=${TAG}" > /dev/null 2>&1; then
|
|
56
|
+
echo "tag ${TAG} is already in ECR; a tag is never reused. Run again." >&2
|
|
57
|
+
exit 1
|
|
58
|
+
fi
|
|
59
|
+
echo "==> tagging ${LOCAL_IMAGE} as ${IMAGE_URI}"
|
|
60
|
+
docker tag "$LOCAL_IMAGE" "$IMAGE_URI"
|
|
61
|
+
|
|
62
|
+
# A private docker config with no credential helper: a helper that blocks on a keychain prompt
|
|
63
|
+
# hangs forever in any unattended shell.
|
|
64
|
+
echo "==> ecr login"
|
|
65
|
+
DOCKER_CONFIG_DIR="${SCRATCH_DIR}/docker-config"
|
|
66
|
+
mkdir -p "$DOCKER_CONFIG_DIR"
|
|
67
|
+
echo '{}' > "${DOCKER_CONFIG_DIR}/config.json"
|
|
68
|
+
aws ecr get-login-password --region "$REGION" \
|
|
69
|
+
| docker --config "$DOCKER_CONFIG_DIR" login --username AWS --password-stdin "$ECR_HOST"
|
|
70
|
+
|
|
71
|
+
echo "==> pushing ${IMAGE_URI}"
|
|
72
|
+
docker --config "$DOCKER_CONFIG_DIR" push "$IMAGE_URI"
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
@@ROLL_STEPS@@
|
|
76
|
+
|
|
77
|
+
# The deploy itself is done. End with the smoke, which reports separately.
|
|
78
|
+
echo "==> deploy succeeded"
|
|
79
|
+
bash ./smoke-after-deploy.sh
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
echo "==> setting the stack's ${CFN_PARAM_KEY} to ${TAG} (registers the new task definition revision)"
|
|
2
|
+
stack_parameters() {
|
|
3
|
+
aws cloudformation describe-stacks --stack-name "$IMAGE_STACK" --region "$REGION" \
|
|
4
|
+
--query 'Stacks[0].Parameters[].{k:ParameterKey,v:ParameterValue}' --output json
|
|
5
|
+
}
|
|
6
|
+
stack_parameters > "${SCRATCH_DIR}/params-before.json"
|
|
7
|
+
ALL_PARAM_KEYS="$(jq -r '.[].k' "${SCRATCH_DIR}/params-before.json")"
|
|
8
|
+
CFN_ARGS=()
|
|
9
|
+
found=0
|
|
10
|
+
for key in $ALL_PARAM_KEYS; do
|
|
11
|
+
if [ "$key" = "$CFN_PARAM_KEY" ]; then
|
|
12
|
+
CFN_ARGS+=("ParameterKey=${key},ParameterValue=${TAG}")
|
|
13
|
+
found=1
|
|
14
|
+
else
|
|
15
|
+
CFN_ARGS+=("ParameterKey=${key},UsePreviousValue=true")
|
|
16
|
+
fi
|
|
17
|
+
done
|
|
18
|
+
if [ "$found" != "1" ]; then
|
|
19
|
+
echo "stack ${IMAGE_STACK} has no parameter named ${CFN_PARAM_KEY}; the image was pushed but nothing was rolled" >&2
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
if UPDATE_OUT="$(aws cloudformation update-stack --stack-name "$IMAGE_STACK" --region "$REGION" \
|
|
24
|
+
--use-previous-template --capabilities CAPABILITY_IAM --parameters "${CFN_ARGS[@]}" \
|
|
25
|
+
--query 'StackId' --output text 2>&1)"; then
|
|
26
|
+
echo "==> waiting for the CloudFormation stack update to settle"
|
|
27
|
+
started=$SECONDS
|
|
28
|
+
while true; do
|
|
29
|
+
stack_status="$(aws cloudformation describe-stacks --stack-name "$IMAGE_STACK" --region "$REGION" \
|
|
30
|
+
--query 'Stacks[0].StackStatus' --output text)"
|
|
31
|
+
case "$stack_status" in
|
|
32
|
+
UPDATE_COMPLETE) echo " -> ${stack_status}"; break ;;
|
|
33
|
+
*ROLLBACK*|*FAILED*)
|
|
34
|
+
echo " -> ${stack_status}: the stack update failed, investigate before rolling anything" >&2
|
|
35
|
+
exit 1 ;;
|
|
36
|
+
esac
|
|
37
|
+
if timed_out "$started"; then
|
|
38
|
+
echo "==> the stack update did not settle within ${SETTLE_TIMEOUT_SECS}s (last: ${stack_status})" >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
sleep 10
|
|
42
|
+
done
|
|
43
|
+
elif echo "$UPDATE_OUT" | grep -q "No updates are to be performed"; then
|
|
44
|
+
echo " -> the stack already has ${TAG}; nothing to update"
|
|
45
|
+
else
|
|
46
|
+
echo "$UPDATE_OUT" >&2
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
echo "==> checking that only ${CFN_PARAM_KEY} changed"
|
|
51
|
+
stack_parameters > "${SCRATCH_DIR}/params-after.json"
|
|
52
|
+
CHANGED="$(jq -rn --slurpfile before "${SCRATCH_DIR}/params-before.json" --slurpfile after "${SCRATCH_DIR}/params-after.json" '
|
|
53
|
+
($before[0] | map({(.k): .v}) | add // {}) as $b
|
|
54
|
+
| ($after[0] | map({(.k): .v}) | add // {}) as $a
|
|
55
|
+
| [($b + $a) | keys[] | select($b[.] != $a[.])] | join(" ")')"
|
|
56
|
+
NOW="$(jq -r --arg k "$CFN_PARAM_KEY" '.[] | select(.k == $k) | .v' "${SCRATCH_DIR}/params-after.json")"
|
|
57
|
+
if [ "$NOW" != "$TAG" ] || { [ -n "$CHANGED" ] && [ "$CHANGED" != "$CFN_PARAM_KEY" ]; }; then
|
|
58
|
+
echo "expected only ${CFN_PARAM_KEY} to change to ${TAG}; it is ${NOW} and changed: ${CHANGED:-nothing}" >&2
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
echo " -> ${CFN_PARAM_KEY} is ${TAG}; no other parameter changed"
|
|
62
|
+
|
|
63
|
+
# The newest active revision of the family is what the stack just registered. Roll it only if it
|
|
64
|
+
# really carries the image pushed above: a stack update that registered something else would
|
|
65
|
+
# otherwise roll an old image and still look like a deploy.
|
|
66
|
+
TD="$(aws ecs describe-task-definition --task-definition "$FAMILY" --region "$REGION" \
|
|
67
|
+
--query 'taskDefinition.[family,revision]' --output text | tr '\t' ':')"
|
|
68
|
+
RUNNING_IMAGE="$(aws ecs describe-task-definition --task-definition "$TD" --region "$REGION" \
|
|
69
|
+
--query "taskDefinition.containerDefinitions[?name=='${SERVICE_NAME}'].image | [0]" --output text)"
|
|
70
|
+
if [ "$RUNNING_IMAGE" != "$IMAGE_URI" ]; then
|
|
71
|
+
echo "task definition ${TD} has ${RUNNING_IMAGE}, not ${IMAGE_URI}; refusing to roll" >&2
|
|
72
|
+
exit 1
|
|
73
|
+
fi
|
|
74
|
+
echo "==> task definition ${TD} carries ${SERVICE_NAME}=${TAG}"
|
|
75
|
+
|
|
76
|
+
bash ./deploy-box.sh "$TD"
|
|
77
|
+
export TASKDEF="$TD"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# The active task definition names the image repository of every container, so the push goes where
|
|
2
|
+
# the box already pulls from.
|
|
3
|
+
active_image() {
|
|
4
|
+
aws ecs describe-task-definition --task-definition "$FAMILY" --region "$REGION" \
|
|
5
|
+
--query "taskDefinition.containerDefinitions[?name=='${SERVICE_NAME}'].image | [0]" --output text
|
|
6
|
+
}
|
|
7
|
+
CURRENT_IMAGE="$(active_image)"
|
|
8
|
+
case "$CURRENT_IMAGE" in
|
|
9
|
+
None|"") echo "task definition family ${FAMILY} has no container named ${SERVICE_NAME}" >&2; exit 1 ;;
|
|
10
|
+
esac
|
|
11
|
+
ECR_REPOSITORY="${CURRENT_IMAGE#*/}"
|
|
12
|
+
ECR_REPOSITORY="${ECR_REPOSITORY%%[:@]*}"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# GENERATED by hecks deploy project -- re-run it to refresh this file rather than hand-editing.
|
|
2
|
+
# Included by the Makefile beside it; settings come from the domain's deployed_to("AwsBox") block
|
|
3
|
+
# (hosting_scripts true).
|
|
4
|
+
|
|
5
|
+
HECKS ?= hecks
|
|
6
|
+
SERVICE ?= @@SERVICE@@
|
|
7
|
+
URL ?= @@URL@@
|
|
8
|
+
|
|
9
|
+
.PHONY: deploy-service smoke-after-deploy check-era
|
|
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.
|
|
13
|
+
deploy-service:
|
|
14
|
+
bash ./deploy-service.sh $(SERVICE)
|
|
15
|
+
|
|
16
|
+
# Waits for the last roll to settle, then dispatches and follows the smoke.
|
|
17
|
+
# SKIP_POST_DEPLOY_SMOKE=1 opts out, DRY_RUN=1 dispatches nothing.
|
|
18
|
+
smoke-after-deploy:
|
|
19
|
+
bash ./smoke-after-deploy.sh
|
|
20
|
+
|
|
21
|
+
# Compares the era a live host reports at GET /version with expected-era.
|
|
22
|
+
check-era:
|
|
23
|
+
@test -n "$(URL)" || { echo "usage: make check-era URL=https://host.example"; exit 1; }
|
|
24
|
+
HECKS_ENVIRONMENT=memory $(HECKS) host.check_era host="$(URL)" expected=expected-era --wait
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
# are replaced with the RDS stack's values where the task defines them, and every container runs on
|
|
10
10
|
# the box's own network, so the proxy reaches each one on 127.0.0.1:<port>. Secrets (name + valueFrom)
|
|
11
11
|
# are written to secrets.json for fetch-secrets.sh to resolve on the box.
|
|
12
|
+
#
|
|
13
|
+
# When the world names origin_env, the named origin secret is compared with those variables in the
|
|
14
|
+
# task definition and the render refuses on any difference (Caddy checks the CDN's header against
|
|
15
|
+
# the secret; the containers check it against their own copy). Values are never printed.
|
|
12
16
|
set -euo pipefail
|
|
13
17
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
|
14
18
|
DB_HOST=${1:?rds endpoint}
|
|
@@ -18,6 +22,19 @@ TD=${3:-@@FAMILY@@}
|
|
|
18
22
|
DEF=$(aws ecs describe-task-definition --task-definition "$TD" --query 'taskDefinition.containerDefinitions' --output json)
|
|
19
23
|
echo "rendering from task definition $TD" >&2
|
|
20
24
|
|
|
25
|
+
ORIGIN_ENV=$(jq -r '.origin.env // [] | .[]' "$HERE/services.json")
|
|
26
|
+
if [ -n "$ORIGIN_ENV" ]; then
|
|
27
|
+
ORIGIN_SECRET_ID=$(jq -r '.origin.secret' "$HERE/services.json")
|
|
28
|
+
ORIGIN_VALUE=$(aws secretsmanager get-secret-value --secret-id "$ORIGIN_SECRET_ID" --query SecretString --output text)
|
|
29
|
+
for NAME in $ORIGIN_ENV; do
|
|
30
|
+
HOLDERS=$(echo "$DEF" | jq -r --arg n "$NAME" '[.[] | select(any((.environment // [])[]; .name == $n)) | .name] | join(" ")')
|
|
31
|
+
[ -n "$HOLDERS" ] || { echo "refusing to render: origin_env names $NAME, which no container in $TD sets" >&2; exit 1; }
|
|
32
|
+
DIFFERENT=$(echo "$DEF" | jq -r --arg n "$NAME" --arg v "$ORIGIN_VALUE" \
|
|
33
|
+
'[.[] | select(any((.environment // [])[]; .name == $n and .value != $v)) | .name] | join(" ")')
|
|
34
|
+
[ -z "$DIFFERENT" ] || { echo "refusing to render: $NAME in $TD (container: $DIFFERENT) differs from the origin secret $ORIGIN_SECRET_ID; the proxy would refuse every request from the CDN. Make them equal." >&2; exit 1; }
|
|
35
|
+
done
|
|
36
|
+
fi
|
|
37
|
+
|
|
21
38
|
jq --argjson def "$DEF" --arg host "$DB_HOST" --arg secret "$DB_SECRET" '
|
|
22
39
|
def log: {driver: "json-file", options: {"max-size": "10m", "max-file": "3"}};
|
|
23
40
|
def env(c): ((c.environment // []) | map({(.name): .value}) | add // {})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GENERATED by hecks deploy project -- re-run it to refresh this file rather than hand-editing.
|
|
3
|
+
# Settings come from the domain's deployed_to("AwsBox") block (hosting_scripts true).
|
|
4
|
+
#
|
|
5
|
+
# Runs the production smoke once a roll of @@STACK@@'s app box has settled, so a deploy ends with a real
|
|
6
|
+
# check of the live site instead of waiting for the next scheduled run. Called at the end of
|
|
7
|
+
# deploy-service.sh and of `make deploy`; also runnable by hand:
|
|
8
|
+
#
|
|
9
|
+
# bash smoke-after-deploy.sh
|
|
10
|
+
#
|
|
11
|
+
# What it does, in order:
|
|
12
|
+
# 1. Waits for the roll to settle: two consecutive checks, one interval apart, must agree that
|
|
13
|
+
@@SETTLE_CHECKS@@
|
|
14
|
+
# Gives up after the timeout. The box is read through SSM, the way deploy-box.sh rolls it.
|
|
15
|
+
# 2. Dispatches the smoke workflow on the configured ref, finds the run that dispatch created (a
|
|
16
|
+
# workflow_dispatch run that did not exist before the dispatch, never a scheduled one), waits for it,
|
|
17
|
+
# and prints its conclusion, the failed check names and its URL.
|
|
18
|
+
#
|
|
19
|
+
# It only ever READS AWS; the smoke itself runs on GitHub against the live site.
|
|
20
|
+
#
|
|
21
|
+
# Exit codes (chosen to stay clear of the ones a failed deploy step produces: 1 from bash, 2 from bash
|
|
22
|
+
# misuse, 254/255 from the aws CLI):
|
|
23
|
+
# 0 the smoke passed, was skipped on purpose (SKIP_POST_DEPLOY_SMOKE=1), or was left running
|
|
24
|
+
# (SMOKE_ASYNC=1)
|
|
25
|
+
# 20 the roll did not settle in time, or a stack ended in a failed state; the smoke was NOT run
|
|
26
|
+
# 21 `gh` is missing or not signed in, or no repository is configured; the smoke was NOT run (the
|
|
27
|
+
# manual command is printed)
|
|
28
|
+
# 22 the smoke ran and FAILED
|
|
29
|
+
# 23 the smoke could not be dispatched, found or finished; result unknown
|
|
30
|
+
#
|
|
31
|
+
# Environment:
|
|
32
|
+
# SKIP_POST_DEPLOY_SMOKE=1 skip everything and say so
|
|
33
|
+
# SMOKE_ASYNC=1 dispatch the smoke and return as soon as its run is found, printing the
|
|
34
|
+
# run URL, instead of waiting for it to finish (exit 0: the result is NOT
|
|
35
|
+
# known yet)
|
|
36
|
+
# DRY_RUN=1 wait for the roll as usual, then print what would be dispatched without
|
|
37
|
+
# dispatching anything
|
|
38
|
+
# SETTLE_TIMEOUT_SECS default 1200
|
|
39
|
+
# SETTLE_CHECK_INTERVAL_SECS default 5
|
|
40
|
+
# SMOKE_RUN_TIMEOUT_SECS default 1800, how long to wait for the run
|
|
41
|
+
# SSM_POLL_SECS default 2, how often to ask SSM whether a command on the box finished
|
|
42
|
+
# REPO GitHub repository (owner/name) holding the workflow
|
|
43
|
+
# WORKFLOW workflow file name
|
|
44
|
+
# SMOKE_REF ref to dispatch on
|
|
45
|
+
# TASKDEF the task definition the box should run (family or family:revision);
|
|
46
|
+
# default: the latest revision of the family. deploy-service.sh and
|
|
47
|
+
# `make deploy TASKDEF=...` pass the one they rolled.
|
|
48
|
+
set -uo pipefail
|
|
49
|
+
|
|
50
|
+
@@SETTINGS@@
|
|
51
|
+
|
|
52
|
+
SETTLE_TIMEOUT_SECS="${SETTLE_TIMEOUT_SECS:-1200}"
|
|
53
|
+
SETTLE_CHECK_INTERVAL_SECS="${SETTLE_CHECK_INTERVAL_SECS:-5}"
|
|
54
|
+
SMOKE_RUN_TIMEOUT_SECS="${SMOKE_RUN_TIMEOUT_SECS:-1800}"
|
|
55
|
+
SSM_POLL_SECS="${SSM_POLL_SECS:-2}"
|
|
56
|
+
MANUAL_COMMAND="bash smoke-after-deploy.sh"
|
|
57
|
+
|
|
58
|
+
if [ "${SKIP_POST_DEPLOY_SMOKE:-}" = "1" ]; then
|
|
59
|
+
echo "==> post-deploy smoke SKIPPED (SKIP_POST_DEPLOY_SMOKE=1)."
|
|
60
|
+
echo " Run it yourself when ready: ${MANUAL_COMMAND}"
|
|
61
|
+
exit 0
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if ! command -v gh >/dev/null 2>&1 || ! gh auth status >/dev/null 2>&1; then
|
|
65
|
+
echo "==> post-deploy smoke NOT RUN: the GitHub CLI (gh) is missing or not signed in." >&2
|
|
66
|
+
echo " Run it by hand: gh workflow run ${WORKFLOW} --ref ${SMOKE_REF}" >&2
|
|
67
|
+
echo " or, once gh works: ${MANUAL_COMMAND}" >&2
|
|
68
|
+
exit 21
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if [ -z "$REPO" ]; then
|
|
72
|
+
REPO="$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || true)"
|
|
73
|
+
fi
|
|
74
|
+
if [ -z "$REPO" ]; then
|
|
75
|
+
echo "==> post-deploy smoke NOT RUN: no GitHub repository is configured. Set REPO=owner/name, or the" >&2
|
|
76
|
+
echo " smoke_repo setting, or run from a clone gh can read." >&2
|
|
77
|
+
exit 21
|
|
78
|
+
fi
|
|
79
|
+
MANUAL_DISPATCH="gh workflow run ${WORKFLOW} --repo ${REPO} --ref ${SMOKE_REF}"
|
|
80
|
+
|
|
81
|
+
# The status of a stack, or nothing when it cannot be read.
|
|
82
|
+
stack_status() {
|
|
83
|
+
aws cloudformation describe-stacks --stack-name "$1" --region "$REGION" \
|
|
84
|
+
--query 'Stacks[0].StackStatus' --output text 2>/dev/null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Runs a script on the box over SSM and prints its output; fails when it does not succeed.
|
|
88
|
+
box_run() {
|
|
89
|
+
local payload command_id state
|
|
90
|
+
payload="$(jq -n --arg c "$1" '{commands: [$c]}')"
|
|
91
|
+
command_id="$(aws ssm send-command --instance-ids "$BOX_ID" --document-name AWS-RunShellScript \
|
|
92
|
+
--region "$REGION" --parameters "$payload" --query 'Command.CommandId' --output text 2>/dev/null)" || return 1
|
|
93
|
+
for _ in $(seq 1 60); do
|
|
94
|
+
sleep "$SSM_POLL_SECS"
|
|
95
|
+
state="$(aws ssm get-command-invocation --command-id "$command_id" --instance-id "$BOX_ID" \
|
|
96
|
+
--region "$REGION" --query Status --output text 2>/dev/null || echo Pending)"
|
|
97
|
+
[ "$state" != InProgress ] && [ "$state" != Pending ] && break
|
|
98
|
+
done
|
|
99
|
+
[ "$state" = Success ] || return 1
|
|
100
|
+
aws ssm get-command-invocation --command-id "$command_id" --instance-id "$BOX_ID" --region "$REGION" \
|
|
101
|
+
--query StandardOutputContent --output text
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# Prints "ok <signature>" when the box looks settled, otherwise "wait <why>". The signature names
|
|
105
|
+
# every image the box runs, so two checks agree only when nothing changed between them.
|
|
106
|
+
box_state() {
|
|
107
|
+
local report compose ps name image wanted signature
|
|
108
|
+
report="$(box_run "cd ${BOX_DIR} && cat compose.json && echo ===PS=== && docker compose -f compose.json ps --format '{{.Service}} {{.Status}}'")" \
|
|
109
|
+
|| { echo "wait could not read the box"; return; }
|
|
110
|
+
compose="${report%%===PS===*}"
|
|
111
|
+
ps="${report#*===PS===}"
|
|
112
|
+
for name in $EXPECTED_SERVICES; do
|
|
113
|
+
printf '%s\n' "$ps" | grep -E "^${name} Up " | grep -qvE " Up (Less than a second|[0-4] seconds?)|Restarting" \
|
|
114
|
+
|| { echo "wait ${name} is not up and settled"; return; }
|
|
115
|
+
done
|
|
116
|
+
signature=""
|
|
117
|
+
for name in $EXPECTED_SERVICES; do
|
|
118
|
+
image="$(printf '%s' "$compose" | jq -r --arg n "$name" '.services[$n].image // empty' 2>/dev/null)"
|
|
119
|
+
[ "$name" = caddy ] || [ -n "$image" ] || { echo "wait ${name} is not in the box's Compose file"; return; }
|
|
120
|
+
if [ -n "$TASK_DEFINITION" ] && [ "$name" != caddy ]; then
|
|
121
|
+
wanted="$(aws ecs describe-task-definition --task-definition "$TASK_DEFINITION" --region "$REGION" \
|
|
122
|
+
--query "taskDefinition.containerDefinitions[?name=='${name}'].image | [0]" --output text 2>/dev/null)"
|
|
123
|
+
[ "$image" = "$wanted" ] || { echo "wait ${name} runs ${image}, ${TASK_DEFINITION} has ${wanted}"; return; }
|
|
124
|
+
fi
|
|
125
|
+
signature="${signature}${name}=${image} "
|
|
126
|
+
done
|
|
127
|
+
echo "ok ${signature}"
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
wait_for_settled_roll() {
|
|
131
|
+
echo "==> waiting for the roll to settle (box ${BOX_STACK}@@IMAGE_STACK_WAIT@@; timeout ${SETTLE_TIMEOUT_SECS}s)"
|
|
132
|
+
local start=$SECONDS previous="" status stacks state signature why s
|
|
133
|
+
BOX_ID="$(aws cloudformation describe-stacks --stack-name "$BOX_STACK" --region "$REGION" \
|
|
134
|
+
--query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" --output text 2>/dev/null)"
|
|
135
|
+
if [ -z "$BOX_ID" ] || [ "$BOX_ID" = None ]; then
|
|
136
|
+
echo "==> no instance in stack ${BOX_STACK}. The smoke was NOT run." >&2
|
|
137
|
+
return 20
|
|
138
|
+
fi
|
|
139
|
+
while true; do
|
|
140
|
+
stacks=""
|
|
141
|
+
for s in $WATCHED_STACKS; do
|
|
142
|
+
status="$(stack_status "$s")"
|
|
143
|
+
case "$status" in
|
|
144
|
+
*ROLLBACK*|*FAILED*)
|
|
145
|
+
echo "==> roll did not settle: stack ${s} is ${status}. The smoke was NOT run." >&2
|
|
146
|
+
return 20 ;;
|
|
147
|
+
esac
|
|
148
|
+
stacks="${stacks}${s}=${status:-unreadable} "
|
|
149
|
+
done
|
|
150
|
+
if printf '%s' "$stacks" | tr ' ' '\n' | grep -v '^$' | grep -qvE '=(CREATE|UPDATE)_COMPLETE$'; then
|
|
151
|
+
state="wait ${stacks}"
|
|
152
|
+
else
|
|
153
|
+
state="$(box_state)"
|
|
154
|
+
fi
|
|
155
|
+
if [ "${state%% *}" = "ok" ]; then
|
|
156
|
+
signature="${stacks}${state#ok }"
|
|
157
|
+
if [ "$signature" = "$previous" ]; then
|
|
158
|
+
echo " -> settled on ${state#ok } (two consecutive checks agree)"
|
|
159
|
+
return 0
|
|
160
|
+
fi
|
|
161
|
+
echo " -> looks settled (${state#ok }); confirming in ${SETTLE_CHECK_INTERVAL_SECS}s"
|
|
162
|
+
previous="$signature"
|
|
163
|
+
else
|
|
164
|
+
why="${state#wait }"
|
|
165
|
+
echo " -> not yet: ${why}"
|
|
166
|
+
previous=""
|
|
167
|
+
fi
|
|
168
|
+
if [ $((SECONDS - start + SETTLE_CHECK_INTERVAL_SECS)) -gt "$SETTLE_TIMEOUT_SECS" ]; then
|
|
169
|
+
echo "==> roll did not settle within ${SETTLE_TIMEOUT_SECS}s (last: ${state#wait }). The smoke was NOT run." >&2
|
|
170
|
+
return 20
|
|
171
|
+
fi
|
|
172
|
+
sleep "$SETTLE_CHECK_INTERVAL_SECS"
|
|
173
|
+
done
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
# Ids of the workflow_dispatch runs that exist now, one per line.
|
|
177
|
+
dispatch_run_ids() {
|
|
178
|
+
gh run list --repo "$REPO" --workflow "$WORKFLOW" --event workflow_dispatch --limit 50 \
|
|
179
|
+
--json databaseId --jq '.[].databaseId' 2>/dev/null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
print_failed_checks() {
|
|
183
|
+
local run_id="$1" log names
|
|
184
|
+
log="$(gh run view "$run_id" --repo "$REPO" --log 2>/dev/null || true)"
|
|
185
|
+
# Log lines are "<job>\t<step>\t<timestamp> <text>"; a smoke that prints " <check>... FAILED: <why>"
|
|
186
|
+
# for each failing check is listed by name.
|
|
187
|
+
names="$(printf '%s\n' "$log" | awk -F'\t' 'NF >= 3 { print $3 }' | sed -E 's/^[0-9T:.-]+Z //' \
|
|
188
|
+
| grep -E '\.\.\. FAILED:' | sed -E 's/^ +//' || true)"
|
|
189
|
+
if [ -n "$names" ]; then
|
|
190
|
+
echo " failed checks:"
|
|
191
|
+
printf '%s\n' "$names" | sed 's/^/ - /'
|
|
192
|
+
else
|
|
193
|
+
echo " (no failed check names in the log; the run may have failed before the checks. Last log lines:)"
|
|
194
|
+
printf '%s\n' "$log" | tail -n 15 | sed 's/^/ /'
|
|
195
|
+
fi
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
run_smoke() {
|
|
199
|
+
local before run_id="" waited=0 url conclusion status
|
|
200
|
+
if ! before="$(dispatch_run_ids)"; then
|
|
201
|
+
echo "==> could not list the smoke workflow's runs, so it was NOT dispatched (it could not be told apart afterwards). Run it by hand: ${MANUAL_DISPATCH}" >&2
|
|
202
|
+
return 23
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
echo "==> dispatching the production smoke: ${MANUAL_DISPATCH}"
|
|
206
|
+
if ! gh workflow run "$WORKFLOW" --repo "$REPO" --ref "$SMOKE_REF"; then
|
|
207
|
+
echo "==> could not dispatch the smoke workflow. Deploy succeeded; run it by hand: ${MANUAL_DISPATCH}" >&2
|
|
208
|
+
return 23
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
# The dispatch call does not return the run, so find the workflow_dispatch run that was not there
|
|
212
|
+
# before it (scheduled runs are never candidates).
|
|
213
|
+
while [ -z "$run_id" ] && [ "$waited" -lt 180 ]; do
|
|
214
|
+
sleep 2
|
|
215
|
+
waited=$((waited + 2))
|
|
216
|
+
run_id="$(comm -13 <(printf '%s\n' "$before" | sort) <(dispatch_run_ids | sort) | sort -n | tail -n 1)"
|
|
217
|
+
done
|
|
218
|
+
if [ -z "$run_id" ]; then
|
|
219
|
+
echo "==> dispatched, but the new run never appeared. Deploy succeeded; check https://github.com/${REPO}/actions/workflows/${WORKFLOW}" >&2
|
|
220
|
+
return 23
|
|
221
|
+
fi
|
|
222
|
+
url="https://github.com/${REPO}/actions/runs/${run_id}"
|
|
223
|
+
echo "==> smoke run ${run_id}: ${url}"
|
|
224
|
+
if [ "${SMOKE_ASYNC:-}" = "1" ]; then
|
|
225
|
+
echo "==> deploy succeeded; the smoke is still running (SMOKE_ASYNC=1), so its result is NOT known yet."
|
|
226
|
+
echo " Watch it: gh run watch ${run_id} --repo ${REPO} or open ${url}"
|
|
227
|
+
return 0
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
waited=0
|
|
231
|
+
while true; do
|
|
232
|
+
status="$(gh run view "$run_id" --repo "$REPO" --json status --jq .status 2>/dev/null || true)"
|
|
233
|
+
[ "$status" = "completed" ] && break
|
|
234
|
+
if [ "$waited" -ge "$SMOKE_RUN_TIMEOUT_SECS" ]; then
|
|
235
|
+
echo "==> deploy succeeded, but the smoke run did not finish within ${SMOKE_RUN_TIMEOUT_SECS}s: see ${url}" >&2
|
|
236
|
+
return 23
|
|
237
|
+
fi
|
|
238
|
+
sleep 5
|
|
239
|
+
waited=$((waited + 5))
|
|
240
|
+
done
|
|
241
|
+
|
|
242
|
+
conclusion="$(gh run view "$run_id" --repo "$REPO" --json conclusion --jq .conclusion || true)"
|
|
243
|
+
echo "==> smoke conclusion: ${conclusion} ${url}"
|
|
244
|
+
if [ "$conclusion" = "success" ]; then
|
|
245
|
+
echo "==> deploy succeeded, post-deploy smoke passed."
|
|
246
|
+
return 0
|
|
247
|
+
fi
|
|
248
|
+
print_failed_checks "$run_id"
|
|
249
|
+
echo "==> deploy succeeded, post-deploy smoke FAILED: see ${url}" >&2
|
|
250
|
+
return 22
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
rc=0
|
|
254
|
+
wait_for_settled_roll || rc=$?
|
|
255
|
+
[ "$rc" -eq 0 ] || exit "$rc"
|
|
256
|
+
|
|
257
|
+
if [ "${DRY_RUN:-}" = "1" ]; then
|
|
258
|
+
echo "==> DRY_RUN=1: would dispatch: ${MANUAL_DISPATCH}"
|
|
259
|
+
newest="$(dispatch_run_ids | sort -n | tail -n 1 || true)"
|
|
260
|
+
if [ -n "$newest" ]; then
|
|
261
|
+
echo " then pick the workflow_dispatch run that appears after it; the newest existing one is"
|
|
262
|
+
echo " https://github.com/${REPO}/actions/runs/${newest} (not the one it would wait for)"
|
|
263
|
+
else
|
|
264
|
+
echo " (no workflow_dispatch runs exist yet)"
|
|
265
|
+
fi
|
|
266
|
+
echo " then wait for it and report its conclusion and failed checks. Nothing dispatched."
|
|
267
|
+
exit 0
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
run_smoke || rc=$?
|
|
271
|
+
exit "$rc"
|