hecks 3.0.4 → 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.
- checksums.yaml +4 -4
- data/exe/hecks +25 -7
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
- data/lib/hecks/bluebook/hexagon.rb +45 -25
- data/lib/hecks/bluebook/model_check.rb +7 -7
- data/lib/hecks/chapters.rb +31 -3
- data/lib/hecks/cli/check_era.rb +1 -1
- data/lib/hecks/cli/console.rb +24 -8
- data/lib/hecks/cli/deploy_template_diff.rb +1 -1
- data/lib/hecks/cli/doc_coverage.rb +1 -1
- data/lib/hecks/cli/domain_stub.rb +128 -0
- data/lib/hecks/cli/interview_draft.rb +246 -0
- data/lib/hecks/cli/model_check.rb +1 -1
- data/lib/hecks/cli/project_cli.rb +69 -18
- data/lib/hecks/cli/reference.rb +2 -1
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
- data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
- data/lib/hecks/cli/spec_example.rb +1 -1
- data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
- data/lib/hecks/corpus.rb +11 -8
- data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
- data/lib/hecks/deploy/oidc.json +5 -0
- data/lib/hecks/doc/reference.rb +4 -3
- data/lib/hecks/doors/cli_door.rb +13 -13
- data/lib/hecks/doors/cli_runner.rb +52 -32
- data/lib/hecks/doors/launcher_options.rb +23 -5
- data/lib/hecks/doors/mcp_door.rb +40 -18
- data/lib/hecks/doors/mcp_door_scope.rb +244 -24
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -2
- data/lib/hecks/forms/banking_presentation.rb +1 -1
- data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
- data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
- data/lib/hecks/framework.rb +1 -1
- data/lib/hecks/fuzzing/combination_miner.rb +1 -1
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
- data/lib/hecks/fuzzing/differential.rb +4 -2
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/form_census.rb +2 -2
- data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
- data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
- data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
- data/lib/hecks/gate/stages.yml +8 -4
- data/lib/hecks/grammar/grammar.hecksagon +2 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
- data/lib/hecks/hecks/adapters/host_http.rb +4 -3
- data/lib/hecks/hecks/adapters/local_files.rb +59 -0
- data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
- data/lib/hecks/hecks/codebase.bluebook +4 -3
- data/lib/hecks/hecks/context_map.hecksagon +2 -2
- data/lib/hecks/hecks/custodian.bluebook +45 -3
- data/lib/hecks/hecks/hecks.hecksagon +10 -4
- data/lib/hecks/hecks/hecks.world +7 -4
- data/lib/hecks/hecks/site.behaviors +17 -0
- data/lib/hecks/hecks/site.world +10 -0
- data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
- data/lib/hecks/projections/deploy/box/settings.rb +248 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
- data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
- data/lib/hecks/projections/deploy/box.rb +387 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +11 -10
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
- data/lib/hecks/projections/diagrams.rb +2 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
- data/lib/hecks/projections/site/edge/checks.rb +137 -0
- data/lib/hecks/projections/site/edge/pattern.rb +50 -0
- data/lib/hecks/projections/site/edge/rules.rb +128 -0
- data/lib/hecks/projections/site/edge.rb +136 -0
- data/lib/hecks/projections/site/regions.rb +44 -0
- data/lib/hecks/projections/site/routes_ts.rb +284 -0
- data/lib/hecks/projections/site/site_cdn.rb +78 -0
- data/lib/hecks/projections/site/table/checks.rb +124 -0
- data/lib/hecks/projections/site/table/row_builder.rb +155 -0
- data/lib/hecks/projections/site/table.rb +98 -0
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/projector/cli_projector.rb +172 -95
- data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
- data/lib/hecks/quality_control/adapters/agent.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
- data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
- data/lib/hecks/quality_control/quality_control.bluebook +18 -18
- data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +2 -0
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/invocation.rb +5 -3
- data/lib/hecks/runtime/registry/verification.rb +15 -29
- data/lib/hecks/runtime/registry.rb +14 -20
- data/lib/hecks/rust_build/append_optionals.rb +79 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
- data/lib/hecks/rust_build/domain_name.rb +26 -0
- data/lib/hecks/rust_build/kernel_input.rb +58 -0
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
- data/lib/hecks/rust_build/project_rust.rb +57 -24
- data/lib/hecks/rust_build/rust_literal.rb +33 -0
- data/lib/hecks/rust_build/write_if_changed.rb +86 -0
- data/lib/hecks/site/bluebook/site.bluebook +205 -0
- data/lib/hecks/site/bluebook/site.hecksagon +5 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
- data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
- data/lib/hecks/storehouse.rb +37 -7
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
- data/lib/hecks/three_zero/forms.yml +88 -88
- data/lib/hecks/tools/ci_gates.rb +1 -1
- data/lib/hecks/tools/deploy_recipe.rb +14 -6
- data/lib/hecks/tools/oidc_manifests.rb +2 -1
- data/lib/hecks/tools/sequence_script.rb +2 -2
- data/lib/hecks/tools/site_routes.rb +152 -0
- data/lib/hecks/tools/tenant_provisioning.rb +1 -1
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/rust/build/src/cargo_sync.rs +1 -1
- data/rust/build/src/pipeline.rs +23 -16
- data/rust/build/src/resolve.rs +39 -6
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +87 -7
- data/rust/host/src/needs.rs +111 -0
- data/rust/host/src/web/newsletter/template.rs +69 -0
- data/rust/host/src/web/newsletter/tests.rs +43 -0
- data/rust/host/src/web/newsletter.rs +6 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/parser/src/keywords.rs +7 -6
- data/rust/parser/src/main.rs +4 -3
- data/rust/parser/src/parse/chapter.rs +1 -1
- data/rust/parser/src/parse/hecksagon.rs +25 -10
- data/rust/project/mutations.rb +4 -30
- data/rust/project/naming.rb +8 -28
- data/rust/project/write_if_changed.rb +4 -80
- data/rust/project_rust_pipeline.rb +16 -12
- data/rust/src/kernel/cli.rs +2 -0
- data/rust/src/kernel/needs.rs +74 -1
- metadata +39 -2
|
@@ -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
|
-
# `
|
|
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 (
|
|
913
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|