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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  3. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  5. data/lib/hecks/cache_dir.rb +10 -0
  6. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  7. data/lib/hecks/deploy/oidc.json +10 -0
  8. data/lib/hecks/doors/usage_cache.rb +19 -9
  9. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  10. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  11. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  12. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  13. data/lib/hecks/fuzzing/form_census.rb +28 -52
  14. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  15. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  16. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  17. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  18. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  19. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  20. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  21. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  22. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  23. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  24. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  25. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  26. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  27. data/lib/hecks/fuzzing/mutation.rb +12 -0
  28. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  29. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  30. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  31. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  32. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  33. data/lib/hecks/fuzzing/properties.rb +7 -0
  34. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  35. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  36. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  37. data/lib/hecks/fuzzing/replay.rb +1 -0
  38. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  39. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  40. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  41. data/lib/hecks/fuzzing.rb +3 -0
  42. data/lib/hecks/gate/stages.yml +1 -1
  43. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  44. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  45. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  46. data/lib/hecks/hecks/custodian.behaviors +6 -0
  47. data/lib/hecks/hecks/custodian.bluebook +37 -0
  48. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +3 -0
  50. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  51. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  52. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  53. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  54. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  55. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  56. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  57. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  58. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  59. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  60. data/lib/hecks/projections/deploy/box.rb +14 -2
  61. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  62. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  63. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  64. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  65. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  66. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  67. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  68. data/lib/hecks/projections.rb +2 -0
  69. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  70. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  71. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  72. data/lib/hecks/rust_build/native_build.rb +29 -1
  73. data/lib/hecks/rust_build/wasm.rb +16 -3
  74. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  75. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  76. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  77. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  78. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  79. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  80. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  81. data/lib/hecks/tools/lanes/files.rb +2 -4
  82. data/lib/hecks/tools/lanes/live.rb +3 -4
  83. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  84. data/lib/hecks/tools/lanes.rb +12 -1
  85. data/lib/hecks/tools/mutation_run.rb +73 -0
  86. data/lib/hecks/tools.rb +1 -0
  87. data/lib/hecks/version.rb +1 -1
  88. data/lib/hecks/vocabulary.rb +2 -0
  89. data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
  90. data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
  91. data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
  92. data/qa/settings.yml +1 -0
  93. data/rust/host/Cargo.toml +2 -2
  94. data/rust/host/HECKS_RELEASE +1 -1
  95. data/rust/host/src/api.rs +28 -5
  96. data/rust/host/src/auth.rs +11 -1
  97. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  98. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  99. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  100. data/rust/host/src/boundary_fuzz/ir.rs +78 -0
  101. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  102. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  103. data/rust/host/src/fuzz_support.rs +165 -0
  104. data/rust/host/src/ir.rs +4 -0
  105. data/rust/host/src/main.rs +9 -4
  106. data/rust/host/src/secrets.rs +44 -0
  107. data/rust/host/src/server.rs +6 -1
  108. data/rust/host/src/web.rs +12 -2
  109. data/rust/parser/fuzz/.gitignore +4 -0
  110. data/rust/parser/fuzz/Cargo.lock +89 -0
  111. data/rust/parser/fuzz/Cargo.toml +27 -0
  112. data/rust/parser/fuzz/README.md +19 -0
  113. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  114. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  115. data/rust/parser/src/parse/command.rs +14 -2
  116. data/rust/parser/src/parse/mod.rs +25 -0
  117. data/rust/parser/src/ruby_value.rs +95 -6
  118. data/rust/src/kernel/json.rs +34 -3
  119. 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 secretField(arn, field) {
74
+ async function secretJson(arn) {
75
75
  const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: arn }));
76
- const value = JSON.parse(SecretString)[field];
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
- const password = await secretField(process.env.DB_SECRET_ARN, "password");
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 = `postgres://postgres:${encodeURIComponent(password)}@${DB_HOST}:5432/${DB_NAME}?${ssl}`;
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
  }
@@ -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 = run_one_seed(@mode, seed)
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 = File.join(rust_dir, "target", "debug", "rust-#{domain_feature}")
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
- puts "== cargo build --release --target #{TARGET} =="
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", "--release", "--target", TARGET,
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, "release", "rust.wasm"), out)
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