hecks 3.6.0 → 3.7.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/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/cache_dir.rb +10 -0
- 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 +1 -1
- 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/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 +3 -0
- 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/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/rust_build/native_build.rb +29 -1
- data/lib/hecks/rust_build/wasm.rb +16 -3
- 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 +2 -0
- data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
- data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
- data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
- data/qa/settings.yml +1 -0
- data/rust/host/Cargo.toml +2 -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 +78 -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/fuzz_support.rs +165 -0
- data/rust/host/src/ir.rs +4 -0
- data/rust/host/src/main.rs +9 -4
- data/rust/host/src/secrets.rs +44 -0
- data/rust/host/src/server.rs +6 -1
- data/rust/host/src/web.rs +12 -2
- 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 +50 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../../projector"
|
|
3
|
+
require_relative "box/settings"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Deploy
|
|
8
|
+
# The shared database target for `deployed_to("AwsSharedDatabase")`: one RDS Postgres instance
|
|
9
|
+
# that several sites each keep a database on (ADR 0092). It renders the instance's stack
|
|
10
|
+
# (`rds.yaml`), a `Makefile` that deploys it, and a README. A site's own world says
|
|
11
|
+
# `shared_database "<this stack>"`, and its box generates the provisioning of its database.
|
|
12
|
+
#
|
|
13
|
+
# The stack's name is the world's `stack_name` exactly, with no prefix or suffix, because
|
|
14
|
+
# every site names it by that string.
|
|
15
|
+
module SharedDatabaseInstance
|
|
16
|
+
extend Projector::Target
|
|
17
|
+
|
|
18
|
+
projects_as :aws_shared_database, needs_world: true, emits: :files
|
|
19
|
+
|
|
20
|
+
TEMPLATE_DIR = File.join(__dir__, "shared_database_instance", "templates").freeze
|
|
21
|
+
|
|
22
|
+
# What the world leaves out resolves to these.
|
|
23
|
+
DEFAULTS = { database_class: "db.t4g.small", storage_gb: 30, engine_version: "16", backup_days: 7 }.freeze
|
|
24
|
+
|
|
25
|
+
# The checked settings the templates are filled from.
|
|
26
|
+
Plan = Struct.new(:stack, :database_class, :storage_gb, :engine_version, :backup_days, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
|
|
31
|
+
# @param options [Hash] generation options; same shape as `Box.call`'s
|
|
32
|
+
# @return [Hash{String => String}] the generated file contents, keyed by filename
|
|
33
|
+
# @raise [ArgumentError] if the domain's deploy settings are invalid
|
|
34
|
+
def call(bluebook:, options: {})
|
|
35
|
+
world, _domain, world_file = options.values_at(:world, :domain_dir, :world_file)
|
|
36
|
+
settings = world.for_verb("deployed_to")
|
|
37
|
+
target = declare(world.domain, settings, world_file)
|
|
38
|
+
render_all(resolve(settings, target))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Validates the target through the Deploy bluebook's own `SharedDatabaseTarget.Declare`.
|
|
42
|
+
#
|
|
43
|
+
# @return [Object] the declared target aggregate
|
|
44
|
+
# @raise [ArgumentError] when the bluebook refuses a value
|
|
45
|
+
def declare(domain, settings, world_file)
|
|
46
|
+
dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
|
|
47
|
+
dispatcher.dispatch("Deploy::SharedDatabaseTarget.Declare", with: declare_arguments(domain, settings)).instance
|
|
48
|
+
rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
|
|
49
|
+
raise ArgumentError, "#{world_file}'s deployed_to(\"AwsSharedDatabase\") is invalid: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Hash{Symbol => Hash}] the `Declare` arguments, each `{ value: }`
|
|
53
|
+
def declare_arguments(domain, settings)
|
|
54
|
+
{
|
|
55
|
+
domain: { value: domain }, region: { value: settings[:region] },
|
|
56
|
+
database_class: { value: settings.fetch(:database_class, DEFAULTS[:database_class]) },
|
|
57
|
+
storage_gb: { value: settings.fetch(:storage_gb, DEFAULTS[:storage_gb]) }
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param settings [Hash{Symbol => Object}] the world's `AwsSharedDatabase` settings
|
|
62
|
+
# @param target [Object] the declared target, whose class and storage `Declare` has checked
|
|
63
|
+
# @return [Plan] the checked settings
|
|
64
|
+
# @raise [ArgumentError] when the stack name is missing or a setting is malformed
|
|
65
|
+
def resolve(settings, target)
|
|
66
|
+
Plan.new(stack: read_stack(settings), database_class: target.state[:database_class].value,
|
|
67
|
+
storage_gb: target.state[:storage_gb].value, engine_version: read_engine(settings),
|
|
68
|
+
backup_days: Box::Settings.integer(:backup_days, settings.fetch(:backup_days, DEFAULTS[:backup_days]), 1, 35))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String] the stack's name, which every site's `shared_database` repeats
|
|
72
|
+
def read_stack(settings)
|
|
73
|
+
stack = settings.fetch(:stack_name) do
|
|
74
|
+
raise ArgumentError, "stack_name: the shared instance's stack, which every site names in shared_database"
|
|
75
|
+
end
|
|
76
|
+
Box::Settings.check(:stack_name, stack, Box::Settings::NAME)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [String] the Postgres major (or major.minor) version
|
|
80
|
+
def read_engine(settings)
|
|
81
|
+
Box::Settings.check(:engine_version, settings.fetch(:engine_version, DEFAULTS[:engine_version]).to_s,
|
|
82
|
+
Box::Settings::ENGINE)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param plan [Plan] the checked settings
|
|
86
|
+
# @return [Hash{String => String}] every generated file
|
|
87
|
+
def render_all(plan)
|
|
88
|
+
values = { "STACK" => plan.stack, "DB_CLASS" => plan.database_class, "DATABASE_CLASS" => plan.database_class,
|
|
89
|
+
"STORAGE_GB" => plan.storage_gb.to_s, "ENGINE_VERSION" => plan.engine_version,
|
|
90
|
+
"BACKUP_DAYS" => plan.backup_days.to_s }
|
|
91
|
+
{ "rds.yaml" => template("rds.yaml.tmpl", values), "Makefile" => template("Makefile.tmpl", values),
|
|
92
|
+
"README.md" => template("README.md.tmpl", values) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Fills `@@NAME@@` markers.
|
|
96
|
+
#
|
|
97
|
+
# @param file [String] the template's name in `TEMPLATE_DIR`
|
|
98
|
+
# @param values [Hash{String => String}] marker name => text
|
|
99
|
+
# @return [String] the rendered file
|
|
100
|
+
def template(file, values)
|
|
101
|
+
values.reduce(File.read(File.join(TEMPLATE_DIR, file))) { |text, (marker, value)| text.gsub("@@#{marker}@@", value) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Projections
|
|
3
|
+
module Deploy
|
|
4
|
+
module Vercel
|
|
5
|
+
# Resolves a world's `deployed_to("Vercel")` settings into the checked plan the generator
|
|
6
|
+
# renders from.
|
|
7
|
+
#
|
|
8
|
+
# Every string that reaches `vercel.json` or the deploy script is matched against a
|
|
9
|
+
# conservative pattern first, so a world file cannot splice shell or JSON into the output.
|
|
10
|
+
# Secrets are named here, never held: `env` lists variable names, and the deploy script
|
|
11
|
+
# reads each value from the caller's environment.
|
|
12
|
+
module Settings
|
|
13
|
+
NAME = /\A[a-z0-9][a-z0-9-]{0,99}\z/
|
|
14
|
+
SCOPE = /\A[a-z0-9][a-z0-9_-]{0,99}\z/
|
|
15
|
+
ENV_VAR = /\A[A-Z][A-Z0-9_]{0,99}\z/
|
|
16
|
+
CRON_PATH = %r{\A/[A-Za-z0-9/_.-]{0,200}\z}
|
|
17
|
+
SCHEDULE = %r{\A[0-9*/,-]+( [0-9A-Za-z*/,-]+){4}\z}
|
|
18
|
+
|
|
19
|
+
# The variable the hecksagon's Postgres adapter reads; always set on deploy.
|
|
20
|
+
DATABASE_ENV = "DATABASE_URL".freeze
|
|
21
|
+
|
|
22
|
+
Plan = Data.define(:project, :scope, :region, :memory, :max_duration, :crons, :env)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param deploy_settings [Hash{Symbol => Object}] the world's `Vercel` settings
|
|
27
|
+
# @param target [Object] the validated `VercelTarget` (region, memory and duration,
|
|
28
|
+
# already checked by `Declare`)
|
|
29
|
+
# @param infra_name [String] the project name the world or the domain gives
|
|
30
|
+
# @return [Plan] the resolved plan
|
|
31
|
+
# @raise [ArgumentError] when a setting is malformed
|
|
32
|
+
def resolve(deploy_settings:, target:, infra_name:)
|
|
33
|
+
s = deploy_settings
|
|
34
|
+
Plan.new(project: check(:project, infra_name, NAME), scope: optional(:scope, s[:scope], SCOPE),
|
|
35
|
+
**declared_sizes(target), crons: read_crons(s.fetch(:crons, [])),
|
|
36
|
+
env: read_env(s.fetch(:env, [])))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param target [Object] the declared `VercelTarget`
|
|
40
|
+
# @return [Hash{Symbol => Object}] the values `Declare` has already validated
|
|
41
|
+
def declared_sizes(target)
|
|
42
|
+
%i[region memory max_duration].to_h { |key| [key, target.state[key].value] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String] the value, when it matches
|
|
46
|
+
# @raise [ArgumentError] when it does not
|
|
47
|
+
def check(key, value, pattern)
|
|
48
|
+
return value.to_s if value.to_s.match?(pattern)
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "#{key} #{value.inspect} is not allowed here (it must match #{pattern.source})"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [String, nil] the value, or nil when the world left it out
|
|
54
|
+
def optional(key, value, pattern)
|
|
55
|
+
value.nil? ? nil : check(key, value, pattern)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param list [Array<Hash>] `[{ path: "/cron/tick", schedule: "0 * * * *" }]`
|
|
59
|
+
# @return [Array<Hash{Symbol => String}>] the checked crons
|
|
60
|
+
def read_crons(list)
|
|
61
|
+
raise ArgumentError, "crons must be a list of { path:, schedule: }" unless list.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
list.map do |cron|
|
|
64
|
+
unless cron.is_a?(Hash) && cron.key?(:path) && cron.key?(:schedule)
|
|
65
|
+
raise ArgumentError, "a cron needs a path and a schedule, got #{cron.inspect}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
{ path: check(:cron_path, cron[:path], CRON_PATH), schedule: check(:cron_schedule, cron[:schedule], SCHEDULE) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param list [Array<String>] environment variable names the deploy sets on Vercel
|
|
73
|
+
# @return [Array<String>] the names, `DATABASE_URL` first, each once
|
|
74
|
+
def read_env(list)
|
|
75
|
+
raise ArgumentError, "env must be a list of variable names" unless list.is_a?(Array)
|
|
76
|
+
|
|
77
|
+
([DATABASE_ENV] + list.map { |name| check(:env, name, ENV_VAR) }).uniq
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../../projector"
|
|
3
|
+
require_relative "vercel/settings"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Projections
|
|
7
|
+
module Deploy
|
|
8
|
+
# The Vercel deploy target for `deployed_to("Vercel")`: the domain's Rust host as one Vercel
|
|
9
|
+
# function. It renders `vercel.json` (the function's size, region, the rewrite that sends
|
|
10
|
+
# every path to it, crons), `.vercelignore`, `deploy-vercel.sh` and a `Makefile`.
|
|
11
|
+
#
|
|
12
|
+
# Persistence is the hecksagon's: the host reads `DATABASE_URL`, which the deploy script sets
|
|
13
|
+
# from the caller's environment. This projection creates no database and holds no secret.
|
|
14
|
+
module Vercel
|
|
15
|
+
extend Projector::Target
|
|
16
|
+
|
|
17
|
+
projects_as :vercel, needs_world: true, emits: :files
|
|
18
|
+
|
|
19
|
+
# The function every request is rewritten to; its file is the host's Vercel entry point.
|
|
20
|
+
FUNCTION = "api/host".freeze
|
|
21
|
+
|
|
22
|
+
# Keeps only the release binaries in an uploaded `target/`, as Vercel's Rust guide advises.
|
|
23
|
+
VERCELIGNORE = <<~IGNORE.freeze
|
|
24
|
+
target/**
|
|
25
|
+
!target/release
|
|
26
|
+
!target/x86_64-unknown-linux-gnu/release/**
|
|
27
|
+
!target/aarch64-unknown-linux-gnu/release/**
|
|
28
|
+
IGNORE
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# @param bluebook [Bluebook::Behaviour::Chapter] the domain's own booted chapter
|
|
33
|
+
# @param options [Hash] generation options; same shape as `Box.call`'s
|
|
34
|
+
# @return [Hash{String => String}] the generated file contents, keyed by filename
|
|
35
|
+
# @raise [ArgumentError] if the domain's deploy settings are invalid
|
|
36
|
+
def call(bluebook:, options: {})
|
|
37
|
+
world, domain, world_file = options.values_at(:world, :domain_dir, :world_file)
|
|
38
|
+
settings = tenant_settings(world.for_verb("deployed_to"), options[:tenant] || {}, File.basename(domain))
|
|
39
|
+
target = declare(world.domain, settings, world_file)
|
|
40
|
+
infra_name = (settings[:stack_name] || File.basename(domain)).to_s.tr("_", "-").downcase
|
|
41
|
+
plan = Settings.resolve(deploy_settings: settings, target: target, infra_name: infra_name)
|
|
42
|
+
render_all(plan)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Hash{Symbol => Object}] the settings with the `--tenant` suffix on the stack name
|
|
46
|
+
def tenant_settings(settings, tenant, domain_name)
|
|
47
|
+
return settings unless tenant[:tenant]
|
|
48
|
+
|
|
49
|
+
settings.merge(stack_name: "#{settings[:stack_name] || domain_name}-#{tenant[:tenant]}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Validates the target through the Deploy bluebook's own `VercelTarget.Declare`.
|
|
53
|
+
#
|
|
54
|
+
# @return [Object] the declared target aggregate
|
|
55
|
+
# @raise [ArgumentError] when the bluebook refuses a value
|
|
56
|
+
def declare(domain, settings, world_file)
|
|
57
|
+
dispatcher = Hecks.boot(File.expand_path("../../deploy", __dir__))
|
|
58
|
+
dispatcher.dispatch("Deploy::VercelTarget.Declare", with: declare_arguments(domain, settings)).instance
|
|
59
|
+
rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
|
|
60
|
+
raise ArgumentError, "#{world_file}'s deployed_to(\"Vercel\") is invalid: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @return [Hash{Symbol => Hash}] the `VercelTarget.Declare` arguments, each a `{ value: }`
|
|
64
|
+
def declare_arguments(domain, settings)
|
|
65
|
+
{
|
|
66
|
+
domain: { value: domain }, region: { value: settings.fetch(:region, "iad1") },
|
|
67
|
+
memory: { value: settings.fetch(:memory, 1024) },
|
|
68
|
+
max_duration: { value: settings.fetch(:max_duration, 30) }
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param plan [Settings::Plan] the resolved settings
|
|
73
|
+
# @return [Hash{String => String}] every generated file
|
|
74
|
+
def render_all(plan)
|
|
75
|
+
{ "vercel.json" => vercel_json(plan), ".vercelignore" => VERCELIGNORE,
|
|
76
|
+
"deploy-vercel.sh" => deploy_script(plan), "Makefile" => makefile(plan) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @param plan [Settings::Plan] the resolved settings
|
|
80
|
+
# @return [String] `vercel.json`
|
|
81
|
+
def vercel_json(plan)
|
|
82
|
+
config = {
|
|
83
|
+
"$schema" => "https://openapi.vercel.sh/vercel.json",
|
|
84
|
+
"functions" => { "#{FUNCTION}.rs" => { "memory" => plan.memory, "maxDuration" => plan.max_duration } },
|
|
85
|
+
"regions" => [plan.region],
|
|
86
|
+
"rewrites" => [{ "source" => "/(.*)", "destination" => "/#{FUNCTION}" }]
|
|
87
|
+
}
|
|
88
|
+
config["crons"] = plan.crons.map { |cron| cron.transform_keys(&:to_s) } if plan.crons.any?
|
|
89
|
+
"#{JSON.pretty_generate(config)}\n"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The script sets each named variable on the project from the caller's environment over
|
|
93
|
+
# stdin, so no value is on a command line or in a file, then deploys to production.
|
|
94
|
+
#
|
|
95
|
+
# @param plan [Settings::Plan] the resolved settings
|
|
96
|
+
# @return [String] `deploy-vercel.sh`
|
|
97
|
+
def deploy_script(plan)
|
|
98
|
+
scope = plan.scope ? " --scope #{plan.scope}" : ""
|
|
99
|
+
sets = plan.env.map { |name| env_command(name, scope) }
|
|
100
|
+
<<~SH
|
|
101
|
+
#!/bin/bash
|
|
102
|
+
# Deploys #{plan.project} to Vercel production. Needs VERCEL_TOKEN and each variable below in the
|
|
103
|
+
# environment, e.g. `op run --env-file=.env.tpl -- make deploy`; no value is written to disk.
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
cd "$(dirname "$0")"
|
|
106
|
+
vercel link --yes --project #{plan.project}#{scope}
|
|
107
|
+
#{sets.join("\n")}
|
|
108
|
+
vercel deploy --prod --yes#{scope}
|
|
109
|
+
SH
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param name [String] a checked variable name
|
|
113
|
+
# @param scope [String] the ` --scope team` flag, or empty
|
|
114
|
+
# @return [String] the line that reads the variable and sets it on the project
|
|
115
|
+
def env_command(name, scope)
|
|
116
|
+
"printf '%s' \"${#{name}:?#{name} must be set in the environment}\" | " \
|
|
117
|
+
"vercel env add #{name} production --sensitive --force --yes#{scope}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @param plan [Settings::Plan] the resolved settings
|
|
121
|
+
# @return [String] the `Makefile`
|
|
122
|
+
def makefile(plan)
|
|
123
|
+
<<~MAKE
|
|
124
|
+
# #{plan.project}: one Vercel function.
|
|
125
|
+
# make deploy (needs VERCEL_TOKEN and #{plan.env.join(", ")} in the environment)
|
|
126
|
+
.PHONY: deploy
|
|
127
|
+
deploy:
|
|
128
|
+
\t./deploy-vercel.sh
|
|
129
|
+
MAKE
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|