hecks 3.6.0 → 3.9.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/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
- data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
- data/lib/hecks/bluebook/meta_validator.rb +4 -2
- data/lib/hecks/cache_dir.rb +10 -0
- data/lib/hecks/corpus/routes.rb +1 -1
- data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
- data/lib/hecks/deploy/oidc.json +10 -0
- data/lib/hecks/doors/usage_cache.rb +19 -9
- data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
- data/lib/hecks/fuzzing/failure_triage.rb +80 -0
- data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
- data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
- data/lib/hecks/fuzzing/form_census.rb +28 -52
- data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
- data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
- data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
- data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
- data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
- data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
- data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
- data/lib/hecks/fuzzing/mutation/report.rb +78 -0
- data/lib/hecks/fuzzing/mutation/run.rb +99 -0
- data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
- data/lib/hecks/fuzzing/mutation/site.rb +21 -0
- data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
- data/lib/hecks/fuzzing/mutation.rb +12 -0
- data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
- data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
- data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
- data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
- data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
- data/lib/hecks/fuzzing/properties.rb +7 -0
- data/lib/hecks/fuzzing/replay/observation.rb +1 -1
- data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
- data/lib/hecks/fuzzing/replay/session.rb +22 -6
- data/lib/hecks/fuzzing/replay.rb +1 -0
- data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
- data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
- data/lib/hecks/fuzzing/shrinker.rb +3 -0
- data/lib/hecks/fuzzing.rb +3 -0
- data/lib/hecks/gate/stages.yml +12 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
- data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
- data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
- data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +37 -0
- data/lib/hecks/hecks/hecks.hecksagon +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
- data/lib/hecks/projections/deploy/box/settings.rb +18 -0
- data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
- data/lib/hecks/projections/deploy/box.rb +14 -2
- data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
- data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
- data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
- data/lib/hecks/projections/deploy/vercel.rb +134 -0
- data/lib/hecks/projections/site/probe.rb +131 -0
- data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
- data/lib/hecks/projections/site/site_host/templates.rb +12 -4
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
- data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
- data/lib/hecks/runtime/value/validation.rb +34 -3
- data/lib/hecks/rust_build/native_build.rb +29 -1
- data/lib/hecks/rust_build/wasm.rb +16 -3
- data/lib/hecks/site/bluebook/site.bluebook +31 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
- data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
- data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
- data/lib/hecks/tools/deploy_recipe.rb +20 -10
- data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
- data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
- data/lib/hecks/tools/fuzz_sweep.rb +26 -9
- data/lib/hecks/tools/lanes/files.rb +2 -4
- data/lib/hecks/tools/lanes/live.rb +3 -4
- data/lib/hecks/tools/lanes/ruleset.rb +23 -1
- data/lib/hecks/tools/lanes.rb +12 -1
- data/lib/hecks/tools/mutation_run.rb +73 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +3 -1
- data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
- data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
- data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
- data/qa/settings.yml +1 -0
- data/rust/codegen/src/fielded.rs +3 -3
- data/rust/codegen/src/json_codec.rs +2 -2
- data/rust/codegen/src/naming.rs +20 -0
- data/rust/host/Cargo.toml +6 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +28 -5
- data/rust/host/src/auth.rs +11 -1
- data/rust/host/src/boundary_fuzz/api.rs +260 -0
- data/rust/host/src/boundary_fuzz/auth.rs +119 -0
- data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
- data/rust/host/src/boundary_fuzz/ir.rs +79 -0
- data/rust/host/src/boundary_fuzz/server.rs +227 -0
- data/rust/host/src/boundary_fuzz/web.rs +125 -0
- data/rust/host/src/commerce_ir.rs +459 -0
- data/rust/host/src/extension.rs +100 -0
- data/rust/host/src/fuzz_support.rs +165 -0
- data/rust/host/src/ir.rs +4 -448
- data/rust/host/src/journal.rs +1 -1
- data/rust/host/src/lib.rs +39 -0
- data/rust/host/src/main.rs +10 -44
- data/rust/host/src/mint.rs +1 -1
- data/rust/host/src/payments/tests.rs +8 -8
- data/rust/host/src/payments.rs +10 -10
- data/rust/host/src/rate_limit.rs +27 -41
- data/rust/host/src/secrets.rs +44 -0
- data/rust/host/src/server.rs +6 -1
- data/rust/host/src/web/commerce.rs +113 -0
- data/rust/host/src/web/newsletter.rs +2 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/host/src/web/registration_receipt.rs +2 -2
- data/rust/host/src/web/registrations.rs +49 -49
- data/rust/host/src/web/resend_webhook.rs +299 -0
- data/rust/host/src/web.rs +55 -49
- data/rust/parser/fuzz/.gitignore +4 -0
- data/rust/parser/fuzz/Cargo.lock +89 -0
- data/rust/parser/fuzz/Cargo.toml +27 -0
- data/rust/parser/fuzz/README.md +19 -0
- data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
- data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
- data/rust/parser/src/parse/command.rs +14 -2
- data/rust/parser/src/parse/mod.rs +25 -0
- data/rust/parser/src/ruby_value.rs +95 -6
- data/rust/src/kernel/json.rs +34 -3
- metadata +58 -2
|
@@ -52,7 +52,7 @@ module Hecks
|
|
|
52
52
|
|
|
53
53
|
COPY deploy-aws/boot.mjs ./
|
|
54
54
|
# RDS enforces TLS; boot.mjs verifies against Amazon's CA bundle.
|
|
55
|
-
ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem ./rds-global-bundle.pem
|
|
55
|
+
ADD --chmod=0644 https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem ./rds-global-bundle.pem
|
|
56
56
|
|
|
57
57
|
# boot.mjs sits outside the Next app, so `next build` does not trace its import of the AWS SDK.
|
|
58
58
|
COPY --from=build /app/node_modules/@aws-sdk ./node_modules/@aws-sdk
|
|
@@ -71,9 +71,13 @@ module Hecks
|
|
|
71
71
|
|
|
72
72
|
const client = new SecretsManagerClient({});
|
|
73
73
|
|
|
74
|
-
async function
|
|
74
|
+
async function secretJson(arn) {
|
|
75
75
|
const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: arn }));
|
|
76
|
-
|
|
76
|
+
return JSON.parse(SecretString);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function secretField(arn, field) {
|
|
80
|
+
const value = (await secretJson(arn))[field];
|
|
77
81
|
if (!value) throw new Error(`secret ${arn} has no "${field}" field`);
|
|
78
82
|
return value;
|
|
79
83
|
}
|
|
@@ -81,12 +85,16 @@ module Hecks
|
|
|
81
85
|
if (process.env.DB_SECRET_ARN) {
|
|
82
86
|
const { DB_HOST, DB_NAME } = process.env;
|
|
83
87
|
if (!DB_HOST || !DB_NAME) throw new Error("DB_HOST and DB_NAME are required when DB_SECRET_ARN is set");
|
|
84
|
-
|
|
88
|
+
// The user and port come from the secret: RDS's master secret says postgres, and a client that shares
|
|
89
|
+
// an instance with others has its own role. A secret without them is read as postgres on 5432.
|
|
90
|
+
const { username = "postgres", password, port = 5432 } = await secretJson(process.env.DB_SECRET_ARN);
|
|
91
|
+
if (!password) throw new Error(`secret ${process.env.DB_SECRET_ARN} has no "password" field`);
|
|
85
92
|
// The password goes through encodeURIComponent: the generated one can hold characters (@ / #)
|
|
86
93
|
// that would otherwise split the URL. RDS enforces TLS; verify against Amazon's CA bundle,
|
|
87
94
|
// which the image holds beside this file.
|
|
88
95
|
const ssl = "sslmode=verify-full&sslrootcert=" + new URL("./rds-global-bundle.pem", import.meta.url).pathname;
|
|
89
|
-
process.env.DATABASE_URL =
|
|
96
|
+
process.env.DATABASE_URL =
|
|
97
|
+
`postgres://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${DB_HOST}:${port}/${DB_NAME}?${ssl}`;
|
|
90
98
|
} else if (!process.env.DATABASE_URL) {
|
|
91
99
|
throw new Error("either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required");
|
|
92
100
|
}
|
data/lib/hecks/projections.rb
CHANGED
|
@@ -23,6 +23,8 @@ require_relative "projections/deploy/shared"
|
|
|
23
23
|
require_relative "projections/deploy/lambda"
|
|
24
24
|
require_relative "projections/deploy/fargate"
|
|
25
25
|
require_relative "projections/deploy/box"
|
|
26
|
+
require_relative "projections/deploy/shared_database_instance"
|
|
27
|
+
require_relative "projections/deploy/vercel"
|
|
26
28
|
require_relative "projections/deploy/smoke"
|
|
27
29
|
require_relative "projections/deploy/template_diff"
|
|
28
30
|
require_relative "projections/site/routes_ts"
|
|
@@ -14,6 +14,10 @@ module Hecks
|
|
|
14
14
|
# Check-run conclusions that count as green; `skipped` covers path-filtered workflows.
|
|
15
15
|
PASSING = %w[success neutral skipped].freeze
|
|
16
16
|
|
|
17
|
+
# The GitHub Actions app. A promotion counts only the checks it reported: a check of the same
|
|
18
|
+
# name posted by another app, a token or a person certifies nothing.
|
|
19
|
+
GITHUB_ACTIONS_APP_ID = 15_368
|
|
20
|
+
|
|
17
21
|
# A commit sha as GitHub accepts it in an API path.
|
|
18
22
|
SHA_PATTERN = /\A[0-9a-fA-F]{7,40}\z/
|
|
19
23
|
|
|
@@ -38,7 +42,8 @@ module Hecks
|
|
|
38
42
|
|
|
39
43
|
# Where each named check stands against a commit, for a caller that must tell a check that is
|
|
40
44
|
# still running from one that failed (`run` raises for both). A name that reported more than
|
|
41
|
-
# once (a re-run) stands as its most recent report.
|
|
45
|
+
# once (a re-run) stands as its most recent report. Only reports of the GitHub Actions app
|
|
46
|
+
# count; a name reported by any other app stands as `:missing`.
|
|
42
47
|
#
|
|
43
48
|
# @param commit [Hash, String] a materialized value object (`{value: sha}`) or a bare sha
|
|
44
49
|
# @param names [Array<String>] the checks to look up
|
|
@@ -46,13 +51,15 @@ module Hecks
|
|
|
46
51
|
# not completed) or `:missing` (not reported at all)
|
|
47
52
|
# @raise [RuntimeError] when the sha is malformed or `gh` is missing or fails
|
|
48
53
|
def states(commit:, names:)
|
|
49
|
-
reported = check_runs(valid_sha(commit)).group_by { |run| run["name"] }
|
|
54
|
+
reported = check_runs(valid_sha(commit)).select { |run| actions?(run) }.group_by { |run| run["name"] }
|
|
50
55
|
latest = reported.transform_values { |runs| runs.max_by { |run| run["id"].to_i } }
|
|
51
56
|
names.to_h { |name| [name, state_of(latest[name])] }
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
private
|
|
55
60
|
|
|
61
|
+
def actions?(run) = run.dig("app", "id") == GITHUB_ACTIONS_APP_ID
|
|
62
|
+
|
|
56
63
|
def valid_sha(commit)
|
|
57
64
|
sha = sha_of(commit)
|
|
58
65
|
raise "not a commit sha: #{sha.inspect}" unless sha.match?(SHA_PATTERN)
|
|
@@ -41,6 +41,7 @@ module Hecks
|
|
|
41
41
|
@guided_generation = dial(:GUIDED_GENERATION, false)
|
|
42
42
|
@corpus_splice_probability = dial(:CORPUS_SPLICE_PROBABILITY, 0.5)
|
|
43
43
|
@favor_rare_verbs = dial(:FAVOR_RARE_VERBS, 3)
|
|
44
|
+
@runtime_coverage_feedback = dial(:RUNTIME_COVERAGE_FEEDBACK, false)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def read_limit_dials
|
|
@@ -57,8 +57,8 @@ module Hecks
|
|
|
57
57
|
# @return [Hash, nil] the seed's result with its surprised checks, or nil when every
|
|
58
58
|
# check held
|
|
59
59
|
def run_seed(seed)
|
|
60
|
-
result =
|
|
61
|
-
@campaign.record(seed, result[:plan], result[:trace]) if @campaign && result[:trace]
|
|
60
|
+
result, runtime = measured_seed(seed)
|
|
61
|
+
@campaign.record(seed, result[:plan], result[:trace], runtime: runtime) if @campaign && result[:trace]
|
|
62
62
|
|
|
63
63
|
surprised = log_seed_checks(result)
|
|
64
64
|
if surprised.empty?
|
|
@@ -70,6 +70,14 @@ module Hecks
|
|
|
70
70
|
result.merge(seed: seed, checks: surprised)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# The seed's result, and the runtime lines and branches it reached when the dial asks for
|
|
74
|
+
# that feedback; nil otherwise, so a campaign falls back to tuples alone.
|
|
75
|
+
def measured_seed(seed)
|
|
76
|
+
return [run_one_seed(@mode, seed), nil] unless @campaign && @runtime_coverage_feedback
|
|
77
|
+
|
|
78
|
+
Hecks::Fuzzing::RuntimeCoverage.measure { run_one_seed(@mode, seed) }
|
|
79
|
+
end
|
|
80
|
+
|
|
73
81
|
def check_modes(checks)
|
|
74
82
|
checks.map { |c| c[:mode] }.join(", ")
|
|
75
83
|
end
|
|
@@ -88,8 +88,8 @@ module Hecks
|
|
|
88
88
|
def normalize_composite_field(aggregate, attribute, fields)
|
|
89
89
|
# A list member hydrates the same as a top-level list, so a value
|
|
90
90
|
# read back from the store matches the shape a live dispatch wrote.
|
|
91
|
-
#
|
|
92
|
-
return
|
|
91
|
+
# An input list member is validated as offered and left as offered.
|
|
92
|
+
return normalize_list(aggregate, attribute, fields) if attribute.list?
|
|
93
93
|
|
|
94
94
|
raw = fields[attribute.name]
|
|
95
95
|
return if raw.nil? || raw.is_a?(self)
|
|
@@ -103,15 +103,46 @@ module Hecks
|
|
|
103
103
|
fields[attribute.name] = nested_fields
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
+
def normalize_list(aggregate, attribute, fields)
|
|
107
|
+
return hydrate_stored_list(aggregate, attribute, fields) if trusting_stored_state?
|
|
108
|
+
|
|
109
|
+
validate_input_list(aggregate, attribute, fields)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Runs each value-object member of an input list through the same door a nested single
|
|
113
|
+
# value goes through, so a member's own invariants and nested lists refuse on dispatch
|
|
114
|
+
# exactly as they do when the list sits directly on a command. The members stay as offered.
|
|
115
|
+
def validate_input_list(aggregate, attribute, fields)
|
|
116
|
+
nested = value_object_for(aggregate, attribute.type)
|
|
117
|
+
return unless nested
|
|
118
|
+
|
|
119
|
+
Array(fields[attribute.name]).each do |member|
|
|
120
|
+
next unless member.is_a?(Hash)
|
|
121
|
+
|
|
122
|
+
member_fields = apply_defaults(nested, fields_for(nested, attribute.name, member))
|
|
123
|
+
validate!(nested, normalize_composite_fields(aggregate, nested, member_fields))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
106
127
|
def hydrate_stored_list(aggregate, attribute, fields)
|
|
107
128
|
return unless trusting_stored_state?
|
|
108
129
|
|
|
109
130
|
fields[attribute.name] = for_attribute(aggregate, attribute, fields[attribute.name])
|
|
110
131
|
end
|
|
111
132
|
|
|
133
|
+
# An optional attribute the caller left out reads as nil in an invariant, so a rule can
|
|
134
|
+
# ask `x.nil?` or `x.set?` of it instead of failing to resolve a key that was never sent.
|
|
135
|
+
def with_absent_optionals(value_object, fields)
|
|
136
|
+
absent = value_object.attributes.select { |attribute| attribute.optional? && !fields.key?(attribute.name) }
|
|
137
|
+
return fields if absent.empty?
|
|
138
|
+
|
|
139
|
+
absent.to_h { |attribute| [attribute.name, nil] }.merge(fields)
|
|
140
|
+
end
|
|
141
|
+
|
|
112
142
|
def check_invariants(value_object, fields)
|
|
143
|
+
known = with_absent_optionals(value_object, fields)
|
|
113
144
|
value_object.invariants.each do |invariant|
|
|
114
|
-
next if Bluebook::Expression::Evaluator.call_rule(invariant,
|
|
145
|
+
next if Bluebook::Expression::Evaluator.call_rule(invariant, known)
|
|
115
146
|
|
|
116
147
|
raise InvariantViolation,
|
|
117
148
|
RefusalWording.render_site("InvariantViolation", "value_object_invariant",
|
|
@@ -12,6 +12,9 @@ module Hecks
|
|
|
12
12
|
# so a built binary is cached per `[rust_dir, feature, sources]`, where `sources` is a digest of
|
|
13
13
|
# the workspace's Rust sources and manifests. Regenerating the domain changes the digest, so the
|
|
14
14
|
# next call rebuilds instead of answering with a stale binary or a stale failure.
|
|
15
|
+
#
|
|
16
|
+
# A binary already pinned and newer than every source is used as it is, so binaries restored
|
|
17
|
+
# from a cache of an earlier run are not built again.
|
|
15
18
|
module NativeBuild
|
|
16
19
|
# Raised when a declared feature fails to build; nil is reserved for "feature not declared".
|
|
17
20
|
class BuildFailed < StandardError; end
|
|
@@ -94,12 +97,37 @@ module Hecks
|
|
|
94
97
|
|
|
95
98
|
File.open(lock_path, File::CREAT | File::RDWR) do |lock|
|
|
96
99
|
lock.flock(File::LOCK_EX)
|
|
100
|
+
pinned = pinned_path(rust_dir, domain_feature)
|
|
101
|
+
next pinned if pinned_fresh?(pinned, rust_dir)
|
|
102
|
+
|
|
97
103
|
command = ["cargo", "build", "--no-default-features", "--features", domain_feature]
|
|
98
104
|
cargo(command, rust_dir, domain_feature)
|
|
99
105
|
pin(rust_dir, domain_feature, command)
|
|
100
106
|
end
|
|
101
107
|
end
|
|
102
108
|
|
|
109
|
+
def pinned_path(rust_dir, domain_feature)
|
|
110
|
+
File.join(rust_dir, "target", "debug", "rust-#{domain_feature}")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A binary pinned ahead of time, by an earlier run whose build was restored from a cache, is
|
|
114
|
+
# current when no source is newer than it: the rule `make` applies to any target.
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean] whether `pinned` exists and every source predates it
|
|
117
|
+
def pinned_fresh?(pinned, rust_dir)
|
|
118
|
+
return false unless File.executable?(pinned)
|
|
119
|
+
|
|
120
|
+
built = File.mtime(pinned)
|
|
121
|
+
source_files(rust_dir).none? { |path| File.mtime(path) > built }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Array<String>] every `.rs` and `.toml` file and `Cargo.lock` outside `target/`
|
|
125
|
+
def source_files(rust_dir)
|
|
126
|
+
paths = Dir.glob(File.join(rust_dir, "**", "*.{rs,toml}"), File::FNM_DOTMATCH)
|
|
127
|
+
paths << File.join(rust_dir, "Cargo.lock")
|
|
128
|
+
paths.select { |path| File.file?(path) && !path.delete_prefix("#{rust_dir}/").start_with?("target/") }
|
|
129
|
+
end
|
|
130
|
+
|
|
103
131
|
def cargo(command, rust_dir, domain_feature)
|
|
104
132
|
_stdout, stderr, status = Open3.capture3(*command, chdir: rust_dir)
|
|
105
133
|
return if status.success?
|
|
@@ -118,7 +146,7 @@ module Hecks
|
|
|
118
146
|
raise BuildFailed, "`#{command.join(" ")}` succeeded in #{rust_dir} but left no executable at #{binary}"
|
|
119
147
|
end
|
|
120
148
|
|
|
121
|
-
pinned =
|
|
149
|
+
pinned = pinned_path(rust_dir, domain_feature)
|
|
122
150
|
FileUtils.cp(binary, pinned)
|
|
123
151
|
File.chmod(0o755, pinned)
|
|
124
152
|
pinned
|
|
@@ -15,10 +15,15 @@ module Hecks
|
|
|
15
15
|
# `wasmtime run rust/dist/<domain>.wasm < script.json`, and the domain's `ir.json` beside it,
|
|
16
16
|
# which `rust/host` reads at runtime. `HECKS_RUST_DIR` names the workspace to build in and
|
|
17
17
|
# `CARGO_TARGET_DIR` where build output goes, beside the workspace by default.
|
|
18
|
+
# `HECKS_WASM_PROFILE=dev` builds without `--release`: a much faster compile, for tests that
|
|
19
|
+
# only run the module, not for one that ships.
|
|
18
20
|
module Wasm
|
|
19
21
|
# The Cargo target the module is built for.
|
|
20
22
|
TARGET = "wasm32-wasip1"
|
|
21
23
|
|
|
24
|
+
# The Cargo flags and the `target/<target>/` directory of each build profile.
|
|
25
|
+
PROFILES = { "release" => { flags: ["--release"], dir: "release" }, "dev" => { flags: [], dir: "debug" } }.freeze
|
|
26
|
+
|
|
22
27
|
module_function
|
|
23
28
|
|
|
24
29
|
# @param argv [Array<String>] the domain's directory
|
|
@@ -83,18 +88,26 @@ module Hecks
|
|
|
83
88
|
# `rustup run`, not bare `cargo`: a `cargo` earlier on PATH may never have seen
|
|
84
89
|
# `rustup target add` and fail with "can't find crate for `std`".
|
|
85
90
|
def compile(scratch)
|
|
86
|
-
|
|
91
|
+
flags = profile.fetch(:flags)
|
|
92
|
+
puts "== cargo build #{flags.join(" ")} --target #{TARGET} =="
|
|
87
93
|
target_dir = ENV.fetch("CARGO_TARGET_DIR", File.join(RustBuild.rust_dir, "target"))
|
|
88
|
-
RustBuild.command!("rustup", "run", "stable", "cargo", "build",
|
|
94
|
+
RustBuild.command!("rustup", "run", "stable", "cargo", "build", *flags, "--target", TARGET,
|
|
89
95
|
chdir: scratch, env: { "CARGO_TARGET_DIR" => target_dir })
|
|
90
96
|
end
|
|
91
97
|
|
|
98
|
+
# @return [Hash] the flags and directory of the profile `HECKS_WASM_PROFILE` names
|
|
99
|
+
# @raise [Failure] when the name is not `release` or `dev`
|
|
100
|
+
def profile
|
|
101
|
+
name = ENV.fetch("HECKS_WASM_PROFILE", "release")
|
|
102
|
+
PROFILES.fetch(name) { raise Failure, "HECKS_WASM_PROFILE=#{name} is not release or dev" }
|
|
103
|
+
end
|
|
104
|
+
|
|
92
105
|
def publish(scratch, rust_dir, name)
|
|
93
106
|
dist = File.join(rust_dir, "dist")
|
|
94
107
|
FileUtils.mkdir_p(dist)
|
|
95
108
|
target_dir = ENV.fetch("CARGO_TARGET_DIR", File.join(rust_dir, "target"))
|
|
96
109
|
out = File.join(dist, "#{name}.wasm")
|
|
97
|
-
FileUtils.cp(File.join(target_dir, TARGET,
|
|
110
|
+
FileUtils.cp(File.join(target_dir, TARGET, profile.fetch(:dir), "rust.wasm"), out)
|
|
98
111
|
puts "wrote #{out}"
|
|
99
112
|
puts %(run it: wasmtime run #{out} < script.json)
|
|
100
113
|
publish_sidecar(scratch, dist, name)
|
|
@@ -107,6 +107,7 @@ Hecks.bluebook "Site" do
|
|
|
107
107
|
attribute :distribution, DistributionId, optional: true
|
|
108
108
|
attribute :expect_new, PathList, optional: true
|
|
109
109
|
attribute :refs, PolicyRefs, optional: true
|
|
110
|
+
attribute :url, SiteUrl, optional: true
|
|
110
111
|
attribute :output, Report, optional: true
|
|
111
112
|
attribute :refusal, Report, optional: true
|
|
112
113
|
identified_by :run
|
|
@@ -155,6 +156,11 @@ Hecks.bluebook "Site" do
|
|
|
155
156
|
attribute :value, String, pattern: '[^\t\n\r]'
|
|
156
157
|
end
|
|
157
158
|
|
|
159
|
+
# A running site's address, `https://example.org`: what `check_site` sends its requests to.
|
|
160
|
+
value_object "SiteUrl" do
|
|
161
|
+
attribute :value, String, pattern: '^https?://[^ \t\n\r]+$'
|
|
162
|
+
end
|
|
163
|
+
|
|
158
164
|
value_object "Report" do
|
|
159
165
|
attribute :value, String
|
|
160
166
|
end
|
|
@@ -224,6 +230,26 @@ Hecks.bluebook "Site" do
|
|
|
224
230
|
emits "LiveCheckRequested"
|
|
225
231
|
end
|
|
226
232
|
|
|
233
|
+
# `hecks site site_projection.check_site <project> url=<https://example.org>`. Asks a running
|
|
234
|
+
# site what its route table says it must answer, with anonymous requests that follow no
|
|
235
|
+
# redirect and change nothing: an admin route refuses, an indexable public page answers 200
|
|
236
|
+
# with a canonical link, an `off` row and an undeclared path answer 404, and a redirect row
|
|
237
|
+
# redirects to its target. Routes that name a parameter or a prefix are left out. It prints a
|
|
238
|
+
# line per check and exits 1 when any answer is wrong.
|
|
239
|
+
command "CheckSite" do
|
|
240
|
+
goal "Ask a running site what its route table says it must answer, and judge each answer"
|
|
241
|
+
|
|
242
|
+
attribute :domain, DomainPath
|
|
243
|
+
attribute :run, RunKey
|
|
244
|
+
attribute :url, SiteUrl
|
|
245
|
+
|
|
246
|
+
sets :domain
|
|
247
|
+
sets :run
|
|
248
|
+
sets :url
|
|
249
|
+
|
|
250
|
+
emits "SiteProbeRequested"
|
|
251
|
+
end
|
|
252
|
+
|
|
227
253
|
command "Complete" do
|
|
228
254
|
goal "Record that routes.ts was written, or found current, or the live edge matched, and what it said"
|
|
229
255
|
|
|
@@ -270,6 +296,11 @@ Hecks.bluebook "Site" do
|
|
|
270
296
|
trigger SiteProjection::SiteToolchain::Compare, with: { run: :run }
|
|
271
297
|
end
|
|
272
298
|
|
|
299
|
+
policy "ProbeWhenRequested" do
|
|
300
|
+
on "SiteProjection.SiteProbeRequested"
|
|
301
|
+
trigger SiteProjection::SiteToolchain::Probe, with: { run: :run }
|
|
302
|
+
end
|
|
303
|
+
|
|
273
304
|
policy "RecordTheProjection" do
|
|
274
305
|
on "SiteProjection.SiteAnswered"
|
|
275
306
|
trigger SiteProjection::Complete, with: { output: :output }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../tools"
|
|
4
|
+
require_relative "targets"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module Tools
|
|
8
|
+
module DeployRecipe
|
|
9
|
+
# Writing the recipe for each `deployed_to` block a world declares: a client may declare
|
|
10
|
+
# several deploy kinds (Vercel beside AwsBox), and each is generated, none locked out.
|
|
11
|
+
module Generation
|
|
12
|
+
# @param deployment [Deployment] what the generator resolved about the domain
|
|
13
|
+
# @param root [String] the checkout
|
|
14
|
+
# @return [String] `--out`, else `deploy/<name>` in the checkout
|
|
15
|
+
def output_dir(deployment, root)
|
|
16
|
+
out = deployment.options.out
|
|
17
|
+
out ? File.expand_path(out) : File.join(root, "deploy", deployment.infra_name)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# One declared target, written straight into `out_dir` as it always was.
|
|
21
|
+
#
|
|
22
|
+
# @param block [Hash, nil] the target's settings; nil takes the world's plain `deployed_to`
|
|
23
|
+
# @return [Integer] 0
|
|
24
|
+
def generate_one(deployment, root, out_dir, block = nil)
|
|
25
|
+
emit(block ? for_target(deployment, block) : deployment, root, out_dir)
|
|
26
|
+
0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Several declared targets, each under its own `out_dir/<adapter>` so their `Makefile`s and
|
|
30
|
+
# scripts do not collide.
|
|
31
|
+
#
|
|
32
|
+
# @return [Integer] 0
|
|
33
|
+
def generate_each(deployment, root, out_dir, blocks)
|
|
34
|
+
blocks.each do |block|
|
|
35
|
+
emit(for_target(deployment, block), root, File.join(out_dir, block[:adapter].downcase))
|
|
36
|
+
end
|
|
37
|
+
0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param view [Deployment] the deployment as one target sees it
|
|
41
|
+
# @return [void] writes the files the target projects into `out_dir`
|
|
42
|
+
def emit(view, root, out_dir)
|
|
43
|
+
write(project(target_key(view.settings, view.world_file, view.domain), view, root), out_dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param block [Hash] one declared `deployed_to` block
|
|
47
|
+
# @return [Deployment] the deployment as that target sees it: its settings, name and world
|
|
48
|
+
def for_target(deployment, block)
|
|
49
|
+
settings = tenant_settings(block, deployment.options, File.basename(deployment.domain))
|
|
50
|
+
deployment.dup.tap do |view|
|
|
51
|
+
view.world = Targets::TargetWorld.new(deployment.world, block)
|
|
52
|
+
view.settings = settings
|
|
53
|
+
view.infra_name = settings[:stack_name] || File.basename(deployment.domain)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "delegate"
|
|
3
4
|
require_relative "../../tools"
|
|
4
5
|
|
|
5
6
|
module Hecks
|
|
@@ -11,7 +12,7 @@ module Hecks
|
|
|
11
12
|
# The refusal for a world with no deploy target, where `__WORLD_FILE__` and `__DOMAIN__`
|
|
12
13
|
# stand for the world file and the domain directory.
|
|
13
14
|
NO_TARGET = <<~MSG
|
|
14
|
-
__WORLD_FILE__ declares no deployed_to("AwsLambda"), deployed_to("AwsFargate") or deployed_to("
|
|
15
|
+
__WORLD_FILE__ declares no deployed_to("AwsLambda"), deployed_to("AwsFargate"), deployed_to("AwsBox"), deployed_to("AwsSharedDatabase") or deployed_to("Vercel") block. Add one, e.g.:
|
|
15
16
|
|
|
16
17
|
deployed_to("AwsLambda") do
|
|
17
18
|
region "us-east-1"
|
|
@@ -35,22 +36,75 @@ module Hecks
|
|
|
35
36
|
containers [{ name: "web", port: 8080 }]
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
or, for the RDS instance several sites share:
|
|
40
|
+
|
|
41
|
+
deployed_to("AwsSharedDatabase") do
|
|
42
|
+
region "us-east-1"
|
|
43
|
+
stack_name "hecks-platform-rds"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
or:
|
|
47
|
+
|
|
48
|
+
deployed_to("Vercel") do
|
|
49
|
+
region "iad1"
|
|
50
|
+
env ["SESSION_SECRET"]
|
|
51
|
+
end
|
|
52
|
+
|
|
38
53
|
then re-run hecks deploy project __DOMAIN__.
|
|
39
54
|
MSG
|
|
40
55
|
|
|
56
|
+
# A world view whose `deployed_to` answers for one declared target, so a projector that
|
|
57
|
+
# reads `world.for_verb("deployed_to")` sees that target's block and no other.
|
|
58
|
+
class TargetWorld < SimpleDelegator
|
|
59
|
+
# @param world [Object] the world the blocks were declared in
|
|
60
|
+
# @param settings [Hash{Symbol => Object}] the one `deployed_to` block this view uses
|
|
61
|
+
def initialize(world, settings)
|
|
62
|
+
super(world)
|
|
63
|
+
@settings = settings
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Hash{Symbol => Object}] this target's block for `deployed_to`, else the world's
|
|
67
|
+
def for_verb(verb) = verb.to_s == "deployed_to" ? @settings : super
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every `deployed_to` block the world declares, in declaration order. The world records
|
|
71
|
+
# each one as `deployed_to:<adapter>`; the plain `deployed_to` entry is only the last.
|
|
72
|
+
#
|
|
73
|
+
# @param world [Object] the loaded world
|
|
74
|
+
# @return [Array<Hash{Symbol => Object}>] one settings hash per target, with its `:adapter`
|
|
75
|
+
def declared_targets(world)
|
|
76
|
+
world.settings.filter_map { |key, block| block if key.to_s.start_with?("deployed_to:") }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param blocks [Array<Hash>] the declared targets
|
|
80
|
+
# @param wanted [String, nil] the `--target` flag
|
|
81
|
+
# @return [Array<Hash>] the targets to generate: all, or the one named
|
|
82
|
+
# @raise [SystemExit] when the world declares no such target
|
|
83
|
+
def chosen_targets(blocks, wanted)
|
|
84
|
+
return blocks unless wanted
|
|
85
|
+
|
|
86
|
+
found = blocks.select { |block| block[:adapter].casecmp?(wanted) }
|
|
87
|
+
return found unless found.empty?
|
|
88
|
+
|
|
89
|
+
declared = blocks.map { |block| block[:adapter] }.join(", ")
|
|
90
|
+
abort "no deployed_to(#{wanted.inspect}) is declared; declared: #{declared}"
|
|
91
|
+
end
|
|
92
|
+
|
|
41
93
|
# Each adapter maps to a registered `Projector` export (`needs_world: true`); this only
|
|
42
94
|
# finds which one to call.
|
|
43
95
|
#
|
|
44
96
|
# @param deploy_settings [Hash] the world's `deployed_to` settings
|
|
45
97
|
# @param world_file [String] the `.world` file, named in the refusal
|
|
46
98
|
# @param domain [String] the domain directory, named in the refusal
|
|
47
|
-
# @return [Symbol]
|
|
99
|
+
# @return [Symbol] the key of the registered projection, such as `:aws_box` or `:vercel`
|
|
48
100
|
# @raise [SystemExit] with an example block when the world declares none of them
|
|
49
101
|
def target_key(deploy_settings, world_file, domain)
|
|
50
102
|
case deploy_settings[:adapter]
|
|
51
103
|
when "AwsLambda" then :aws_lambda
|
|
52
104
|
when "AwsFargate" then :aws_fargate
|
|
53
105
|
when "AwsBox" then :aws_box
|
|
106
|
+
when "AwsSharedDatabase" then :aws_shared_database
|
|
107
|
+
when "Vercel" then :vercel
|
|
54
108
|
else
|
|
55
109
|
abort NO_TARGET.gsub("__WORLD_FILE__") { world_file }.gsub("__DOMAIN__") { domain }
|
|
56
110
|
end
|
|
@@ -21,7 +21,8 @@ module Hecks
|
|
|
21
21
|
|
|
22
22
|
File.exist?(world_file) or
|
|
23
23
|
abort "#{world_file} does not exist — a domain needs a .world file to declare " \
|
|
24
|
-
"deployed_to(\"AwsLambda\"), deployed_to(\"AwsFargate\")
|
|
24
|
+
"deployed_to(\"AwsLambda\"), deployed_to(\"AwsFargate\"), deployed_to(\"AwsBox\"), " \
|
|
25
|
+
"deployed_to(\"AwsSharedDatabase\") or deployed_to(\"Vercel\")"
|
|
25
26
|
world_file
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -6,6 +6,7 @@ require "optparse"
|
|
|
6
6
|
require_relative "../tools"
|
|
7
7
|
require_relative "deploy_recipe/worlds"
|
|
8
8
|
require_relative "deploy_recipe/targets"
|
|
9
|
+
require_relative "deploy_recipe/generation"
|
|
9
10
|
|
|
10
11
|
module Hecks
|
|
11
12
|
module Tools
|
|
@@ -14,7 +15,7 @@ module Hecks
|
|
|
14
15
|
# scripts and Makefile) under `deploy/<stack>/` of the checkout.
|
|
15
16
|
#
|
|
16
17
|
# hecks deploy recipe.project <domain> [--tenant=<slug>] [--schema=<name>]
|
|
17
|
-
# [--out=<dir>] [--environment=<name>]
|
|
18
|
+
# [--out=<dir>] [--environment=<name>] [--target=<adapter>]
|
|
18
19
|
#
|
|
19
20
|
# `--environment` layers an overlay `.world` file over the base one; a missing overlay is an
|
|
20
21
|
# error. `--out` writes the recipe elsewhere, for a domain in a client's repo. `--tenant` and
|
|
@@ -22,13 +23,13 @@ module Hecks
|
|
|
22
23
|
# re-running with the same `--tenant` regenerates the same stack.
|
|
23
24
|
module DeployRecipe
|
|
24
25
|
USAGE = "usage: hecks deploy project <domain> [--tenant=<slug>] [--schema=<name>] " \
|
|
25
|
-
"[--out=<dir>] [--environment=<name>]"
|
|
26
|
+
"[--out=<dir>] [--environment=<name>] [--target=<adapter>]"
|
|
26
27
|
|
|
27
28
|
# The directory `ports/` and `adapters/` live under, loaded into every domain's registry.
|
|
28
29
|
LIB_HECKS = File.expand_path("..", __dir__)
|
|
29
30
|
|
|
30
31
|
# What the flags asked for.
|
|
31
|
-
Options = Struct.new(:tenant, :out, :environment)
|
|
32
|
+
Options = Struct.new(:tenant, :out, :environment, :target)
|
|
32
33
|
|
|
33
34
|
# What the generator resolved about a domain: its directory, the flags, the `.world` file and
|
|
34
35
|
# its declaration, the `deployed_to` settings, the stack's name, the registry and the chapter.
|
|
@@ -36,6 +37,7 @@ module Hecks
|
|
|
36
37
|
|
|
37
38
|
extend Worlds
|
|
38
39
|
extend Targets
|
|
40
|
+
extend Generation
|
|
39
41
|
|
|
40
42
|
module_function
|
|
41
43
|
|
|
@@ -53,17 +55,19 @@ module Hecks
|
|
|
53
55
|
domain = argv.shift or abort USAGE
|
|
54
56
|
|
|
55
57
|
deployment = resolve(domain, options)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
blocks = chosen_targets(declared_targets(deployment.world), options.target)
|
|
59
|
+
out_dir = output_dir(deployment, root)
|
|
60
|
+
# No block at all falls through to the refusal that shows an example.
|
|
61
|
+
return generate_each(deployment, root, out_dir, blocks) if blocks.size > 1
|
|
62
|
+
|
|
63
|
+
generate_one(deployment, root, out_dir, blocks.first)
|
|
60
64
|
end
|
|
61
65
|
|
|
62
66
|
# @param argv [Array<String>] the arguments; the flags are removed from it
|
|
63
67
|
# @return [Options] the flags, with `tenant` a hash of `tenant` and `schema`
|
|
64
68
|
# @raise [SystemExit] when `--schema` has no `--tenant`
|
|
65
69
|
def parse(argv)
|
|
66
|
-
options = Options.new({}, nil, nil)
|
|
70
|
+
options = Options.new({}, nil, nil, nil)
|
|
67
71
|
option_parser(options).parse!(argv)
|
|
68
72
|
|
|
69
73
|
if options.tenant[:schema] && !options.tenant[:tenant]
|
|
@@ -77,11 +81,17 @@ module Hecks
|
|
|
77
81
|
OptionParser.new do |parser|
|
|
78
82
|
parser.on("--tenant=SLUG") { |v| options.tenant[:tenant] = v }
|
|
79
83
|
parser.on("--schema=NAME") { |v| options.tenant[:schema] = v }
|
|
80
|
-
parser
|
|
81
|
-
parser.on("--environment=NAME") { |v| options.environment = v }
|
|
84
|
+
where_flags(parser, options)
|
|
82
85
|
end
|
|
83
86
|
end
|
|
84
87
|
|
|
88
|
+
# @return [void] the flags that say where to write and which target to write
|
|
89
|
+
def where_flags(parser, options)
|
|
90
|
+
parser.on("--out=DIR") { |v| options.out = v }
|
|
91
|
+
parser.on("--environment=NAME") { |v| options.environment = v }
|
|
92
|
+
parser.on("--target=ADAPTER") { |v| options.target = v }
|
|
93
|
+
end
|
|
94
|
+
|
|
85
95
|
# @param domain [String] the domain directory
|
|
86
96
|
# @param options [Options] the flags
|
|
87
97
|
# @return [Deployment] the world, settings, registry and chapter the recipe is projected from
|