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.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  5. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  6. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  7. data/lib/hecks/cache_dir.rb +10 -0
  8. data/lib/hecks/corpus/routes.rb +1 -1
  9. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  10. data/lib/hecks/deploy/oidc.json +10 -0
  11. data/lib/hecks/doors/usage_cache.rb +19 -9
  12. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  13. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  14. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  15. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  16. data/lib/hecks/fuzzing/form_census.rb +28 -52
  17. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  18. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  19. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  20. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  21. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  22. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  23. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  24. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  25. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  26. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  27. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  28. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  29. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  30. data/lib/hecks/fuzzing/mutation.rb +12 -0
  31. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  32. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  33. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  34. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  35. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  36. data/lib/hecks/fuzzing/properties.rb +7 -0
  37. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  38. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  39. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  40. data/lib/hecks/fuzzing/replay.rb +1 -0
  41. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  42. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  43. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  44. data/lib/hecks/fuzzing.rb +3 -0
  45. data/lib/hecks/gate/stages.yml +12 -2
  46. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  47. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  48. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  49. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  50. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  51. data/lib/hecks/hecks/custodian.behaviors +6 -0
  52. data/lib/hecks/hecks/custodian.bluebook +37 -0
  53. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  54. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
  55. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  56. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  57. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  58. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  59. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  60. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  61. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  62. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  63. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  64. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  65. data/lib/hecks/projections/deploy/box.rb +14 -2
  66. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  67. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  68. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  69. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  70. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  71. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  72. data/lib/hecks/projections/site/probe.rb +131 -0
  73. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  74. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  75. data/lib/hecks/projections.rb +2 -0
  76. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  77. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  78. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  79. data/lib/hecks/runtime/value/validation.rb +34 -3
  80. data/lib/hecks/rust_build/native_build.rb +29 -1
  81. data/lib/hecks/rust_build/wasm.rb +16 -3
  82. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  83. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  84. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  85. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  86. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  87. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  88. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  89. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  90. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  91. data/lib/hecks/tools/lanes/files.rb +2 -4
  92. data/lib/hecks/tools/lanes/live.rb +3 -4
  93. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  94. data/lib/hecks/tools/lanes.rb +12 -1
  95. data/lib/hecks/tools/mutation_run.rb +73 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +3 -1
  99. data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
  100. data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
  101. data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
  102. data/qa/settings.yml +1 -0
  103. data/rust/codegen/src/fielded.rs +3 -3
  104. data/rust/codegen/src/json_codec.rs +2 -2
  105. data/rust/codegen/src/naming.rs +20 -0
  106. data/rust/host/Cargo.toml +6 -2
  107. data/rust/host/HECKS_RELEASE +1 -1
  108. data/rust/host/src/api.rs +28 -5
  109. data/rust/host/src/auth.rs +11 -1
  110. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  111. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  112. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  113. data/rust/host/src/boundary_fuzz/ir.rs +79 -0
  114. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  115. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  116. data/rust/host/src/commerce_ir.rs +459 -0
  117. data/rust/host/src/extension.rs +100 -0
  118. data/rust/host/src/fuzz_support.rs +165 -0
  119. data/rust/host/src/ir.rs +4 -448
  120. data/rust/host/src/journal.rs +1 -1
  121. data/rust/host/src/lib.rs +39 -0
  122. data/rust/host/src/main.rs +10 -44
  123. data/rust/host/src/mint.rs +1 -1
  124. data/rust/host/src/payments/tests.rs +8 -8
  125. data/rust/host/src/payments.rs +10 -10
  126. data/rust/host/src/rate_limit.rs +27 -41
  127. data/rust/host/src/secrets.rs +44 -0
  128. data/rust/host/src/server.rs +6 -1
  129. data/rust/host/src/web/commerce.rs +113 -0
  130. data/rust/host/src/web/newsletter.rs +2 -1
  131. data/rust/host/src/web/newsletter_send.rs +1 -1
  132. data/rust/host/src/web/registration_receipt.rs +2 -2
  133. data/rust/host/src/web/registrations.rs +49 -49
  134. data/rust/host/src/web/resend_webhook.rs +299 -0
  135. data/rust/host/src/web.rs +55 -49
  136. data/rust/parser/fuzz/.gitignore +4 -0
  137. data/rust/parser/fuzz/Cargo.lock +89 -0
  138. data/rust/parser/fuzz/Cargo.toml +27 -0
  139. data/rust/parser/fuzz/README.md +19 -0
  140. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  141. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  142. data/rust/parser/src/parse/command.rs +14 -2
  143. data/rust/parser/src/parse/mod.rs +25 -0
  144. data/rust/parser/src/ruby_value.rs +95 -6
  145. data/rust/src/kernel/json.rs +34 -3
  146. 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 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
@@ -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
- # Load door only — an input list member is left as offered.
92
- return hydrate_stored_list(aggregate, attribute, fields) if attribute.list?
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, fields)
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 = 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)
@@ -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 }
@@ -17,5 +17,11 @@ Hecks.hecksagon "Site" do
17
17
  answers "SiteAnswered"
18
18
  refuses "SiteRefused"
19
19
  end
20
+
21
+ asks "Probe", to: SiteProjection do
22
+ attribute :run, RunKey
23
+ answers "SiteAnswered"
24
+ refuses "SiteRefused"
25
+ end
20
26
  end
21
27
  end
@@ -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("AwsBox") block. Add one, e.g.:
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] `:aws_lambda`, `:aws_fargate` or `:aws_box`
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\") or deployed_to(\"AwsBox\")"
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
- out_dir = options.out ? File.expand_path(options.out) : File.join(root, "deploy", deployment.infra_name)
57
- target = target_key(deployment.settings, deployment.world_file, domain)
58
- write(project(target, deployment, root), out_dir)
59
- 0
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.on("--out=DIR") { |v| options.out = v }
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