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.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/check_era.rb +3 -39
  10. data/lib/hecks/cli/interview_draft.rb +86 -31
  11. data/lib/hecks/cli/interview_session.rb +77 -26
  12. data/lib/hecks/cli/interview_shape.rb +179 -0
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door.rb +16 -2
  16. data/lib/hecks/doors/mcp_door_scope.rb +45 -5
  17. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  18. data/lib/hecks/doors.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  20. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  21. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  22. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  23. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  24. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  25. data/lib/hecks/gate/stages.yml +113 -8
  26. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  28. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  29. data/lib/hecks/hecks/adapters/git.rb +59 -0
  30. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  31. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  32. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  33. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  34. data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
  35. data/lib/hecks/hecks/codebase.behaviors +6 -0
  36. data/lib/hecks/hecks/codebase.bluebook +21 -0
  37. data/lib/hecks/hecks/custodian.behaviors +6 -0
  38. data/lib/hecks/hecks/custodian.bluebook +217 -0
  39. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  40. data/lib/hecks/hecks/hecks.world +8 -3
  41. data/lib/hecks/hecks/site.world +5 -2
  42. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  43. data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
  44. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  45. data/lib/hecks/language/oidc.json +5 -0
  46. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  47. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  48. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  49. data/lib/hecks/projections/deploy/box/settings.rb +40 -6
  50. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  51. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  52. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  53. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  54. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  55. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  56. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  57. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  58. data/lib/hecks/projections/deploy/box.rb +28 -4
  59. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  60. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  61. data/lib/hecks/projections/model/deviations.rb +1 -1
  62. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  63. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  64. data/lib/hecks/projections/site/edge.rb +25 -11
  65. data/lib/hecks/projections/site/live_edge.rb +162 -0
  66. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  67. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  68. data/lib/hecks/projections/site/table/checks.rb +53 -7
  69. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  70. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  71. data/lib/hecks/projections/site/table.rb +16 -5
  72. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  73. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  74. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  75. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  76. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  77. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  78. data/lib/hecks/runtime/interpreting.rb +3 -22
  79. data/lib/hecks/runtime/needs.rb +48 -0
  80. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  81. data/lib/hecks/runtime/registry/verification.rb +3 -3
  82. data/lib/hecks/rust_build/host.rb +147 -0
  83. data/lib/hecks/rust_build/kernel_input.rb +51 -17
  84. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  85. data/lib/hecks/rust_build/project_rust.rb +7 -140
  86. data/lib/hecks/rust_build.rb +1 -0
  87. data/lib/hecks/site/bluebook/site.bluebook +80 -14
  88. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  89. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  90. data/lib/hecks/storehouse.rb +35 -1
  91. data/lib/hecks/tools/comment_style.rb +4 -2
  92. data/lib/hecks/tools/gate.rb +1 -1
  93. data/lib/hecks/tools/rust_comment_style.rb +2 -4
  94. data/lib/hecks/tools/site_routes.rb +69 -28
  95. data/lib/hecks/tools/tools_doc.rb +204 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +152 -1
  99. data/lib/hecks.rb +1 -0
  100. data/qa/settings.yml +1 -2
  101. data/rust/build/src/lineage_pass.rs +31 -16
  102. data/rust/build/src/main.rs +4 -2
  103. data/rust/build/src/pipeline.rs +9 -20
  104. data/rust/codegen/src/attr.rs +1 -1
  105. data/rust/codegen/src/bridging.rs +8 -8
  106. data/rust/codegen/src/commands.rs +1 -1
  107. data/rust/codegen/src/constraints.rs +1 -1
  108. data/rust/codegen/src/dependency_planning.rs +1 -1
  109. data/rust/codegen/src/domain_generator.rs +2 -2
  110. data/rust/codegen/src/exemplar.rs +1 -1
  111. data/rust/codegen/src/expr_emitter.rs +1 -1
  112. data/rust/codegen/src/fielded.rs +1 -1
  113. data/rust/codegen/src/json.rs +214 -23
  114. data/rust/codegen/src/json_codec.rs +1 -1
  115. data/rust/codegen/src/literal.rs +6 -3
  116. data/rust/codegen/src/main.rs +23 -13
  117. data/rust/codegen/src/mutations.rs +1 -1
  118. data/rust/codegen/src/naming.rs +5 -3
  119. data/rust/codegen/src/optional_pass.rs +226 -0
  120. data/rust/codegen/src/ports.rs +1 -1
  121. data/rust/codegen/src/queries.rs +3 -3
  122. data/rust/codegen/src/reactions.rs +1 -1
  123. data/rust/codegen/src/read_models.rs +2 -2
  124. data/rust/codegen/src/reference_specs.rs +1 -1
  125. data/rust/codegen/src/registry.rs +4 -4
  126. data/rust/codegen/src/shared.rs +1 -1
  127. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  128. data/rust/codegen/src/skip_reason.rs +1 -1
  129. data/rust/codegen/src/types.rs +4 -4
  130. data/rust/host/HECKS_RELEASE +1 -1
  131. data/rust/host/src/api.rs +1 -1
  132. data/rust/host/src/dispatch.rs +5 -0
  133. data/rust/host/src/needs.rs +193 -29
  134. data/rust/host/src/resend.rs +4 -4
  135. data/rust/host/src/ui_schema.rs +1 -1
  136. data/rust/parser/src/emit.rs +12 -0
  137. data/rust/parser/src/ir.rs +2 -0
  138. data/rust/parser/src/keywords.rs +2 -0
  139. data/rust/parser/src/main.rs +1 -0
  140. data/rust/parser/src/parse/command.rs +2 -44
  141. data/rust/parser/src/parse/mod.rs +1 -0
  142. data/rust/parser/src/parse/needs.rs +60 -0
  143. data/rust/parser/src/parse/query.rs +3 -0
  144. data/rust/src/exemplar/commands.rs +1 -1
  145. data/rust/src/exemplar/constraints.rs +1 -1
  146. data/rust/src/exemplar/fielded.rs +1 -1
  147. data/rust/src/exemplar/json.rs +1 -1
  148. data/rust/src/exemplar/mod.rs +1 -1
  149. data/rust/src/exemplar/mutations.rs +1 -1
  150. data/rust/src/exemplar/queries.rs +1 -1
  151. data/rust/src/exemplar/reactions.rs +1 -1
  152. data/rust/src/exemplar/read_models.rs +1 -1
  153. data/rust/src/exemplar/registry.rs +1 -1
  154. data/rust/src/exemplar/types.rs +1 -1
  155. data/rust/src/kernel/cli.rs +18 -8
  156. data/rust/src/kernel/needs.rs +104 -9
  157. data/rust/src/kernel/read_model.rs +1 -1
  158. data/rust/src/lib.rs +1 -1
  159. metadata +24 -30
  160. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  161. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  162. data/lib/hecks/three_zero/forms.yml +0 -127
  163. data/lib/hecks/three_zero.rb +0 -12
  164. data/rust/build/src/json.rs +0 -421
  165. data/rust/build/src/optional_pass.rs +0 -150
  166. data/rust/project/bridging.rb +0 -257
  167. data/rust/project/commands.rb +0 -877
  168. data/rust/project/constraints.rb +0 -86
  169. data/rust/project/dependency_planning.rb +0 -178
  170. data/rust/project/domain_generator.rb +0 -983
  171. data/rust/project/exemplar.rb +0 -148
  172. data/rust/project/expr_emitter.rb +0 -72
  173. data/rust/project/fielded.rb +0 -141
  174. data/rust/project/json_codec.rb +0 -641
  175. data/rust/project/mutations.rb +0 -695
  176. data/rust/project/naming.rb +0 -223
  177. data/rust/project/ports.rb +0 -65
  178. data/rust/project/queries.rb +0 -493
  179. data/rust/project/reactions.rb +0 -317
  180. data/rust/project/read_models.rb +0 -690
  181. data/rust/project/reference_specs.rb +0 -27
  182. data/rust/project/registry.rb +0 -396
  183. data/rust/project/skip_reason.rb +0 -29
  184. data/rust/project/types.rb +0 -225
  185. data/rust/project/write_if_changed.rb +0 -7
  186. data/rust/project.rb +0 -30
  187. data/rust/project_rust_pipeline.rb +0 -391
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require_relative "../../projector"
3
3
  require_relative "box/settings"
