hecks 3.1.3 → 3.2.1
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/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
- data/lib/hecks/deploy/oidc.json +15 -0
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +43 -4
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
- data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
- data/lib/hecks/hecks/hecks.hecksagon +8 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/settings.rb +23 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box.rb +13 -2
- 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/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/rust_build/kernel_input.rb +18 -6
- data/lib/hecks/rust_build/project_rust.rb +6 -132
- data/lib/hecks/site/bluebook/site.bluebook +27 -13
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/deploy_recipe.rb +1 -0
- data/lib/hecks/tools/rust_comment_style.rb +1 -1
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -1
- data/lib/hecks.rb +1 -0
- data/rust/build/src/lineage_pass.rs +1 -1
- data/rust/build/src/pipeline.rs +1 -1
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +1 -1
- 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_codec.rs +1 -1
- data/rust/codegen/src/main.rs +3 -4
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +1 -1
- 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/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/auth.rs +25 -1
- data/rust/host/src/needs.rs +94 -27
- data/rust/host/src/web.rs +9 -5
- 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 +2 -2
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +5 -23
- 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
|
@@ -368,6 +368,14 @@ Hecks.hecksagon "Hecks" do
|
|
|
368
368
|
end
|
|
369
369
|
end
|
|
370
370
|
|
|
371
|
+
Deploy::CostCheck.port "CostExplorer" do
|
|
372
|
+
asks "Measure", to: CostCheck do
|
|
373
|
+
attribute :run, RunKey
|
|
374
|
+
answers "CostAnswered"
|
|
375
|
+
refuses "CostRefused"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
371
379
|
Deploy::TemplateComparison.port "DeployToolchain" do
|
|
372
380
|
asks "Compare", to: TemplateComparison do
|
|
373
381
|
attribute :run, RunKey
|
|
@@ -625,16 +625,16 @@ Hecks.bluebook "Bluebook" do
|
|
|
625
625
|
pattern: '^lib/hecks/runtime/', push: "skip",
|
|
626
626
|
label: "does this change touch lib/hecks/runtime/**?"
|
|
627
627
|
member name: "postgres_io_relevant_changed", workflow: "ci-postgres-io-parallel.yml", mode: "skips_unless",
|
|
628
|
-
pattern: '^(docs/|editors/|release/|deploy/|\.claude/|\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests
|
|
628
|
+
pattern: '^(docs/|editors/|release/|deploy/|\.claude/|\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests)/|\.rubocop\.yml$|\.rubocop_todo\.yml$|\.mcp\.json$|\.rspec-local\.example$|README\.md$|CHANGELOG\.md$|CONTRIBUTING\.md$|SECURITY\.md$|LICENSE$|\.gitignore$)',
|
|
629
629
|
push: "before_sha",
|
|
630
630
|
label: "does this change touch anything rspec_postgres_io_parallel covers?"
|
|
631
631
|
end
|
|
632
632
|
|
|
633
633
|
# Rust keywords (strict + reserved) a generated identifier must not
|
|
634
|
-
# collide with.
|
|
635
|
-
#
|
|
636
|
-
#
|
|
637
|
-
#
|
|
634
|
+
# collide with. ModelCheck reads this; hecks project_reserved_names
|
|
635
|
+
# projects it into rust/codegen/src/reserved_names.rs, so Ruby and Rust
|
|
636
|
+
# escape or refuse the same words from one declaration instead of two
|
|
637
|
+
# hand-typed copies.
|
|
638
638
|
value_object "RustReservedWord" do
|
|
639
639
|
attribute :word, String,
|
|
640
640
|
one_of: ["as", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", "false",
|
|
@@ -648,8 +648,8 @@ Hecks.bluebook "Bluebook" do
|
|
|
648
648
|
# as a Cargo `[features]` key: every key rust/Cargo.toml already uses
|
|
649
649
|
# outside `[features]` (`[package]`, `[lib]`, `[[bin]]`) plus `default`,
|
|
650
650
|
# which Cargo reserves for the auto-enabled feature set. Read by
|
|
651
|
-
#
|
|
652
|
-
#
|
|
651
|
+
# Hecks::RustBuild::DomainName and projected by hecks
|
|
652
|
+
# project_reserved_names, same as RustReservedWord above.
|
|
653
653
|
value_object "CargoReservedName" do
|
|
654
654
|
attribute :name, String,
|
|
655
655
|
one_of: ["name", "version", "edition", "path", "authors", "license", "description", "default",
|
|
@@ -83,7 +83,7 @@ Hecks.bluebook "Hecksagon" do
|
|
|
83
83
|
member word: "hecksagon", context: "File", body: "keywords", inner: "Hecksagon", opens: "Hecksagon", fills: ""
|
|
84
84
|
member word: "subscribe", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "subscriptions"
|
|
85
85
|
member word: "attaches", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments"
|
|
86
|
-
# Deprecated spellings of `attaches`; they warn and are removed in 3.
|
|
86
|
+
# Deprecated spellings of `attaches`; they warn and are removed in 3.3.0.
|
|
87
87
|
member word: "uses_framework", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments", status: "deprecated"
|
|
88
88
|
member word: "uses_embryonaut_bluebook", context: "Hecksagon", body: "none", inner: "", opens: "", fills: "attachments", status: "deprecated"
|
|
89
89
|
member word: "port", context: "Hecksagon", body: "keywords", inner: "DomainPort", opens: "DomainPort", fills: "", calls: "port_impl"
|
|
@@ -58,7 +58,7 @@ module Hecks
|
|
|
58
58
|
# @return [String] the `era` the host's `/version` document carries
|
|
59
59
|
# @raise [Unreachable] if the request fails or the status is not `200`
|
|
60
60
|
# @raise [BadResponse] if the body is not JSON or its `era` is not a non-empty string
|
|
61
|
-
def fetch_era(url, timeout:
|
|
61
|
+
def fetch_era(url, timeout:)
|
|
62
62
|
fetch_version(url, timeout: timeout).fetch("era")
|
|
63
63
|
end
|
|
64
64
|
|
|
@@ -70,7 +70,7 @@ module Hecks
|
|
|
70
70
|
# host reports none)
|
|
71
71
|
# @raise [Unreachable] if the request fails or the status is not `200`
|
|
72
72
|
# @raise [BadResponse] if the body is not JSON or its `era` is not a non-empty string
|
|
73
|
-
def fetch_version(url, timeout:
|
|
73
|
+
def fetch_version(url, timeout:)
|
|
74
74
|
response = get(version_url(url), timeout)
|
|
75
75
|
raise Unreachable, "#{version_url(url)} answered #{response.code}" unless response.code == "200"
|
|
76
76
|
|
|
@@ -105,7 +105,7 @@ module Hecks
|
|
|
105
105
|
# @raise [Errno::ENOENT] if the allow-list file does not exist
|
|
106
106
|
# @raise [Unreachable] see `fetch_era`
|
|
107
107
|
# @raise [BadResponse] see `fetch_era`
|
|
108
|
-
def check(url, file, timeout:
|
|
108
|
+
def check(url, file, timeout:)
|
|
109
109
|
allowed = parse(File.read(file))
|
|
110
110
|
verdict(fetch_era(url, timeout: timeout), allowed)
|
|
111
111
|
end
|
|
@@ -34,6 +34,8 @@ module Hecks
|
|
|
34
34
|
"@sha256:226d1f059b75399fe19182893c7184591c07b97afc8dfcf44eeb80c9a77a530f".freeze
|
|
35
35
|
TUNNEL_SHAPE = "tunnel: a hash needs `to` (the container it forwards to) and `token_secret` " \
|
|
36
36
|
"(the secret holding the tunnel token)".freeze
|
|
37
|
+
ORIGIN_ENV_SHAPE = "origin_env: the container environment variable names that hold the origin secret " \
|
|
38
|
+
"need an origin_secret and a task_definition to compare against".freeze
|
|
37
39
|
ORIGIN_PAIR = "origin_header and origin_secret go together: the header a CDN sends, " \
|
|
38
40
|
"and the secret that holds its value".freeze
|
|
39
41
|
|
|
@@ -79,7 +81,7 @@ module Hecks
|
|
|
79
81
|
:infra_name, :stack_prefix, :instance_type, :volume_gb, :swap_gb, :database_class,
|
|
80
82
|
:storage_gb, :backup_days, :snapshots_keep, :database_name, :engine_version,
|
|
81
83
|
:containers, :routes, :default_container, :origin_header, :origin_secret,
|
|
82
|
-
:secret_prefixes, :writable_secrets, :tunnel, :tunnel_service, :proxy_image, :task_definition,
|
|
84
|
+
:secret_prefixes, :writable_secrets, :origin_env, :tunnel, :tunnel_service, :proxy_image, :task_definition,
|
|
83
85
|
:migration, :s3_buckets, keyword_init: true
|
|
84
86
|
) do
|
|
85
87
|
# @return [String] the CloudFormation stack that holds the database
|
|
@@ -110,6 +112,7 @@ module Hecks
|
|
|
110
112
|
task_definition = read_task_definition(s[:task_definition], listed)
|
|
111
113
|
containers = read_containers(listed, infra_name)
|
|
112
114
|
header, secret = read_origin(s)
|
|
115
|
+
origin_env = read_origin_env(s, secret, task_definition)
|
|
113
116
|
tunnel, tunnel_service = read_tunnel(s.fetch(:tunnel, false), containers)
|
|
114
117
|
|
|
115
118
|
Plan.new(
|
|
@@ -118,7 +121,7 @@ module Hecks
|
|
|
118
121
|
engine_version: check(:engine_version, s.fetch(:engine_version, "16").to_s, ENGINE),
|
|
119
122
|
containers: containers, routes: read_routes(s.fetch(:routes, []), containers),
|
|
120
123
|
default_container: read_default(s[:default_container], containers), origin_header: header,
|
|
121
|
-
origin_secret: secret, secret_prefixes: read_prefixes(s, infra_name),
|
|
124
|
+
origin_secret: secret, origin_env: origin_env, secret_prefixes: read_prefixes(s, infra_name),
|
|
122
125
|
writable_secrets: read_writable_secrets(s),
|
|
123
126
|
tunnel: tunnel, tunnel_service: tunnel_service,
|
|
124
127
|
proxy_image: check(:proxy_image, s.fetch(:proxy_image, PROXY_IMAGE), IMAGE),
|
|
@@ -263,6 +266,24 @@ module Hecks
|
|
|
263
266
|
[check(:origin_header, header, HEADER), check(:origin_secret, secret, SECRET_NAME)]
|
|
264
267
|
end
|
|
265
268
|
|
|
269
|
+
# The container environment variables that hold the origin secret in the task
|
|
270
|
+
# definition. Only Caddy reads the named secret; the containers keep the task
|
|
271
|
+
# definition's copy, so a copy that differs makes every request through the CDN
|
|
272
|
+
# fail. Naming them lets deploy refuse a mismatch.
|
|
273
|
+
#
|
|
274
|
+
# @param settings [Hash{Symbol => Object}] the world's `AwsBox` settings
|
|
275
|
+
# @param secret [String, nil] the declared origin secret
|
|
276
|
+
# @param task_definition [String, nil] the declared task definition family
|
|
277
|
+
# @return [Array<String>] the variable names, empty when none are declared
|
|
278
|
+
# @raise [ArgumentError] when named without an origin secret and a task definition
|
|
279
|
+
def read_origin_env(settings, secret, task_definition)
|
|
280
|
+
names = Array(settings.fetch(:origin_env, []))
|
|
281
|
+
return [] if names.empty?
|
|
282
|
+
raise ArgumentError, ORIGIN_ENV_SHAPE if secret.nil? || task_definition.nil?
|
|
283
|
+
|
|
284
|
+
names.map { |name| check(:origin_env, name, ENV_KEY) }.uniq
|
|
285
|
+
end
|
|
286
|
+
|
|
266
287
|
# `tunnel true` only opens the outbound port; a hash also runs cloudflared as a service.
|
|
267
288
|
#
|
|
268
289
|
# @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,7 +63,8 @@ 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",
|
|
65
|
-
"
|
|
66
|
+
"[ \"$(sha256sum < Caddyfile)\" = \"$CADDY_BEFORE\" ] || docker compose -f compose.json restart caddy",
|
|
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
|
|
|
68
70
|
echo "==> rolling $BOX_STACK ($BOX) at $(date -u +%H:%M:%SZ)"
|
|
@@ -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 // {})
|
|
@@ -379,9 +379,19 @@ module Hecks
|
|
|
379
379
|
def route_blocks(plan)
|
|
380
380
|
routes = plan.routes.each_with_index.map do |route, i|
|
|
381
381
|
port = plan.containers.find { |c| c.name == route.container }.port
|
|
382
|
-
"@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n
|
|
382
|
+
"@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n#{upstream(port)}}\n\n"
|
|
383
383
|
end
|
|
384
|
-
"#{routes.join}handle {\n
|
|
384
|
+
"#{routes.join}handle {\n#{upstream(plan.default.port)}}\n"
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# One upstream. A request that arrives while its container is being replaced
|
|
388
|
+
# waits, and is tried again every quarter second for up to 15 seconds, so a
|
|
389
|
+
# deploy shows a visitor a slow page instead of a 502.
|
|
390
|
+
#
|
|
391
|
+
# @param port [Integer] the container's port
|
|
392
|
+
# @return [String] the `reverse_proxy` block, one tab in, ending in a newline
|
|
393
|
+
def upstream(port)
|
|
394
|
+
"\treverse_proxy 127.0.0.1:#{port} {\n\t\tlb_try_duration 15s\n\t\tlb_try_interval 250ms\n\t}\n"
|
|
385
395
|
end
|
|
386
396
|
|
|
387
397
|
# @param plan [Settings::Plan] the resolved settings
|
|
@@ -425,6 +435,7 @@ module Hecks
|
|
|
425
435
|
def services_json(plan)
|
|
426
436
|
services = plan.containers.to_h { |c| [c.name, service_entry(plan, c)] }
|
|
427
437
|
origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
|
|
438
|
+
origin["env"] = plan.origin_env if origin && plan.origin_env.any?
|
|
428
439
|
document = { "services" => services, "origin" => origin }
|
|
429
440
|
document["task_definition"] = plan.task_definition if plan.task_definition
|
|
430
441
|
document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
|
|
@@ -63,9 +63,11 @@ module Hecks
|
|
|
63
63
|
|
|
64
64
|
def reference(text) = text.nil? || text == "none" ? nil : Checks.resolve(text)
|
|
65
65
|
|
|
66
|
-
# CloudFront takes three sets of methods: read, read with `OPTIONS`, and all.
|
|
66
|
+
# CloudFront takes three sets of methods: read, read with `OPTIONS`, and all. A route's
|
|
67
|
+
# `edge_methods` are the verbs the edge lets through; they are its own `methods` unless
|
|
68
|
+
# the row says more.
|
|
67
69
|
def methods(rows)
|
|
68
|
-
verbs = rows.flat_map(&:
|
|
70
|
+
verbs = rows.flat_map(&:edge_verbs).uniq
|
|
69
71
|
return Deploy::Fargate::Cdn::METHODS.fetch("all") unless (verbs - ["GET"]).empty?
|
|
70
72
|
|
|
71
73
|
assets = rows.all? { |row| row.origin == "assets" }
|
|
@@ -76,7 +78,7 @@ module Hecks
|
|
|
76
78
|
# http and redirected; anything else (a form, an API, a signed or admin page, a route of
|
|
77
79
|
# the cms or the domain) is served over https only.
|
|
78
80
|
def protocol(row)
|
|
79
|
-
plain = row.auth == "public" && row.
|
|
81
|
+
plain = row.auth == "public" && row.edge_verbs == ["GET"] && %w[website assets].include?(row.origin)
|
|
80
82
|
plain ? "redirect-to-https" : "https-only"
|
|
81
83
|
end
|
|
82
84
|
|
|
@@ -28,7 +28,9 @@ module Hecks
|
|
|
28
28
|
# @param rows [Array<Table::Row>] the route table's rows
|
|
29
29
|
# @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
|
|
30
30
|
# @param problems [Array<String>] collects each problem found, one line each
|
|
31
|
-
|
|
31
|
+
# @param template_named [Boolean] whether the caller names the template itself
|
|
32
|
+
def initialize(edge, rows, vocabulary, problems, template_named: false)
|
|
33
|
+
@template_named = template_named
|
|
32
34
|
@edge = edge
|
|
33
35
|
@rows = rows
|
|
34
36
|
@vocabulary = vocabulary
|
|
@@ -48,15 +50,21 @@ module Hecks
|
|
|
48
50
|
|
|
49
51
|
def check_setting
|
|
50
52
|
setting = @edge.setting
|
|
51
|
-
return problem("Edge", "has no row
|
|
53
|
+
return problem("Edge", "has no row; declare one member with template:") unless setting
|
|
54
|
+
|
|
55
|
+
check_template(setting.template) if setting.template || !@template_named
|
|
52
56
|
|
|
53
|
-
template = setting.template.to_s
|
|
54
|
-
problem("Edge", "template #{template.inspect} must be a path inside the project") if
|
|
55
|
-
template.empty? || template.start_with?("/") || template.split("/").include?("..")
|
|
56
57
|
problem("Edge", "has secret_header but no secret_value") if setting.secret_header && !setting.secret_value
|
|
57
58
|
problem("Edge", "has secret_value but no secret_header") if setting.secret_value && !setting.secret_header
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
def check_template(template)
|
|
62
|
+
template = template.to_s
|
|
63
|
+
problem("Edge", "has no template; declare template: or name the file to the tool") if template.empty?
|
|
64
|
+
problem("Edge", "template #{template.inspect} must be a path inside the project") if
|
|
65
|
+
template.start_with?("/") || template.split("/").include?("..")
|
|
66
|
+
end
|
|
67
|
+
|
|
60
68
|
def check_policies
|
|
61
69
|
@edge.policies.each do |policy|
|
|
62
70
|
label = "EdgePolicy #{policy.cache_class}#{" on #{policy.origin}" if policy.origin}"
|
|
@@ -86,12 +94,30 @@ module Hecks
|
|
|
86
94
|
end
|
|
87
95
|
|
|
88
96
|
def check_rules
|
|
97
|
+
return check_no_alb unless @edge.alb?
|
|
98
|
+
|
|
89
99
|
problem("EdgeRule", "rows need an Edge row with listener:") if @edge.rules.any? && !@edge.setting&.listener
|
|
90
100
|
@edge.rules.each { |rule| check_rule(rule) }
|
|
91
101
|
repeated(@edge.rules.map(&:rule), "EdgeRule", &:itself)
|
|
92
102
|
repeated(@edge.rules.map(&:priority), "EdgeRule priority", &:to_s)
|
|
93
103
|
end
|
|
94
104
|
|
|
105
|
+
# With `alb: false` nothing routes by listener rule, so a rule row, or a route that names
|
|
106
|
+
# one, describes a load balancer the project says it does not have. Every route that is
|
|
107
|
+
# served by a server behind the edge still needs the origin that reaches it.
|
|
108
|
+
def check_no_alb
|
|
109
|
+
problem("EdgeRule", "rows describe a load balancer, and the Edge row says alb: false") if @edge.rules.any?
|
|
110
|
+
@rows.select(&:alb_rule).each do |row|
|
|
111
|
+
problem(row.path, "names alb_rule #{row.alb_rule}, and the Edge row says alb: false")
|
|
112
|
+
end
|
|
113
|
+
@rows.each do |row|
|
|
114
|
+
next if row.cdn || !%w[cms domain].include?(row.origin) || @edge.upstream(row.origin)
|
|
115
|
+
|
|
116
|
+
problem(row.path, "is served from #{row.origin}, which no EdgeOrigin maps, and with alb: false " \
|
|
117
|
+
"nothing else reaches it")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
95
121
|
def check_rule(rule)
|
|
96
122
|
label = "EdgeRule #{rule.rule}"
|
|
97
123
|
member_of(label, "origin", rule.origin, :origin)
|
|
@@ -19,7 +19,7 @@ module Hecks
|
|
|
19
19
|
# `EdgePolicy`, `EdgeOrigin` and `EdgeRule`. `docs/site-routes.md` lists their fields. A
|
|
20
20
|
# project that declares none of the four has no edge to project.
|
|
21
21
|
class Edge
|
|
22
|
-
Setting = Struct.new(:template, :listener, :secret_header, :secret_value, keyword_init: true)
|
|
22
|
+
Setting = Struct.new(:template, :listener, :secret_header, :secret_value, :alb, keyword_init: true)
|
|
23
23
|
Policy = Struct.new(:cache_class, :origin, :cache, :origin_request, :response_headers, keyword_init: true)
|
|
24
24
|
Upstream = Struct.new(:origin, :id, :target_group, keyword_init: true)
|
|
25
25
|
Rule = Struct.new(:rule, :priority, :origin, keyword_init: true)
|
|
@@ -29,15 +29,15 @@ module Hecks
|
|
|
29
29
|
|
|
30
30
|
# The fields each kind of row may carry, with the Ruby class each takes.
|
|
31
31
|
FIELDS = {
|
|
32
|
-
setting: { template: String, listener: String, secret_header: String, secret_value: String },
|
|
32
|
+
setting: { template: String, listener: String, secret_header: String, secret_value: String, alb: :bool },
|
|
33
33
|
policy: { cache_class: String, origin: String, cache: String, origin_request: String,
|
|
34
34
|
response_headers: String },
|
|
35
35
|
upstream: { origin: String, id: String, target_group: String },
|
|
36
36
|
rule: { rule: String, priority: Integer, origin: String }
|
|
37
37
|
}.freeze
|
|
38
38
|
|
|
39
|
-
# The fields a row of each kind must carry.
|
|
40
|
-
REQUIRED = { setting: [
|
|
39
|
+
# The fields a row of each kind must carry. The template may instead be named to the tool.
|
|
40
|
+
REQUIRED = { setting: [], policy: %i[cache_class cache], upstream: %i[origin id],
|
|
41
41
|
rule: %i[rule priority origin] }.freeze
|
|
42
42
|
|
|
43
43
|
STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule }.freeze
|
|
@@ -62,15 +62,17 @@ module Hecks
|
|
|
62
62
|
# @param chapter [Bluebook::Chapter] the chapter that declares the route table
|
|
63
63
|
# @param table [Table] the checked route table
|
|
64
64
|
# @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
|
|
65
|
+
# @param template [String, nil] the template the caller names, which stands in for the
|
|
66
|
+
# `Edge` row's own `template:`
|
|
65
67
|
# @return [Edge, nil] the checked edge, or nil when the chapter declares none
|
|
66
68
|
# @raise [Table::Invalid] when a row is refused or the edge contradicts the routes
|
|
67
|
-
def self.read(chapter, table:, vocabulary:)
|
|
69
|
+
def self.read(chapter, table:, vocabulary:, template: nil)
|
|
68
70
|
declared = OBJECTS.transform_values { |name| Table.rows_of(chapter, name) }
|
|
69
71
|
return nil if declared.values.all?(&:empty?)
|
|
70
72
|
|
|
71
73
|
problems = []
|
|
72
74
|
records = declared.to_h { |kind, members| [kind, build(kind, members, problems)] }
|
|
73
|
-
new(records, table: table, vocabulary: vocabulary, problems: problems)
|
|
75
|
+
new(records, table: table, vocabulary: vocabulary, problems: problems, template: template)
|
|
74
76
|
end
|
|
75
77
|
|
|
76
78
|
# @param kind [Symbol] a key of `OBJECTS`
|
|
@@ -86,27 +88,35 @@ module Hecks
|
|
|
86
88
|
end
|
|
87
89
|
(REQUIRED.fetch(kind) - member.keys).each { |field| problems << "#{label} needs #{field}" }
|
|
88
90
|
typed = member.slice(*FIELDS.fetch(kind).keys).select do |field, value|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
typed?(value, FIELDS.fetch(kind).fetch(field)) ||
|
|
92
|
+
(problems << "#{label} has #{field} #{value.inspect}; " \
|
|
93
|
+
"#{field} is #{type_name(FIELDS.fetch(kind).fetch(field))}")
|
|
91
94
|
end
|
|
92
95
|
STRUCTS.fetch(kind).new(**typed)
|
|
93
96
|
end
|
|
94
97
|
end
|
|
95
98
|
private_class_method :build
|
|
96
99
|
|
|
100
|
+
def self.typed?(value, type) = type == :bool ? [true, false].include?(value) : value.is_a?(type)
|
|
101
|
+
private_class_method :typed?
|
|
102
|
+
|
|
103
|
+
def self.type_name(type) = type == :bool ? "true or false" : "a #{type}"
|
|
104
|
+
private_class_method :type_name
|
|
105
|
+
|
|
97
106
|
# @param records [Hash{Symbol => Array<Struct>}] the rows of each kind
|
|
98
107
|
# @param table [Table] the checked route table
|
|
99
108
|
# @param vocabulary [Hash{Symbol => Array<String>}] the closed sets
|
|
100
109
|
# @param problems [Array<String>] the problems already found in reading the rows
|
|
110
|
+
# @param template [String, nil] the template the caller names, if any
|
|
101
111
|
# @raise [Table::Invalid] naming every problem when there is one
|
|
102
|
-
def initialize(records, table:, vocabulary:, problems:)
|
|
112
|
+
def initialize(records, table:, vocabulary:, problems:, template: nil)
|
|
103
113
|
@setting = records.fetch(:setting).first
|
|
104
114
|
@policies = records.fetch(:policy)
|
|
105
115
|
@upstreams = records.fetch(:upstream)
|
|
106
116
|
@rules = records.fetch(:rule)
|
|
107
|
-
Checks.new(self, table.rows, vocabulary, problems).call
|
|
117
|
+
Checks.new(self, table.rows, vocabulary, problems, template_named: !template.nil?).call
|
|
108
118
|
if problems.empty?
|
|
109
|
-
@listener_rules = Rules.new(self, table.rows, problems).call
|
|
119
|
+
@listener_rules = alb? ? Rules.new(self, table.rows, problems).call : []
|
|
110
120
|
built = Behaviours.new(self, table.rows, problems).call
|
|
111
121
|
@default_behaviour = built.fetch(:default)
|
|
112
122
|
@behaviours = built.fetch(:list)
|
|
@@ -116,6 +126,10 @@ module Hecks
|
|
|
116
126
|
raise Table::Invalid, "the edge is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
|
|
117
127
|
end
|
|
118
128
|
|
|
129
|
+
# @return [Boolean] whether the project has a load balancer, and so listener rules; true
|
|
130
|
+
# unless the `Edge` row says `alb: false`
|
|
131
|
+
def alb? = setting.nil? || setting.alb != false
|
|
132
|
+
|
|
119
133
|
# @param origin [String] an origin of the Origin set
|
|
120
134
|
# @return [Upstream, nil] its CloudFront origin and target group
|
|
121
135
|
def upstream(origin) = upstreams.find { |candidate| candidate.origin == origin }
|
|
@@ -77,10 +77,12 @@ module Hecks
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def route_fields(row)
|
|
80
|
-
{ path: row.path, kind: row.kind, render: row.render, auth: row.auth, methods: row.verbs,
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
fields = { path: row.path, kind: row.kind, render: row.render, auth: row.auth, methods: row.verbs,
|
|
81
|
+
cache: row.cache, origin: row.origin, source: row.source, indexable: row.indexable,
|
|
82
|
+
switch: blank_to_nil(row.switch), off: row.off, preview: row.preview,
|
|
83
|
+
label: blank_to_nil(row.label), seo: blank_to_nil(row.seo), redirectTo: row.redirect_to }
|
|
84
|
+
fields[:seoTitle] = row.seo_title if row.seo_title
|
|
85
|
+
fields
|
|
84
86
|
end
|
|
85
87
|
|
|
86
88
|
def switches(rows)
|
|
@@ -112,13 +114,16 @@ module Hecks
|
|
|
112
114
|
TS
|
|
113
115
|
end
|
|
114
116
|
|
|
117
|
+
# The navigation arrays hold every entry that has a slot, a switched-off page's too: an
|
|
118
|
+
# entry of an off page carries its `switch` and `on: false`, and the site drops it while
|
|
119
|
+
# `pageIsOn(switch)` is false. An entry of a page that is on carries neither.
|
|
115
120
|
def navigation(table)
|
|
116
|
-
|
|
121
|
+
items = table.rows + table.links
|
|
117
122
|
[nav_const("NAV_DESKTOP", "The desktop navigation: a page with no group is its own entry; a group holds its pages.",
|
|
118
|
-
desktop(
|
|
119
|
-
nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(
|
|
120
|
-
nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(
|
|
121
|
-
nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(
|
|
123
|
+
desktop(items)),
|
|
124
|
+
nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(items)),
|
|
125
|
+
nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(items)),
|
|
126
|
+
nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(items))].join("\n\n")
|
|
122
127
|
end
|
|
123
128
|
|
|
124
129
|
def nav_const(name, comment, entries)
|
|
@@ -126,32 +131,41 @@ module Hecks
|
|
|
126
131
|
"// #{comment}\nexport const #{name} = #{body.empty? ? '[]' : "[\n#{body.join("\n")}\n]"} as const;"
|
|
127
132
|
end
|
|
128
133
|
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
# One link: a row's or a link's path and label, the fragment a link names, and for a page
|
|
135
|
+
# that is off the switch that turns it on.
|
|
136
|
+
def entry(item, **before)
|
|
137
|
+
link = { **before, path: item.path, label: item.label }
|
|
138
|
+
link[:fragment] = item.fragment if item.respond_to?(:fragment) && item.fragment
|
|
139
|
+
link.merge!(switch: item.switch, on: false) if item.off
|
|
140
|
+
link
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def order_key(item, order) = [order, item.path, item.respond_to?(:fragment) ? item.fragment.to_s : ""]
|
|
144
|
+
|
|
145
|
+
def desktop(items)
|
|
131
146
|
entries = []
|
|
132
|
-
items.each do |
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
slot
|
|
136
|
-
slot ? slot[:items] << item : entries << { group: group, items: [item] }
|
|
147
|
+
items.reject { |item| item.nav_order.nil? }.sort_by { |item| order_key(item, item.nav_order) }.each do |item|
|
|
148
|
+
group = item.nav_group
|
|
149
|
+
slot = group && entries.find { |candidate| candidate[:group] == group }
|
|
150
|
+
slot ? slot[:items] << entry(item) : entries << { group: group, items: [entry(item)] }
|
|
137
151
|
end
|
|
138
152
|
entries
|
|
139
153
|
end
|
|
140
154
|
|
|
141
|
-
def mobile(
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
def mobile(items)
|
|
156
|
+
items.reject { |item| item.mobile_order.nil? }.sort_by { |item| order_key(item, item.mobile_order) }
|
|
157
|
+
.map { |item| item.mobile_heading ? entry(item, heading: item.mobile_heading) : entry(item) }
|
|
144
158
|
end
|
|
145
159
|
|
|
146
|
-
def footer(
|
|
147
|
-
columns =
|
|
160
|
+
def footer(items)
|
|
161
|
+
columns = items.select(&:footer_column).sort_by { |item| order_key(item, item.footer_order) }.group_by(&:footer_column)
|
|
148
162
|
columns.sort_by { |name, group| [group.first.footer_order, name] }
|
|
149
|
-
.map { |name, group| { column: name, items: group.map { |
|
|
163
|
+
.map { |name, group| { column: name, items: group.map { |item| entry(item) } } }
|
|
150
164
|
end
|
|
151
165
|
|
|
152
|
-
def admin(
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
def admin(items)
|
|
167
|
+
items.select(&:admin_key).sort_by { |item| [item.admin_order, item.admin_key] }
|
|
168
|
+
.map { |item| entry(item, key: item.admin_key) }
|
|
155
169
|
end
|
|
156
170
|
|
|
157
171
|
def search(rows)
|
|
@@ -216,13 +230,22 @@ module Hecks
|
|
|
216
230
|
const source = stripHtml(pattern)
|
|
217
231
|
.split("/")
|
|
218
232
|
.map((part) =>
|
|
219
|
-
part
|
|
233
|
+
part.startsWith(":")
|
|
234
|
+
? "[^/]+"
|
|
235
|
+
: part
|
|
236
|
+
.split("*")
|
|
237
|
+
.map((piece) => piece.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
238
|
+
.join(".*"),
|
|
220
239
|
)
|
|
221
240
|
.join("/");
|
|
222
241
|
return new RegExp("^" + source + "$");
|
|
223
242
|
}
|
|
224
243
|
|
|
225
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Whether a pathname is one a route pattern describes, with or without `.html`. A `:name` is one
|
|
246
|
+
* segment; a `*` is any run of characters, `/` included, wherever it stands, as in a CDN's path
|
|
247
|
+
* pattern: `/pay/*` matches `/pay/7` and `/admin*` matches `/admin`, `/admin-inbox` and `/admin/x`.
|
|
248
|
+
*/
|
|
226
249
|
export function matchesPath(pattern: string, pathname: string): boolean {
|
|
227
250
|
return toRegExp(pattern).test(stripHtml(pathname));
|
|
228
251
|
}
|
|
@@ -11,9 +11,10 @@ module Hecks
|
|
|
11
11
|
# on it: the distribution's cache behaviours and the load balancer's listener rules.
|
|
12
12
|
#
|
|
13
13
|
# The blocks are returned by name, `"behaviors"` and `"listener_rules"`, as text from the
|
|
14
|
-
# left margin
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# left margin; a project whose `Edge` row says `alb: false` has no load balancer and gets
|
|
15
|
+
# `"behaviors"` alone. `Regions` puts each between the markers of the same name in the
|
|
16
|
+
# project's template, at the markers' indentation. The text is a pure function of the table
|
|
17
|
+
# and the edge rows.
|
|
17
18
|
module SiteCdn
|
|
18
19
|
extend Projector::Target
|
|
19
20
|
|
|
@@ -25,7 +26,7 @@ module Hecks
|
|
|
25
26
|
# @param options [Hash{Symbol => Object}] `:registry` (Runtime::Registry) the project booted
|
|
26
27
|
# into; or `:table` (Table) and `:edge` (Edge), already read
|
|
27
28
|
# @return [Hash{String => String}] each region's name to its text; empty when the chapter
|
|
28
|
-
# declares no edge
|
|
29
|
+
# declares no edge, and without `"listener_rules"` when the edge has no load balancer
|
|
29
30
|
# @raise [Table::Invalid] when the table or the edge is refused
|
|
30
31
|
def call(bluebook:, options: {})
|
|
31
32
|
registry = options[:registry]
|
|
@@ -33,6 +34,8 @@ module Hecks
|
|
|
33
34
|
edge = options[:edge] || Edge.read(bluebook, table: table, vocabulary: Table.vocabulary(registry))
|
|
34
35
|
return {} unless edge
|
|
35
36
|
|
|
37
|
+
return { "behaviors" => behaviors(edge) } unless edge.alb?
|
|
38
|
+
|
|
36
39
|
{ "behaviors" => behaviors(edge), "listener_rules" => listener_rules(edge) }
|
|
37
40
|
end
|
|
38
41
|
|