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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  3. data/lib/hecks/cli/check_era.rb +3 -39
  4. data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
  5. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  6. data/lib/hecks/deploy/oidc.json +15 -0
  7. data/lib/hecks/doors/mcp_door.rb +16 -2
  8. data/lib/hecks/doors/mcp_door_scope.rb +43 -4
  9. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  10. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  11. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  12. data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
  13. data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
  14. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  15. data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
  16. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
  18. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  19. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  20. data/lib/hecks/projections/deploy/box/settings.rb +23 -2
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
  22. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  23. data/lib/hecks/projections/deploy/box.rb +13 -2
  24. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  25. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  26. data/lib/hecks/projections/site/edge.rb +25 -11
  27. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  28. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  29. data/lib/hecks/projections/site/table/checks.rb +53 -7
  30. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  31. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  32. data/lib/hecks/projections/site/table.rb +16 -5
  33. data/lib/hecks/rust_build/kernel_input.rb +18 -6
  34. data/lib/hecks/rust_build/project_rust.rb +6 -132
  35. data/lib/hecks/site/bluebook/site.bluebook +27 -13
  36. data/lib/hecks/storehouse.rb +35 -1
  37. data/lib/hecks/tools/comment_style.rb +4 -2
  38. data/lib/hecks/tools/deploy_recipe.rb +1 -0
  39. data/lib/hecks/tools/rust_comment_style.rb +1 -1
  40. data/lib/hecks/tools/site_routes.rb +69 -28
  41. data/lib/hecks/version.rb +1 -1
  42. data/lib/hecks/vocabulary.rb +1 -1
  43. data/lib/hecks.rb +1 -0
  44. data/rust/build/src/lineage_pass.rs +1 -1
  45. data/rust/build/src/pipeline.rs +1 -1
  46. data/rust/codegen/src/attr.rs +1 -1
  47. data/rust/codegen/src/bridging.rs +1 -1
  48. data/rust/codegen/src/commands.rs +1 -1
  49. data/rust/codegen/src/constraints.rs +1 -1
  50. data/rust/codegen/src/dependency_planning.rs +1 -1
  51. data/rust/codegen/src/domain_generator.rs +2 -2
  52. data/rust/codegen/src/exemplar.rs +1 -1
  53. data/rust/codegen/src/expr_emitter.rs +1 -1
  54. data/rust/codegen/src/fielded.rs +1 -1
  55. data/rust/codegen/src/json_codec.rs +1 -1
  56. data/rust/codegen/src/main.rs +3 -4
  57. data/rust/codegen/src/mutations.rs +1 -1
  58. data/rust/codegen/src/naming.rs +1 -1
  59. data/rust/codegen/src/ports.rs +1 -1
  60. data/rust/codegen/src/queries.rs +3 -3
  61. data/rust/codegen/src/reactions.rs +1 -1
  62. data/rust/codegen/src/read_models.rs +2 -2
  63. data/rust/codegen/src/reference_specs.rs +1 -1
  64. data/rust/codegen/src/registry.rs +4 -4
  65. data/rust/codegen/src/shared.rs +1 -1
  66. data/rust/codegen/src/skip_reason.rs +1 -1
  67. data/rust/codegen/src/types.rs +4 -4
  68. data/rust/host/HECKS_RELEASE +1 -1
  69. data/rust/host/src/auth.rs +25 -1
  70. data/rust/host/src/needs.rs +94 -27
  71. data/rust/host/src/web.rs +9 -5
  72. data/rust/src/exemplar/commands.rs +1 -1
  73. data/rust/src/exemplar/constraints.rs +1 -1
  74. data/rust/src/exemplar/fielded.rs +1 -1
  75. data/rust/src/exemplar/json.rs +1 -1
  76. data/rust/src/exemplar/mod.rs +1 -1
  77. data/rust/src/exemplar/mutations.rs +1 -1
  78. data/rust/src/exemplar/queries.rs +1 -1
  79. data/rust/src/exemplar/reactions.rs +1 -1
  80. data/rust/src/exemplar/read_models.rs +1 -1
  81. data/rust/src/exemplar/registry.rs +1 -1
  82. data/rust/src/exemplar/types.rs +1 -1
  83. data/rust/src/kernel/cli.rs +2 -2
  84. data/rust/src/kernel/read_model.rs +1 -1
  85. data/rust/src/lib.rs +1 -1
  86. metadata +5 -23
  87. data/rust/project/bridging.rb +0 -257
  88. data/rust/project/commands.rb +0 -877
  89. data/rust/project/constraints.rb +0 -86
  90. data/rust/project/dependency_planning.rb +0 -178
  91. data/rust/project/domain_generator.rb +0 -983
  92. data/rust/project/exemplar.rb +0 -148
  93. data/rust/project/expr_emitter.rb +0 -72
  94. data/rust/project/fielded.rb +0 -141
  95. data/rust/project/json_codec.rb +0 -641
  96. data/rust/project/mutations.rb +0 -695
  97. data/rust/project/naming.rb +0 -223
  98. data/rust/project/ports.rb +0 -65
  99. data/rust/project/queries.rb +0 -493
  100. data/rust/project/reactions.rb +0 -317
  101. data/rust/project/read_models.rb +0 -690
  102. data/rust/project/reference_specs.rb +0 -27
  103. data/rust/project/registry.rb +0 -396
  104. data/rust/project/skip_reason.rb +0 -29
  105. data/rust/project/types.rb +0 -225
  106. data/rust/project/write_if_changed.rb +0 -7
  107. data/rust/project.rb +0 -30
  108. 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|project)/|rust/project\.rb$|rust/project_rust_pipeline\.rb$|\.rubocop\.yml$|\.rubocop_todo\.yml$|\.mcp\.json$|\.rspec-local\.example$|README\.md$|CHANGELOG\.md$|CONTRIBUTING\.md$|SECURITY\.md$|LICENSE$|\.gitignore$)',
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. RustProjection::Projector::RUST_KEYWORDS reads this;
635
- # hecks project_reserved_names projects it into
636
- # rust/codegen/src/reserved_names.rs, so Ruby and Rust escape or refuse
637
- # the same words from one declaration instead of two hand-typed copies.
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
- # RustProjection::Projector::CARGO_RESERVED_DOMAIN_NAMES and projected by
652
- # hecks project_reserved_names, same as RustReservedWord above.
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.2.0.
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: 10)
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: 10)
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: 10)
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
- "sleep 20; docker compose -f compose.json ps --format \"table {{.Service}}\\t{{.Status}}\""],
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\treverse_proxy 127.0.0.1:#{port}\n}\n\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\treverse_proxy 127.0.0.1:#{plan.default.port}\n}\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(&:verbs).uniq
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.verbs == ["GET"] && %w[website assets].include?(row.origin)
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
- def initialize(edge, rows, vocabulary, problems)
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 naming the template; declare one member with template:") unless setting
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: [:template], policy: %i[cache_class cache], upstream: %i[origin id],
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
- value.is_a?(FIELDS.fetch(kind).fetch(field)) || (problems << "#{label} has #{field} #{value.inspect}; " \
90
- "#{field} is a #{FIELDS.fetch(kind).fetch(field)}")
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
- 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 }
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
- rows = live(table.rows)
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(rows)),
119
- nav_const("NAV_MOBILE", "The mobile navigation, in its own order.", mobile(rows)),
120
- nav_const("NAV_FOOTER", "The footer columns, left to right, each with its links.", footer(rows)),
121
- nav_const("NAV_ADMIN", "The admin navigation: each admin page under its key.", admin(rows))].join("\n\n")
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
- def desktop(rows)
130
- items = rows.reject { |row| row.nav_order.nil? }.sort_by { |row| [row.nav_order, row.path] }
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 |row|
133
- item = { path: row.path, label: row.label }
134
- group = row.nav_group
135
- slot = group && entries.find { |entry| entry[:group] == group }
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(rows)
142
- rows.reject { |row| row.mobile_order.nil? }.sort_by { |row| [row.mobile_order, row.path] }
143
- .map { |row| { path: row.path, label: row.label } }
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(rows)
147
- columns = rows.select(&:footer_column).sort_by { |row| [row.footer_order, row.path] }.group_by(&:footer_column)
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 { |row| { path: row.path, label: row.label } } } }
163
+ .map { |name, group| { column: name, items: group.map { |item| entry(item) } } }
150
164
  end
151
165
 
152
- def admin(rows)
153
- rows.select(&:admin_key).sort_by { |row| [row.admin_order, row.admin_key] }
154
- .map { |row| { key: row.admin_key, path: row.path, label: row.label } }
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 === "*" ? ".*" : part.startsWith(":") ? "[^/]+" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
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
- /** Whether a pathname is one a route pattern (`/blog/:slug.html`, `/pay/*`) describes, with or without `.html`. */
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. `Regions` puts each between the markers of the same name in the project's
15
- # template, at the markers' indentation. The text is a pure function of the table and the
16
- # edge rows.
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