4
+ require_relative "box/hosting"
4
5
 
5
6
  module Hecks
6
7
  module Projections
@@ -94,7 +95,7 @@ module Hecks
94
95
  "fetch-secrets.sh" => File.read(File.join(TEMPLATE_DIR, "fetch-secrets.sh")),
95
96
  "deploy-box.sh" => deploy_box_sh(plan, region),
96
97
  "Makefile" => makefile(plan)
97
- }.merge(migration_files(plan))
98
+ }.merge(migration_files(plan)).then { |files| Hosting.extend_files(files, plan: plan, region: region) }
98
99
  end
99
100
 
100
101
  # The tooling that moves a project's data from its old database into the RDS instance, for
@@ -379,9 +380,19 @@ module Hecks
379
380
  def route_blocks(plan)
380
381
  routes = plan.routes.each_with_index.map do |route, i|
381
382
  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"
383
+ "@r#{i + 1} path #{route.paths.join(' ')}\nhandle @r#{i + 1} {\n#{upstream(port)}}\n\n"
383
384
  end
384
- "#{routes.join}handle {\n\treverse_proxy 127.0.0.1:#{plan.default.port}\n}\n"
385
+ "#{routes.join}handle {\n#{upstream(plan.default.port)}}\n"
386
+ end
387
+
388
+ # One upstream. A request that arrives while its container is being replaced
389
+ # waits, and is tried again every quarter second for up to 15 seconds, so a
390
+ # deploy shows a visitor a slow page instead of a 502.
391
+ #
392
+ # @param port [Integer] the container's port
393
+ # @return [String] the `reverse_proxy` block, one tab in, ending in a newline
394
+ def upstream(port)
395
+ "\treverse_proxy 127.0.0.1:#{port} {\n\t\tlb_try_duration 15s\n\t\tlb_try_interval 250ms\n\t}\n"
385
396
  end
