hecks 3.0.5 → 3.1.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +24 -12
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +31 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  12. data/lib/hecks/cli/doc_coverage.rb +1 -1
  13. data/lib/hecks/cli/domain_stub.rb +128 -0
  14. data/lib/hecks/cli/interview_draft.rb +246 -0
  15. data/lib/hecks/cli/model_check.rb +1 -1
  16. data/lib/hecks/cli/project_cli.rb +51 -29
  17. data/lib/hecks/cli/reference.rb +2 -1
  18. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  19. data/lib/hecks/cli/seed_semantics_corpus.rb +3 -3
  20. data/lib/hecks/cli/spec_example.rb +1 -1
  21. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  22. data/lib/hecks/corpus.rb +11 -8
  23. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  24. data/lib/hecks/deploy/oidc.json +5 -0
  25. data/lib/hecks/doc/reference.rb +4 -3
  26. data/lib/hecks/doors/cli_door.rb +13 -13
  27. data/lib/hecks/doors/cli_runner.rb +52 -32
  28. data/lib/hecks/doors/launcher_options.rb +5 -5
  29. data/lib/hecks/doors/mcp_door.rb +40 -18
  30. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  31. data/lib/hecks/doors.rb +1 -1
  32. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  33. data/lib/hecks/forms/banking_presentation.rb +1 -1
  34. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  35. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  37. data/lib/hecks/framework.rb +1 -1
  38. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  39. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  40. data/lib/hecks/fuzzing/differential.rb +4 -2
  41. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  42. data/lib/hecks/fuzzing/form_census.rb +2 -2
  43. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  44. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  45. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  46. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  47. data/lib/hecks/gate/stages.yml +5 -5
  48. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  49. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  50. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  51. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  52. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  54. data/lib/hecks/hecks/codebase.bluebook +4 -3
  55. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  56. data/lib/hecks/hecks/custodian.bluebook +45 -3
  57. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  58. data/lib/hecks/hecks/hecks.world +6 -6
  59. data/lib/hecks/hecks/site.behaviors +17 -0
  60. data/lib/hecks/hecks/site.world +10 -0
  61. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  62. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  63. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  64. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  65. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  66. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  67. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  68. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  69. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  70. data/lib/hecks/projections/deploy/box.rb +387 -0
  71. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  72. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  73. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  74. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  75. data/lib/hecks/projections/diagrams.rb +2 -2
  76. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  77. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  78. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  79. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  80. data/lib/hecks/projections/site/edge.rb +136 -0
  81. data/lib/hecks/projections/site/regions.rb +44 -0
  82. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  83. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  84. data/lib/hecks/projections/site/table/checks.rb +124 -0
  85. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  86. data/lib/hecks/projections/site/table.rb +98 -0
  87. data/lib/hecks/projections.rb +2 -0
  88. data/lib/hecks/projector/cli_projector.rb +122 -104
  89. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  90. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  91. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  92. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  93. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  95. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  96. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  97. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  99. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  100. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  101. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  102. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  103. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  104. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  105. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  106. data/lib/hecks/runtime/registry/verification.rb +15 -29
  107. data/lib/hecks/runtime/registry.rb +14 -20
  108. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  109. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  110. data/lib/hecks/rust_build/domain_name.rb +26 -0
  111. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  112. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  113. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  114. data/lib/hecks/rust_build/project_rust.rb +57 -24
  115. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  116. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  117. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  118. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  119. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  120. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  121. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  122. data/lib/hecks/storehouse.rb +37 -7
  123. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  124. data/lib/hecks/three_zero/forms.yml +88 -88
  125. data/lib/hecks/tools/ci_gates.rb +1 -1
  126. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  127. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  128. data/lib/hecks/tools/sequence_script.rb +2 -2
  129. data/lib/hecks/tools/site_routes.rb +152 -0
  130. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  131. data/lib/hecks/tools.rb +1 -0
  132. data/lib/hecks/version.rb +1 -1
  133. data/rust/build/src/cargo_sync.rs +1 -1
  134. data/rust/build/src/pipeline.rs +23 -16
  135. data/rust/build/src/resolve.rs +39 -6
  136. data/rust/host/HECKS_RELEASE +1 -1
  137. data/rust/host/src/api.rs +1 -1
  138. data/rust/host/src/dispatch.rs +87 -7
  139. data/rust/host/src/needs.rs +111 -0
  140. data/rust/parser/src/keywords.rs +7 -6
  141. data/rust/parser/src/main.rs +4 -3
  142. data/rust/parser/src/parse/chapter.rs +1 -1
  143. data/rust/parser/src/parse/hecksagon.rs +25 -10
  144. data/rust/project/mutations.rb +4 -30
  145. data/rust/project/naming.rb +8 -28
  146. data/rust/project/write_if_changed.rb +4 -80
  147. data/rust/project_rust_pipeline.rb +16 -12
  148. data/rust/src/kernel/cli.rs +2 -0
  149. data/rust/src/kernel/needs.rs +74 -1
  150. metadata +37 -1