386
397
 
387
398
  # @param plan [Settings::Plan] the resolved settings
@@ -425,6 +436,7 @@ module Hecks
425
436
  def services_json(plan)
426
437
  services = plan.containers.to_h { |c| [c.name, service_entry(plan, c)] }
427
438
  origin = plan.origin_secret ? { "header" => plan.origin_header, "secret" => plan.origin_secret } : nil
439
+ origin["env"] = plan.origin_env if origin && plan.origin_env.any?
428
440
  document = { "services" => services, "origin" => origin }
429
441
  document["task_definition"] = plan.task_definition if plan.task_definition
430
442
  document["tunnel"] = tunnel_entry(plan.tunnel_service) if plan.tunnel_service
@@ -535,7 +547,7 @@ module Hecks
535
547
  # @param plan [Settings::Plan] the resolved settings
536
548
  # @return [String] a Makefile whose targets create the two stacks and roll the box
537
549
  def makefile(plan)
538
- <<~MAKE
550
+ base = <<~MAKE
539
551
  # #{plan.infra_name}: one app box and one RDS instance.
540
552
  # make stacks VPC=vpc-... PRIVATE_SUBNETS=subnet-a,subnet-b PUBLIC_SUBNET=subnet-c [REHEARSAL=true]
541
553
  # make deploy #{plan.task_definition ? '[TASKDEF=family:revision]' : '[TAGS="web=20260101 worker=20260101"]'}
@@ -559,6 +571,18 @@ module Hecks
559
571
  deploy:
560
572
  \tbash ./deploy-box.sh $(#{plan.task_definition ? 'TASKDEF' : 'TAGS'})
561
573
  MAKE
574
+ "#{base}#{hosting_makefile_tail(plan)}"
575
+ end
576
+
577
+ # With hosting scripts, a deploy ends with the post-deploy smoke and the Makefile includes
578
+ # the fragment that adds the hosting targets.
579
+ #
580
+ # @param plan [Settings::Plan] the resolved settings
581
+ # @return [String] the Makefile lines that follow the `deploy` recipe, or nothing
582
+ def hosting_makefile_tail(plan)
583
+ return "" unless plan.hosting
584
+
585
+ "\t#{'TASKDEF="$(TASKDEF)" ' if plan.task_definition}bash ./smoke-after-deploy.sh\n\ninclude hosting.mk\n"
562
586
  end
563
587
 
564
588
  # Fills `@@NAME@@` markers. A marker alone on its line is replaced together with the line,
@@ -100,7 +100,7 @@ module Hecks
100
100
  deployed_to("AwsFargate") do
101
101
  ...
102
102
  database "Shared"
103
- owner "Embryonaut"
103
+ owner "Core"
104
104
  end
105
105
 
106
106
  naming the already-deployed domain whose Postgres instance this one borrows.
@@ -327,8 +327,8 @@ module Hecks
327
327
  RetentionInDays: 30
328
328
 
329
329
  # Always minted — HECKS_SERVE_MODE always runs web.rs, which
330
- # panics on an empty SESSION_SECRET (found live: hecksagain-pizzas
331
- # 502 after the Aurora cutover). rust/host fetches this at cold
330
+ # panics on an empty SESSION_SECRET (seen live as a 502
331
+ # after a database cutover). rust/host fetches this at cold
332
332
  # start (secrets.rs), never a plain env var.
333
333
  #{session_secret_id}:
334
334
  Type: AWS::SecretsManager::Secret
@@ -147,7 +147,7 @@ module Hecks
147
147
  deployed_to("AwsLambda") do
148
148
  ...
149
149
  database "Shared"
150
- owner "Embryonaut"
150
+ owner "Core"
151
151
  end
152
152
 
153
153
  naming the already-deployed domain whose Postgres instance this one borrows.
@@ -273,7 +273,7 @@ module Hecks
273
273
  # GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET/GOOGLE_REDIRECT_URI
274
274
  # deliberately NOT set here — #{domain}'s own .env.local has
275
275
  # no real GOOGLE_CLIENT_ID yet, matching Fly's own current
276
- # reality (fly secrets list -a embryonaut-founder-app: only
276
+ # reality (its secrets list holds only
277
277
  # DATABASE_URL and SESSION_SECRET) that nobody has configured
278
278
  # OAuth for this app at all. Drop real credentials into
279
279
  # .env.local and regenerate to wire this up.
@@ -410,7 +410,7 @@ module Hecks
410
410
  # `stack_name` above exactly — one name, two places it's read
411
411
  # from, never out of sync since both derive from `infra_name`.
412
412
  FunctionName: #{stack_name}
413
- # EXPLICIT, not omitted -- `sam build EmbryonautFunction` and
413
+ # EXPLICIT, not omitted -- `sam build WebFunction` and
414
414
  # `sam build --use-container WebFunction` run as TWO SEPARATE
415
415
  # single-resource invocations (deploy:'s own comment on why:
416
416
  # cargo-lambda cross-compiles on this host directly, pg's
@@ -434,7 +434,7 @@ module Hecks
434
434
  # "." NOT "#{logical_id}" -- SAM's Makefile build workflow
435
435
  # requires the Makefile to live INSIDE CodeUri itself
436
436
  # (confirmed live: "Makefile not found at
437
- # .../EmbryonautFunction/Makefile" when CodeUri named a
437
+ # .../WebFunction/Makefile" when CodeUri named a
438
438
  # directory of its own). This project's one Makefile lives at
439
439
  # the deploy directory's own root, alongside this template --
440
440
  # "." is where it actually is.
@@ -568,7 +568,7 @@ module Hecks
568
568
  "SubnetIds: [!Ref #{db_id}SubnetA, !Ref #{db_id}SubnetB]\n SecurityGroupIds: [!Ref #{logical_id}SecurityGroup]"
569
569
  end}
570
570
  # AWS_IAM by default — this dispatches real domain commands
571
- # (cap-table/governance-shaped data, for Embryonaut's own use);
571
+ # (cap-table/governance-shaped data);
572
572
  # an unauthenticated public URL is the wrong default for that,
573
573
  # even though it's the simpler one to demo with. NONE only when
574
574
  # `web "Rust"` (rust/host/src/web.rs) makes this the public web
@@ -586,7 +586,7 @@ module Hecks
586
586
  # THE WEB APP'S OWN LAMBDA — opt-in, see `web_handler_present` above.
587
587
  # VPC-attached for the SAME reason #{logical_id} is (Member's own
588
588
  # persistence needs the private RDS instance directly, permanently —
589
- # embryonaut.hecksagon's own comment on why), sharing
589
+ # the hecksagon's own comment on why), sharing
590
590
  # #{logical_id}SecurityGroup rather than minting a second one, since
591
591
  # both need the identical egress-to-DB rule. That VPC attachment
592
592
  # ALSO cuts off the public internet by default (this stack's own "NO
@@ -674,7 +674,7 @@ module Hecks
674
674
  # without reworking that whole from-source pg build for a newer
675
675
  # image is a real, separate undertaking (untested container image
676
676
  # availability, a different vendor/bundle native-extension path)
677
- # out of scope here. Matches Embryonaut's own live, currently-
677
+ # out of scope here. Matches the live, currently-
678
678
  # deployed choice exactly, so both consumers of this one shared
679
679
  # WebFunction code path stay on the same, PROVEN runtime.
680
680
  Properties:
@@ -763,7 +763,7 @@ module Hecks
763
763
  # documents. But THIS function's consumer (`lambda_handler.rb`)