@@ -109,7 +109,7 @@ module Hecks
109
109
  webhook_handler_module = deploy_settings.fetch(:handler_module, "WebLambdaHandler")
110
110
 
111
111
  # Scans every loaded chapter, not just this domain's own — a
112
- # `uses_framework`-attached chapter can itself declare a cross-domain
112
+ # `attaches`-attached chapter can itself declare a cross-domain
113
113
  # policy, including one nested inside `aggregate "X" do ... end`.
114
114
  cross_domain_lambda_targets = cross_domain_registry.bluebooks.flat_map { |chapter_name, bluebook|
115
115
  bluebook.policies.select(&:target_domain).map(&:target_domain)
@@ -909,8 +909,8 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
909
909
  # settings -- the exact same call this target runs automatically
910
910
  # for a domain with its own dedicated instance.
911
911
  #
912
- # IF THIS DOMAIN VENDORS/ATTACHES ANOTHER BLUEBOOK (uses_embryonaut_
913
- # bluebook, uses_framework), `check!` alone leaves THAT chapter's own
912
+ # IF THIS DOMAIN VENDORS/ATTACHES ANOTHER BLUEBOOK (attaches,
913
+ # attaches ... from: :vendor), `check!` alone leaves THAT chapter's own
914
914
  # aggregates with no snapshot table at all -- it only provisions the
915
915
  # ONE bluebook it's called with (era_resolver.rb's own `bluebook.
916
916
  # aggregates.each`), never the whole registry. Found live minting
@@ -998,7 +998,7 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
998
998
  # hecks_eras at all. The fix reuses the SAME loading pipeline
999
999
  # `Loader.boot` itself does (`Ports::Loading.bootstrap`/
1000
1000
  # `load_library`/`load_project`/`load_domain`) to build a real,
1001
- # fully-loaded registry (bluebook + any uses_framework attachments),
1001
+ # fully-loaded registry (bluebook + any attaches attachments),
1002
1002
  # then calls straight into PostgresEra's own `LineageManager.check!`
1003
1003
  # with THIS deploy's actual `DATABASE_URL` — bypassing the world
1004
1004
  # file's own adapter choice entirely, the same targeted pattern
@@ -1072,7 +1072,8 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1072
1072
  end
1073
1073
 
1074
1074
  # Shares mint_era_recipe's bastion/tunnel/retry/teardown chain but
1075
- # runs `hecks ask scaffold_translation` or `hecks ask audit_translation` over it.
1075
+ # runs `hecks query era.scaffold_translation` or
1076
+ # `hecks query era.audit_translation` over it.
1076
1077
  #
1077
1078
  # Neither script actually reads DATABASE_URL/HECKS_SCHEMA (both call
1078
1079
  # `registry.binding_settings`, a lookup against the literal `database
@@ -1147,12 +1148,12 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1147
1148
 
1148
1149
  # `hecks ask` answers the scaffold as text, and refuses (exit 1) where the script aborted.
1149
1150
  scaffold_translation_recipe = translation_recipe.call(
1150
- "scaffold-translation", "exe/hecks ask scaffold_translation", db_env_blind: true,
1151
+ "scaffold-translation", "exe/hecks query era.scaffold_translation", db_env_blind: true,
1151
1152
  run_prefix: "HECKS_ENVIRONMENT=memory ruby", domain_arg: "domain=$(DOMAIN) "
1152
1153
  )
1153
1154
  # A refused audit is a refused query: `hecks ask` exits 1, so the target fails too.
1154
1155
  translation_audit_recipe = translation_recipe.call(
1155
- "translation-audit", "exe/hecks ask audit_translation", db_env_blind: true,
1156
+ "translation-audit", "exe/hecks query era.audit_translation", db_env_blind: true,
1156
1157
  run_prefix: "HECKS_ENVIRONMENT=memory ruby", domain_arg: "domain=$(DOMAIN) "
1157
1158
  )
1158
1159
 
@@ -1393,7 +1394,7 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1393
1394
  # field). `hecks build_wasm` recompiles fast enough that "always
1394
1395
  # rebuild" is the safe default, matching rust/host's own bootstrap
1395
1396
  # build just below, which was never guarded this way to begin with.
1396
- \tcd #{root} && HECKS_ENVIRONMENT=memory ruby exe/hecks build_wasm domain=#{domain} --wait
1397
+ \tcd #{root} && HECKS_ENVIRONMENT=memory ruby exe/hecks build.build_wasm domain=#{domain} --wait
1397
1398
  \t@rustup target list --installed 2>/dev/null | grep -qx aarch64-unknown-linux-gnu || rustup target add aarch64-unknown-linux-gnu
1398
1399
  # `rustup run stable`, not a bare `cargo lambda` — same reasoning
1399
1400
  # `hecks build_wasm`'s own Makefile-equivalent line holds itself to: a
@@ -1495,12 +1496,12 @@ bastion_yaml = shared ? nil : Shared.bastion_yaml(
1495
1496
  .PHONY: verify-parity-#{logical_id}
1496
1497
  verify-parity-#{logical_id}:
1497
1498
  \t@if [ -f #{root}/spec/corpus/#{domain_name}.json ]; then \\
1498
- \t\tcd #{root} && HECKS_ENVIRONMENT=memory ruby exe/hecks check_conformance domain=#{domain} script=spec/corpus/#{domain_name}.json artifact=$(WASM) --wait; \\
1499
+ \t\tcd #{root} && HECKS_ENVIRONMENT=memory ruby exe/hecks build.check_conformance domain=#{domain} script=spec/corpus/#{domain_name}.json artifact=$(WASM) --wait; \\
1499
1500
  \telse \\
1500
1501
  \t\techo "verify-parity-#{logical_id}: no spec/corpus/#{domain_name}.json -- SKIPPING the pre-deploy Ruby/Rust parity check, nothing to compare $(WASM) against. Write one (hecks fuzz/hecks run's own script shape) before this domain's next deploy."; \\
1501
1502
  \tfi
1502
1503
  \t@echo "verify-parity-#{logical_id}: fuzzing $(WASM) against generated sequences (ADR 0037's bridge, WARN-ONLY -- see this target's own comment)..."
1503
- \t-@cd #{root} && HECKS_ENVIRONMENT=memory ruby exe/hecks fuzz_conformance domain=#{domain} artifact=$(WASM) --wait
1504
+ \t-@cd #{root} && HECKS_ENVIRONMENT=memory ruby exe/hecks build.fuzz_conformance domain=#{domain} artifact=$(WASM) --wait
1504
1505
 
1505
1506
  # `make mint-era` — takes #{stack_name}'s RDS instance from freshly
1506
1507
  # created to "era 1 minted, ready for HECKS_DOMAIN/HECKS_ERA to
@@ -51,4 +51,4 @@ smoke-after-deploy:
51
51
  # Compares the era a live host reports at GET /version with expected-era.
52
52
  check-era:
53
53
  @test -n "$(URL)" || { echo "usage: make check-era URL=https://host.example"; exit 1; }
54
- HECKS_ENVIRONMENT=memory ruby "$(HECKS_ROOT)/exe/hecks" check_era host="$(URL)" expected=expected-era --wait
54
+ HECKS_ENVIRONMENT=memory ruby "$(HECKS_ROOT)/exe/hecks" host.check_era host="$(URL)" expected=expected-era --wait
@@ -306,12 +306,12 @@ module Hecks
306
306
  def frameworks_diagram(bluebook, hecksagon)
307
307
  return nil unless hecksagon
308
308
 
309
- lines = hecksagon.framework_members.map { |name| domain_edge(bluebook.name, "attaches", name, dotted: true) }
309
+ lines = hecksagon.member_chapters.map { |name| domain_edge(bluebook.name, "attaches", name, dotted: true) }
310
310
  lines += bluebook.policies.filter_map(&:target_domain).uniq
311
311
  .map { |name| domain_edge(bluebook.name, "reaches across", name, dotted: false) }
312
312
  return nil if lines.empty?
313
313
 
314
- subject = "#{bluebook.name}'s own declared uses_framework and cross-domain policy targets"
314
+ subject = "#{bluebook.name}'s own declared attaches and cross-domain policy targets"
315
315
  "#{header(bluebook.name, subject)}flowchart LR\n#{lines.uniq.join("\n")}\n"
316
316
  end
317
317
 
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ class Edge
7
+ # The CloudFront behaviours a route table needs.
8
+ #
9
+ # Each route through the CDN resolves to the behaviour its origin, cache class, methods and
10
+ # compression give it. A route needs none of its own when the behaviour that would match its
11
+ # path anyway (the nearest broader route's, else the default one) is the same. The rest keep
12
+ # the order the table declares, which is the order CloudFront matches in, and a route that
13
+ # a broader, different one before it would shadow is refused.
14
+ class Behaviours
15
+ # The default behaviour's path: the row that holds the settings of everything else.
16
+ DEFAULT_PATH = "/*"
17
+
18
+ # @param edge [Edge] the edge being built; its rows are set
19
+ # @param rows [Array<Table::Row>] the route table's rows
20
+ # @param problems [Array<String>] collects each problem found, one line each
21
+ def initialize(edge, rows, problems)
22
+ @edge = edge
23
+ @rows = rows
24
+ @problems = problems
25
+ end
26
+
27
+ # @return [Hash{Symbol => Object}] `:default` the default behaviour and `:list` the
28
+ # others, each as `Cdn.behavior_lines` takes it, `:path` set on those of the list
29
+ def call
30
+ candidates = resolved
31
+ default = candidates.delete(DEFAULT_PATH) || {}
32
+ kept = candidates.reject { |pattern, entry| same?(nearest_cover(candidates, pattern, default), entry) }
33
+ check_order(kept.values)
34
+ { default: default.except(:path), list: kept.values }
35
+ end
36
+
37
+ private
38
+
39
+ def same?(one, other) = one.except(:path) == other.except(:path)
40
+
41
+ # Each CDN route's edge pattern to its behaviour; rows on one pattern with different verbs
42
+ # share it, their methods united.
43
+ def resolved
44
+ groups = @rows.select(&:cdn).group_by { |row| Pattern.edge(row.path) }
45
+ groups.to_h do |pattern, rows|
46
+ entries = rows.map { |row| entry(row, pattern) }
47
+ disagree = entries.map { |entry| entry.except(:methods) }.uniq.size > 1
48
+ problem(pattern, "is declared by #{rows.size} routes that resolve to different behaviours") if disagree
49
+ [pattern, entries.first.merge(methods: methods(rows))]
50
+ end
51
+ end
52
+
53
+ def entry(row, pattern)
54
+ policy = @edge.policy(row.cache, row.origin)
55
+ {
56
+ path: pattern, origin: @edge.upstream(row.origin).id, methods: methods([row]),
57
+ viewer_protocol: protocol(row), compress: row.compress,
58
+ cache_policy: Checks.resolve(policy.cache),
59
+ origin_request_policy: reference(policy.origin_request),
60
+ response_headers_policy: reference(policy.response_headers)
61
+ }
62
+ end
63
+
64
+ def reference(text) = text.nil? || text == "none" ? nil : Checks.resolve(text)
65
+
66
+ # CloudFront takes three sets of methods: read, read with `OPTIONS`, and all.
67
+ def methods(rows)
68
+ verbs = rows.flat_map(&:verbs).uniq
69
+ return Deploy::Fargate::Cdn::METHODS.fetch("all") unless (verbs - ["GET"]).empty?
70
+
71
+ assets = rows.all? { |row| row.origin == "assets" }
72
+ Deploy::Fargate::Cdn::METHODS.fetch(assets ? "get_head" : "read")
73
+ end
74
+
75
+ # A public page that is only read, from the website or the assets, may be asked for over
76
+ # http and redirected; anything else (a form, an API, a signed or admin page, a route of
77
+ # the cms or the domain) is served over https only.
78
+ def protocol(row)
79
+ plain = row.auth == "public" && row.verbs == ["GET"] && %w[website assets].include?(row.origin)
80
+ plain ? "redirect-to-https" : "https-only"
81
+ end
82
+
83
+ # The behaviour CloudFront would match for `pattern` were it not in the list: that of the
84
+ # most specific broader pattern, else the default behaviour.
85
+ def nearest_cover(candidates, pattern, default)
86
+ covers = candidates.keys.select { |other| Pattern.strictly_covers?(other, pattern) }
87
+ nearest = covers.find { |one| covers.none? { |other| Pattern.strictly_covers?(one, other) } }
88
+ nearest ? candidates.fetch(nearest) : default
89
+ end
90
+
91
+ def check_order(list)
92
+ list.each_with_index do |later, index|
93
+ list.first(index).each do |earlier|
94
+ next unless Pattern.strictly_covers?(earlier[:path], later[:path]) && !same?(earlier, later)
95
+
96
+ problem(later[:path], "is shadowed by #{earlier[:path]}, which comes first and matches it; " \
97
+ "declare #{later[:path]} before #{earlier[:path]}")
98
+ end
99
+ end
100
+ end
101
+
102
+ def problem(label, text) = @problems << "#{label} #{text}"
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ class Edge
7
+ # What the edge rows must satisfy before anything is built from them: each reference is to a
8
+ # thing that exists, no two rows claim one name or priority, and every route the edge serves
9
+ # has the origin, the policy and, for a server behind the load balancer, the rule it needs.
10
+ class Checks
11
+ LOGICAL_ID = /\A[A-Za-z][A-Za-z0-9]*\z/
12
+ INTRINSIC = /\A!(Ref|ImportValue|Sub|GetAtt)\s+\S/
13
+ PRIORITIES = (1..50_000)
14
+
15
+ # The id a policy reference names, or nil when it names none.
16
+ #
17
+ # @param reference [String] a managed policy name, an id, or an intrinsic
18
+ # @return [Array<String>, nil] `[id_or_intrinsic, comment_or_nil]`
19
+ def self.resolve(reference)
20
+ managed = Deploy::Fargate::Cdn::MANAGED_POLICIES[reference]
21
+ return managed if managed
22
+ return [reference, nil] if Deploy::Fargate::Cdn::UUID.match?(reference) || INTRINSIC.match?(reference)
23
+
24
+ nil
25
+ end
26
+
27
+ # @param edge [Edge] the edge being read, its rows set
28
+ # @param rows [Array<Table::Row>] the route table's rows
29
+ # @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
30
+ # @param problems [Array<String>] collects each problem found, one line each
31
+ def initialize(edge, rows, vocabulary, problems)
32
+ @edge = edge
33
+ @rows = rows
34
+ @vocabulary = vocabulary
35
+ @problems = problems
36
+ end
37
+
38
+ # @return [void]
39
+ def call
40
+ check_setting
41
+ check_policies
42
+ check_upstreams
43
+ check_rules
44
+ check_routes
45
+ end
46
+
47
+ private
48
+
49
+ def check_setting
50
+ setting = @edge.setting
51
+ return problem("Edge", "has no row naming the template; declare one member with template:") unless setting
52
+
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
+ problem("Edge", "has secret_header but no secret_value") if setting.secret_header && !setting.secret_value
57
+ problem("Edge", "has secret_value but no secret_header") if setting.secret_value && !setting.secret_header
58
+ end
59
+
60
+ def check_policies
61
+ @edge.policies.each do |policy|
62
+ label = "EdgePolicy #{policy.cache_class}#{" on #{policy.origin}" if policy.origin}"
63
+ member_of(label, "cache_class", policy.cache_class, :cache)
64
+ member_of(label, "origin", policy.origin, :origin) if policy.origin
65
+ { cache: policy.cache, origin_request: policy.origin_request,
66
+ response_headers: policy.response_headers }.compact.each do |field, reference|
67
+ next if (reference == "none" && field != :cache) || self.class.resolve(reference)
68
+
69
+ managed = Deploy::Fargate::Cdn::MANAGED_POLICIES.keys.join(", ")
70
+ problem(label, "has #{field} #{reference.inspect}; use a managed name (#{managed}), " \
71
+ "a policy id or an intrinsic like !Ref Name")
72
+ end
73
+ end
74
+ repeated(@edge.policies.map { |policy| [policy.cache_class, policy.origin] }, "EdgePolicy") do |key|
75
+ "#{key[0]}#{" on #{key[1]}" if key[1]}"
76
+ end
77
+ end
78
+
79
+ def check_upstreams
80
+ @edge.upstreams.each do |upstream|
81
+ member_of("EdgeOrigin #{upstream.origin}", "origin", upstream.origin, :origin)
82
+ problem("EdgeOrigin #{upstream.origin}", "has id #{upstream.id.inspect}; an origin id is a logical id") unless
83
+ LOGICAL_ID.match?(upstream.id.to_s)
84
+ end
85
+ repeated(@edge.upstreams.map(&:origin), "EdgeOrigin", &:itself)
86
+ end
87
+
88
+ def check_rules
89
+ problem("EdgeRule", "rows need an Edge row with listener:") if @edge.rules.any? && !@edge.setting&.listener
90
+ @edge.rules.each { |rule| check_rule(rule) }
91
+ repeated(@edge.rules.map(&:rule), "EdgeRule", &:itself)
92
+ repeated(@edge.rules.map(&:priority), "EdgeRule priority", &:to_s)
93
+ end
94
+
95
+ def check_rule(rule)
96
+ label = "EdgeRule #{rule.rule}"
97
+ member_of(label, "origin", rule.origin, :origin)
98
+ problem(label, "has origin assets; the load balancer does not front the assets origin") if rule.origin == "assets"
99
+ problem(label, "is not a logical id") unless LOGICAL_ID.match?(rule.rule.to_s)
100
+ unless PRIORITIES.cover?(rule.priority.to_i)
101
+ problem(label, "has priority #{rule.priority}; a priority is #{PRIORITIES.first} to #{PRIORITIES.last}")
102
+ end
103
+ return if rule.origin.nil? || @edge.upstream(rule.origin)&.target_group
104
+
105
+ problem(label, "forwards to #{rule.origin}, whose EdgeOrigin names no target_group")
106
+ end
107
+
108
+ def check_routes
109
+ edge_rows = @rows.select { |row| row.cdn || row.alb_rule }
110
+ edge_rows.each do |row|
111
+ next if row.origin == "assets" && !row.cdn
112
+
113
+ problem(row.path, "has origin #{row.origin}, which no EdgeOrigin maps") unless @edge.upstream(row.origin)
114
+ next unless row.cdn && !@edge.policy(row.cache, row.origin)
115
+
116
+ problem(row.path, "has cache class #{row.cache} on #{row.origin}, which no EdgePolicy maps")
117
+ end
118
+ default_rows = @rows.select { |row| row.path == "/*" && row.cdn }
119
+ problem("the route table", "needs one row for /* : the default behaviour") if default_rows.empty?
120
+ end
121
+
122
+ def member_of(label, field, value, vocabulary)
123
+ return if @vocabulary.fetch(vocabulary).include?(value)
124
+
125
+ problem(label, "has #{field} #{value.inspect}; #{field} is one of #{@vocabulary.fetch(vocabulary).join(', ')}")
126
+ end
127
+
128
+ def repeated(keys, what)
129
+ keys.tally.each { |key, count| problem(what, "#{yield(key)} is declared #{count} times") if count > 1 }
130
+ end
131
+
132
+ def problem(label, text) = @problems << "#{label} #{text}"
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ class Edge
7
+ # A path pattern as CloudFront and the load balancer read it, and the one question the edge
8
+ # asks of two of them: does the first match everything the second does.
9
+ #
10
+ # A route path names a parameter as `:slug`; the edge has no parameters, so a parameter
11
+ # stands for `*`. Both services treat `*` as any run of characters, `/` included, and `?` as
12
+ # one character, and match the whole path, so a pattern without either matches one path.
13
+ module Pattern
14
+ module_function
15
+
16
+ # @param path [String] a route path such as `/blog/:slug.html` or `/cms/*`
17
+ # @return [String] the pattern the edge reads for it, `/blog/*.html` for the first
18
+ def edge(path) = path.gsub(/:[A-Za-z_]\w*/, "*")
19
+
20
+ # @param pattern [String] an edge pattern
21
+ # @return [Regexp] what it matches, from the start of a path to its end
22
+ def regexp(pattern)
23
+ source = pattern.each_char.map do |char|
24
+ case char
25
+ when "*" then ".*"
26
+ when "?" then "."
27
+ else Regexp.escape(char)
28
+ end
29
+ end
30
+ Regexp.new("\\A#{source.join}\\z")
31
+ end
32
+
33
+ # Whether every path `specific` matches is also matched by `general`, tested by reading
34
+ # `specific` as a literal string, so `/admin*` covers `/admin/members` and `/admin-inbox`
35
+ # and `/cms/*` covers `/cms/_next/static/*`. A pattern covers itself.
36
+ #
37
+ # @param general [String] an edge pattern
38
+ # @param specific [String] an edge pattern
39
+ # @return [Boolean]
40
+ def covers?(general, specific) = regexp(general).match?(specific)
41
+
42
+ # @param general [String] an edge pattern
43
+ # @param specific [String] an edge pattern
44
+ # @return [Boolean] whether `general` covers `specific` and is not the same pattern
45
+ def strictly_covers?(general, specific) = general != specific && covers?(general, specific)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Site
6
+ class Edge
7
+ # The load balancer's listener rules a route table needs.
8
+ #
9
+ # A route names the rule that carries its path with `alb_rule`. A route of a server other
10
+ # than the website needs one unless a broader route of the same server has it: `/cms/*`
11
+ # carries `/cms/_static/*` too. The website's routes need none, since the website takes
12
+ # whatever no rule claims; the default row `/*` names that rule, written with no path
13
+ # condition. A rule is refused that carries more paths than a condition may hold, that
14
+ # carries no route, or that a lower-numbered rule of another server would answer first.
15
+ class Rules
16
+ # An ALB rule holds five condition values in all; the secret header's takes one.
17
+ VALUES = 5
18
+
19
+ # @param edge [Edge] the edge being built; its rows are set
20
+ # @param rows [Array<Table::Row>] the route table's rows
21
+ # @param problems [Array<String>] collects each problem found, one line each
22
+ def initialize(edge, rows, problems)
23
+ @edge = edge
24
+ @rows = rows
25
+ @problems = problems
26
+ end
27
+
28
+ # @return [Array<Hash{Symbol => Object}>] each rule by ascending priority, with `:rule`,
29
+ # `:priority`, `:origin`, `:target_group` and the `:paths` it carries
30
+ def call
31
+ check_names
32
+ check_carried
33
+ check_cross_origin
34
+ @edge.rules.sort_by(&:priority).map { |rule| render(rule) }
35
+ end
36
+
37
+ private
38
+
39
+ def render(rule)
40
+ paths = carried(rule).map { |row| Pattern.edge(row.path) }.uniq
41
+ check_values(rule, paths)
42
+ { rule: rule.rule, priority: rule.priority, origin: rule.origin,
43
+ target_group: @edge.upstream(rule.origin)&.target_group, paths: paths }
44
+ end
45
+
46
+ def carried(rule) = @rows.select { |row| row.alb_rule == rule.rule }
47
+
48
+ def check_values(rule, paths)
49
+ if paths.empty?
50
+ problem("EdgeRule #{rule.rule}", "carries no route; name it with alb_rule: on a route")
51
+ elsif paths.include?("/*") && paths.size > 1
52
+ problem("EdgeRule #{rule.rule}", "carries /*, which every other path it lists is part of")
53
+ end
54
+ limit = @edge.setting.secret_header ? VALUES - 1 : VALUES
55
+ return if paths.size <= limit
56
+
57
+ problem("EdgeRule #{rule.rule}", "carries #{paths.size} paths; a rule holds at most #{limit} " \
58
+ "condition values, so split it into two rules")
59
+ end
60
+
61
+ def check_names
62
+ names = @edge.rules.map(&:rule)
63
+ @rows.select(&:alb_rule).each do |row|
64
+ rule = @edge.rules.find { |candidate| candidate.rule == row.alb_rule }
65
+ next problem(row.path, "names alb_rule #{row.alb_rule}; rules are #{names.join(', ')}") unless rule
66
+ next if rule.origin == row.origin
67
+
68
+ problem(row.path, "is served from #{row.origin} but its rule #{rule.rule} forwards to #{rule.origin}")
69
+ end
70
+ end
71
+
72
+ # A route of a server behind the load balancer other than the website has a rule of its
73
+ # own or sits under a broader route of the same server that has one.
74
+ def check_carried
75
+ @rows.each do |row|
76
+ next if row.origin == "website" || row.origin == "assets" || rule_of(row)
77
+
78
+ problem(row.path, "is served from #{row.origin} and no rule carries it; name one with alb_rule: " \
79
+ "or put it under a broader #{row.origin} route that has one")
80
+ end
81
+ end
82
+
83
+ # The rule that carries a route: its own, else the nearest broader route's of its server.
84
+ def rule_of(row)
85
+ named = @edge.rules.find { |rule| rule.rule == row.alb_rule }
86
+ return named if named
87
+
88
+ covers = @rows.select do |other|
89
+ other.alb_rule && other.origin == row.origin &&
90
+ Pattern.strictly_covers?(Pattern.edge(other.path), Pattern.edge(row.path))
91
+ end
92
+ covering = covers.find do |one|
93
+ covers.none? do |other|
94
+ Pattern.strictly_covers?(Pattern.edge(one.path), Pattern.edge(other.path))
95
+ end
96
+ end
97
+ covering && @edge.rules.find { |rule| rule.rule == covering.alb_rule }
98
+ end
99
+
100
+ # A rule of another server, with a lower priority, whose paths include one a route's own
101
+ # rule would answer later, is a route answered by the wrong server.
102
+ def check_cross_origin
103
+ fallback = default_priority
104
+ @rows.each do |row|
105
+ next if row.origin == "assets"
106
+
107
+ own = rule_of(row)&.priority || fallback
108
+ @edge.rules.each do |rule|
109
+ next if rule.origin == row.origin || rule.priority >= own
110
+ next unless carried(rule).any? { |other| Pattern.covers?(Pattern.edge(other.path), Pattern.edge(row.path)) }
111
+
112
+ problem(row.path, "is served from #{row.origin} but rule #{rule.rule} (priority #{rule.priority}) " \
113
+ "sends it to #{rule.origin} first")
114
+ end
115
+ end
116
+ end
117
+
118
+ def default_priority
119
+ default = @rows.find { |row| row.path == "/*" }
120
+ @edge.rules.find { |rule| rule.rule == default&.alb_rule }&.priority || Float::INFINITY
121
+ end
122
+
123
+ def problem(label, text) = @problems << "#{label} #{text}"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../deploy/fargate/cdn"
4
+ require_relative "table"
5
+ require_relative "edge/pattern"
6
+ require_relative "edge/checks"
7
+ require_relative "edge/behaviours"
8
+ require_relative "edge/rules"
9
+
10
+ module Hecks
11
+ module Projections
12
+ module Site
13
+ # What a site's route table says about the edge: the CloudFront behaviours and the load
14
+ # balancer's listener rules that send each route to the server and cache that serve it.
15
+ #
16
+ # The routes are the table's own `Route` rows. What a table cannot know, the names a template
17
+ # gives its policies, origins, target groups and listener, a project declares as `member`
18
+ # rows of four more value objects of the same chapter: `Edge` (the template and listener),
19
+ # `EdgePolicy`, `EdgeOrigin` and `EdgeRule`. `docs/site-routes.md` lists their fields. A
20
+ # project that declares none of the four has no edge to project.
21
+ class Edge
22
+ Setting = Struct.new(:template, :listener, :secret_header, :secret_value, keyword_init: true)
23
+ Policy = Struct.new(:cache_class, :origin, :cache, :origin_request, :response_headers, keyword_init: true)
24
+ Upstream = Struct.new(:origin, :id, :target_group, keyword_init: true)
25
+ Rule = Struct.new(:rule, :priority, :origin, keyword_init: true)
26
+
27
+ # The value objects a project declares its edge rows in, by the kind of record each holds.
28
+ OBJECTS = { setting: "Edge", policy: "EdgePolicy", upstream: "EdgeOrigin", rule: "EdgeRule" }.freeze
29
+
30
+ # The fields each kind of row may carry, with the Ruby class each takes.
31
+ FIELDS = {
32
+ setting: { template: String, listener: String, secret_header: String, secret_value: String },
33
+ policy: { cache_class: String, origin: String, cache: String, origin_request: String,
34
+ response_headers: String },
35
+ upstream: { origin: String, id: String, target_group: String },
36
+ rule: { rule: String, priority: Integer, origin: String }
37
+ }.freeze
38
+
39
+ # The fields a row of each kind must carry.
40
+ REQUIRED = { setting: [:template], policy: %i[cache_class cache], upstream: %i[origin id],
41
+ rule: %i[rule priority origin] }.freeze
42
+
43
+ STRUCTS = { setting: Setting, policy: Policy, upstream: Upstream, rule: Rule }.freeze
44
+
45
+ # @return [Setting] the template and the listener
46
+ attr_reader :setting
47
+ # @return [Array<Policy>] the cache class to policy mapping
48
+ attr_reader :policies
49
+ # @return [Array<Upstream>] the origin mapping
50
+ attr_reader :upstreams
51
+ # @return [Array<Rule>] the listener rules
52
+ attr_reader :rules
53
+ # @return [Hash{Symbol => Object}] the default behaviour as `Cdn.behavior_lines` takes it
54
+ attr_reader :default_behaviour
55
+ # @return [Array<Hash{Symbol => Object}>] the other behaviours, in matching order
56
+ attr_reader :behaviours
57
+ # @return [Array<Hash{Symbol => Object}>] each listener rule with the paths it carries
58
+ attr_reader :listener_rules
59
+
60
+ # Reads and checks the edge a chapter declares.
61
+ #
62
+ # @param chapter [Bluebook::Chapter] the chapter that declares the route table
63
+ # @param table [Table] the checked route table
64
+ # @param vocabulary [Hash{Symbol => Array<String>}] the Site chapter's closed sets
65
+ # @return [Edge, nil] the checked edge, or nil when the chapter declares none
66
+ # @raise [Table::Invalid] when a row is refused or the edge contradicts the routes
67
+ def self.read(chapter, table:, vocabulary:)
68
+ declared = OBJECTS.transform_values { |name| Table.rows_of(chapter, name) }
69
+ return nil if declared.values.all?(&:empty?)
70
+
71
+ problems = []
72
+ records = declared.to_h { |kind, members| [kind, build(kind, members, problems)] }
73
+ new(records, table: table, vocabulary: vocabulary, problems: problems)
74
+ end
75
+
76
+ # @param kind [Symbol] a key of `OBJECTS`
77
+ # @param members [Array<Hash{Symbol => Object}>] the declared rows
78
+ # @param problems [Array<String>] collects each problem found
79
+ # @return [Array<Struct>] the rows that carry known, well-typed fields
80
+ def self.build(kind, members, problems)
81
+ members.each_with_index.map do |member, index|
82
+ label = "#{OBJECTS.fetch(kind)} row #{index + 1}"
83
+ unknown = member.keys - FIELDS.fetch(kind).keys
84
+ if unknown.any?
85
+ problems << "#{label} has no field #{unknown.join(', ')}; fields are #{FIELDS.fetch(kind).keys.join(', ')}"
86
+ end
87
+ (REQUIRED.fetch(kind) - member.keys).each { |field| problems << "#{label} needs #{field}" }
88
+ 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
+ end
92
+ STRUCTS.fetch(kind).new(**typed)
93
+ end
94
+ end
95
+ private_class_method :build
96
+
97
+ # @param records [Hash{Symbol => Array<Struct>}] the rows of each kind
98
+ # @param table [Table] the checked route table
99
+ # @param vocabulary [Hash{Symbol => Array<String>}] the closed sets
100
+ # @param problems [Array<String>] the problems already found in reading the rows
101
+ # @raise [Table::Invalid] naming every problem when there is one
102
+ def initialize(records, table:, vocabulary:, problems:)
103
+ @setting = records.fetch(:setting).first
104
+ @policies = records.fetch(:policy)
105
+ @upstreams = records.fetch(:upstream)
106
+ @rules = records.fetch(:rule)
107
+ Checks.new(self, table.rows, vocabulary, problems).call
108
+ if problems.empty?
109
+ @listener_rules = Rules.new(self, table.rows, problems).call
110
+ built = Behaviours.new(self, table.rows, problems).call
111
+ @default_behaviour = built.fetch(:default)
112
+ @behaviours = built.fetch(:list)
113
+ end
114
+ return if problems.empty?
115
+
116
+ raise Table::Invalid, "the edge is refused:\n#{problems.map { |line| " - #{line}" }.join("\n")}"
117
+ end
118
+
119
+ # @param origin [String] an origin of the Origin set
120
+ # @return [Upstream, nil] its CloudFront origin and target group
121
+ def upstream(origin) = upstreams.find { |candidate| candidate.origin == origin }
122
+
123
+ # The policy mapping for a class on an origin: the row naming both, else the one naming the
124
+ # class alone.
125
+ #
126
+ # @param cache_class [String] a cache class
127
+ # @param origin [String] an origin
128
+ # @return [Policy, nil]
129
+ def policy(cache_class, origin)
130
+ matching = policies.select { |candidate| candidate.cache_class == cache_class }
131
+ matching.find { |candidate| candidate.origin == origin } || matching.find { |candidate| candidate.origin.nil? }
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end