764
764
  # is not a file this generator writes or this repo carries --
765
765
  # it lives in the deploying app's own domain directory (real,
766
- # live example: Embryonaut's own Sinatra app), outside this
766
+ # live example: a Sinatra app), outside this
767
767
  # codebase entirely. Redeploying a domain with an existing
768
768
  # `lambda_handler.rb` BEFORE that handler is updated to fetch
769
769
  # these itself (`aws-sdk-secretsmanager`, not yet in this
@@ -48,7 +48,7 @@ module Hecks
48
48
  # Fields emitted by `to_h`'s own merge rather than `emits_ir`, for constructs
49
49
  # whose shape is not fixed.
50
50
  DYNAMIC_TAIL = {
51
- "Query" => %i[options returns],
51
+ "Query" => %i[options returns needs],
52
52
  "ReadModel" => %i[options group_by aggregate_heads count median_field sum_field avg_field
53
53
  min_field max_field percentile_field percentile_at any_field all_field]
54
54
  }.freeze
@@ -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 }
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "edge"
5
+
6
+ module Hecks
7
+ module Projections
8
+ module Site
9
+ # Compares the CloudFront behaviours a site's edge generates with the ones a live
10
+ # distribution has, and says every way they differ. It only reads: the live side is a
11
+ # `get-distribution-config` answer, however it was obtained.
12
+ #
13
+ # A behaviour is identified by its path pattern (`(default)` for the default one) and judged
14
+ # on its origin, allowed and cached methods, viewer protocol, compression and three policy
15
+ # ids. The edge may name a policy by a CloudFormation intrinsic (`!Ref PageCachePolicy`) that
16
+ # the live distribution holds as an id, so a reference is compared through the `refs` the
17
+ # caller gives for it, and one with none is reported as unchecked. The behaviours a change is
18
+ # about to add (`expect_new`) are reported as expected additions, not differences.
19
+ class LiveEdge
20
+ # The default behaviour's name, since it has no path pattern.
21
+ DEFAULT = "(default)"
22
+
23
+ # The fields a behaviour is compared on, besides its path.
24
+ FIELDS = %i[origin methods cached protocol compress cache request response].freeze
25
+
26
+ # What a comparison found.
27
+ #
28
+ # @!attribute [r] expected
29
+ # @return [Array<String>] a line per behaviour only the project has, that was expected
30
+ # @!attribute [r] differences
31
+ # @return [Array<String>] a line per difference that was not expected
32
+ # @!attribute [r] unchecked
33
+ # @return [Array<String>] the policy references no `refs` entry resolves
34
+ # @!attribute [r] counts
35
+ # @return [Array<Integer>] the number of generated and of live behaviours, default aside
36
+ Comparison = Struct.new(:expected, :differences, :unchecked, :counts, keyword_init: true) do
37
+ # @return [Boolean] whether live is as generated, expected additions aside
38
+ def clean? = differences.empty? && unchecked.empty?
39
+
40
+ # @return [String] the report, one section per kind of finding
41
+ def to_s
42
+ lines = ["generated: #{counts[0]} behaviours + default; live: #{counts[1]} + default"]
43
+ lines += section("expected additions (only in the project):", expected)
44
+ lines += section("differences:", differences)
45
+ lines += section("policy references with no refs entry (unchecked):", unchecked)
46
+ lines << "the live distribution matches the project" if clean?
47
+ lines.join("\n")
48
+ end
49
+
50
+ private
51
+
52
+ def section(title, entries) = entries.empty? ? [] : [title, *entries.map { |entry| " #{entry}" }]
53
+ end
54
+
55
+ # @param edge [Edge] the project's checked edge
56
+ # @param live [Hash] a CloudFront distribution configuration, or an answer wrapping one in
57
+ # `DistributionConfig`
58
+ # @param expect_new [Array<String>] path patterns the project may have and the live one not
59
+ # @param refs [Hash{String => String}] a policy intrinsic to the id it stands for live
60
+ def initialize(edge, live, expect_new: [], refs: {})
61
+ @edge = edge
62
+ @live = live.fetch("DistributionConfig", live)
63
+ @expect_new = expect_new
64
+ @refs = refs
65
+ end
66
+
67
+ # @return [Comparison] every way the project and the live distribution differ
68
+ def call
69
+ generated = generated_behaviours
70
+ current = live_behaviours
71
+ unchecked = unresolved(generated.values)
72
+ generated = generated.transform_values { |entry| resolve(entry) }
73
+ expected, differences = missing(generated, current)
74
+ differences.concat(changed(generated, current), order(generated, current))
75
+ Comparison.new(expected: expected, differences: differences, unchecked: unchecked,
76
+ counts: [generated.size - 1, current.size - 1])
77
+ end
78
+
79
+ # Reads a policy reference mapping written as `!Ref Name=id` words.
80
+ #
81
+ # @param text [String, nil] the words, separated by commas
82
+ # @return [Hash{String => String}] each intrinsic to its id
83
+ def self.refs_from(text)
84
+ text.to_s.split(",").to_h { |pair| pair.strip.split("=", 2).map(&:strip) }
85
+ end
86
+
87
+ private
88
+
89
+ def generated_behaviours
90
+ list = [[DEFAULT, @edge.default_behaviour], *@edge.behaviours.map { |entry| [entry[:path], entry] }]
91
+ list.to_h do |path, entry|
92
+ [path, { path: path, origin: entry[:origin], methods: Array(entry[:methods]).sort, cached: %w[GET HEAD],
93
+ protocol: entry[:viewer_protocol], compress: entry[:compress] == true,
94
+ cache: policy_id(entry[:cache_policy]), request: policy_id(entry[:origin_request_policy]),
95
+ response: policy_id(entry[:response_headers_policy]) }]
96
+ end
97
+ end
98
+
99
+ def policy_id(policy) = policy&.first
100
+
101
+ def live_behaviours
102
+ list = [[DEFAULT, @live.fetch("DefaultCacheBehavior")],
103
+ *Array(@live.dig("CacheBehaviors", "Items")).map { |entry| [entry.fetch("PathPattern"), entry] }]
104
+ list.to_h do |path, entry|
105
+ [path, { path: path, origin: entry.fetch("TargetOriginId"), methods: methods_of(entry, "Items"),
106
+ cached: cached_of(entry), protocol: entry.fetch("ViewerProtocolPolicy"),
107
+ compress: entry.fetch("Compress", false) == true, cache: entry["CachePolicyId"],
108
+ request: blank(entry["OriginRequestPolicyId"]), response: blank(entry["ResponseHeadersPolicyId"]) }]
109
+ end
110
+ end
111
+
112
+ def methods_of(entry, key) = entry.fetch("AllowedMethods").fetch(key).sort
113
+
114
+ def cached_of(entry) = entry.dig("AllowedMethods", "CachedMethods", "Items").sort
115
+
116
+ def blank(value) = value.to_s.empty? ? nil : value
117
+
118
+ def intrinsic?(value) = value.is_a?(String) && value.start_with?("!")
119
+
120
+ def unresolved(entries)
121
+ entries.flat_map { |entry| entry.values_at(:cache, :request, :response) }
122
+ .select { |value| intrinsic?(value) && !@refs.key?(value) }.uniq.sort
123
+ end
124
+
125
+ def resolve(entry) = entry.transform_values { |value| intrinsic?(value) ? @refs.fetch(value, value) : value }
126
+
127
+ def missing(generated, current)
128
+ expected = []
129
+ differences = []
130
+ generated.each do |path, entry|
131
+ next if current.key?(path)
132
+
133
+ detail = entry.except(:path).map { |key, value| "#{key}=#{value.inspect}" }.join(" ")
134
+ (@expect_new.include?(path) ? expected : differences) << "only in the project: #{path} #{detail}"
135
+ end
136
+ current.each_key { |path| differences << "only live: #{path}" unless generated.key?(path) }
137
+ [expected, differences]
138
+ end
139
+
140
+ def changed(generated, current)
141
+ generated.flat_map do |path, entry|
142
+ next [] unless current.key?(path)
143
+
144
+ FIELDS.filter_map do |field|
145
+ next if entry[field] == current[path][field]
146
+
147
+ "#{path} differs in #{field}: project=#{entry[field].inspect} live=#{current[path][field].inspect}"
148
+ end
149
+ end
150
+ end
151
+
152
+ def order(generated, current)
153
+ mine = generated.keys.reject { |path| path == DEFAULT || !current.key?(path) }
154
+ theirs = current.keys.reject { |path| path == DEFAULT || !generated.key?(path) }
155
+ return [] if mine == theirs
156
+
157
+ ["order differs among the behaviours both have:\n project: #{mine.join(' ')}\n live: #{theirs.join(' ')}"]
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -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
  }