hecks 3.4.1 → 3.5.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/memory/shared_elements.rb +121 -17
  3. data/lib/hecks/deploy/bluebook/deploy.bluebook +1630 -0
  4. data/lib/hecks/deploy/bluebook/deploy.hecksagon +65 -0
  5. data/lib/hecks/deploy/oidc.json +240 -0
  6. data/lib/hecks/doors/cli_runner.rb +13 -4
  7. data/lib/hecks/doors/launcher_options.rb +14 -2
  8. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  9. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  10. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +379 -8
  11. data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
  12. data/lib/hecks/hecks/adapters/git.rb +29 -0
  13. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  14. data/lib/hecks/hecks/adapters/site_toolchain.rb +8 -8
  15. data/lib/hecks/hecks/custodian.bluebook +23 -2
  16. data/lib/hecks/hecks/hecks.hecksagon +66 -0
  17. data/lib/hecks/hecks/hecks.world +6 -1
  18. data/lib/hecks/hecks/site.world +4 -2
  19. data/lib/hecks/projections/deploy/box/hosting.rb +13 -3
  20. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  22. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  23. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  24. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  25. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  26. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +11 -5
  27. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  28. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  29. data/lib/hecks/projections/deploy/box.rb +26 -13
  30. data/lib/hecks/projections/site/admin.rb +140 -0
  31. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  32. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  33. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  34. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  35. data/lib/hecks/projections/site/payload/model.rb +128 -0
  36. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  37. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  38. data/lib/hecks/projections/site/root_rows.rb +59 -0
  39. data/lib/hecks/projections/site/site_admin.rb +292 -0
  40. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  41. data/lib/hecks/projections/site/site_host.rb +58 -0
  42. data/lib/hecks/projections/site/site_root.rb +179 -0
  43. data/lib/hecks/runtime/value.rb +7 -0
  44. data/lib/hecks/site/bluebook/site.bluebook +13 -2
  45. data/lib/hecks/tools/site_routes.rb +78 -8
  46. data/lib/hecks/version.rb +1 -1
  47. data/rust/host/HECKS_RELEASE +1 -1
  48. metadata +18 -2
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "console_capture"
4
+ require_relative "shell"
4
5
  require_relative "codebase/tree"
5
6
  require_relative "codebase/ruby_child"
6
7
  require "hecks/projections/deploy/template_diff"
8
+ require_relative "bluebook_report"
9
+ require_relative "deploy_plans"
7
10
 
8
11
  module Hecks
9
12
  module Adapters
@@ -12,8 +15,10 @@ module Hecks
12
15
  #
13
16
  # The recipe, the lint and the manifests run the `Hecks::Tools` tool that already does the work,
14
17
  # in this process with its printing and exit status captured: a tool that ends non-zero is a
15
- # refusal whose reason is what it printed. They write into a hecks checkout, so outside one the
16
- # ask is refused with "needs a hecks checkout". A comparison needs no checkout.
18
+ # refusal whose reason is what it printed. The lint and the manifests write into a hecks
19
+ # checkout, so outside one the ask is refused with "needs a hecks checkout". The recipe reads
20
+ # the project it is given, wherever that is, so it needs no checkout, and neither does a
21
+ # comparison.
17
22
  class DeployToolchain
18
23
  # The `Hecks::Tools` tool an ask runs, by ask.
19
24
  SCRIPTS = { generate: "project_deploy", lint: "lint_deploy_recipes", manifest: "project_oidc" }.freeze
@@ -22,6 +27,62 @@ module Hecks
22
27
  GENERATE_FLAGS = { "--tenant" => :tenant, "--schema" => :schema, "--out" => :out,
23
28
  "--environment" => :environment }.freeze
24
29
 
30
+ # The file name the AwsBox projection gives the post-deploy smoke.
31
+ SMOKE_SCRIPT = "smoke-after-deploy.sh"
32
+
33
+ # What each status of `smoke-after-deploy.sh` means, for the reason a refusal gives.
34
+ SMOKE_STATUS = { 20 => "the roll did not settle", 21 => "gh missing or no repository, smoke not run",
35
+ 22 => "the smoke failed", 23 => "the smoke's result is unknown" }.freeze
36
+
37
+ # The file names the AwsBox projection gives the two rolls.
38
+ SERVICE_SCRIPT = "deploy-service.sh"
39
+ BOX_SCRIPT = "deploy-box.sh"
40
+
41
+ # What each status of `deploy-box.sh` means, for the reason a refusal gives.
42
+ BOX_STATUS = { 40 => "the box stack has no instance", 41 => "the roll did not succeed on the box",
43
+ 42 => "the box is not healthy after the roll" }.freeze
44
+
45
+ # What each status of `deploy-service.sh` means; it ends with the box roll's own statuses.
46
+ SERVICE_STATUS = { 2 => "unknown service", 30 => "the existing tag is not in ECR",
47
+ 31 => "the fresh tag is already in ECR", 32 => "the box's Compose file is unreadable",
48
+ 33 => "the box stack has no instance", 34 => "the stack has no parameter for the container",
49
+ 35 => "the stack update failed or did not settle",
50
+ 36 => "a parameter other than the container's changed",
51
+ 37 => "the task definition lacks the pushed image",
52
+ 38 => "the task definition has no such container" }.merge(BOX_STATUS).freeze
53
+
54
+ # The file names the AwsBox projection gives the data copy and its comparison.
55
+ RESTORE_SCRIPT = "restore-to-rds.sh"
56
+ VERIFY_SCRIPT = "verify-copy.sh"
57
+
58
+ # The status `verify-copy.sh` ends with when the databases differ: an answer, not a refusal.
59
+ DRIFT_STATUS = 50
60
+
61
+ # What each status of `restore-to-rds.sh` means, for the reason a refusal gives.
62
+ RESTORE_STATUS = { 60 => "a Postgres client older than 16",
63
+ 61 => "the target already has a schema; force=true replaces it",
64
+ 62 => "unexpected errors restoring a schema",
65
+ DRIFT_STATUS => "the copy does not match the source" }.freeze
66
+
67
+ # The file name of the bluebook report script and of the preview script; a companion roll runs
68
+ # `deploy-<companion>.sh`.
69
+ DIFF_SCRIPT = "bluebooks-diff.sh"
70
+ PREVIEW_SCRIPT = "preview.sh"
71
+
72
+ # What `preview.sh` ends with: every refusal and failure is 1, a bad verb 2.
73
+ PREVIEW_STATUS = { 1 => "the preview script refused or failed", 2 => "usage" }.freeze
74
+
75
+ # What a roll script ends with: every refusal and failed check is 1.
76
+ COMPANION_STATUS = { 1 => "the roll was refused, did not succeed, or the companion is not healthy" }.freeze
77
+
78
+ # The outcome each `preview.sh` verb records; the writes among them need `confirm`.
79
+ PREVIEW_OUTCOMES = { "name" => "named", "url" => "located", "list" => "listed", "deploy" => "deployed",
80
+ "destroy" => "destroyed", "login" => "signed_in" }.freeze
81
+ PREVIEW_WRITES = %w[deploy destroy login].freeze
82
+
83
+ # What `bluebooks-diff.sh` prints when it has nothing to compare.
84
+ DIFF_UNAVAILABLE = /^==> bluebooks: (could not read|the local domain image has no|the running domain image .* predates)/
85
+
25
86
  # Accepts the arguments every driven adapter is built with and keeps none of them.
26
87
  #
27
88
  # @param aggregate [Object, nil] unused
@@ -29,17 +90,29 @@ module Hecks
29
90
  # @param root [String, nil] unused
30
91
  def initialize(aggregate: nil, settings: {}, root: nil); end
31
92
 
32
- # Writes a domain's deploy recipe (the template, scripts and Makefile) from its declared
33
- # `deployed_to` target, through `hecks deploy recipe.project`.
93
+ # Writes a domain's deploy recipe from its declared `deployed_to` target, through
94
+ # `hecks deploy recipe.project`.
95
+ #
96
+ # The domain and `out` are paths relative to where the command runs, or absolute, so a
97
+ # project outside the hecks checkout (an installed gem has none) works. The Makefiles name
98
+ # the checkout as their root when the tree is one, else the directory the command runs in.
34
99
  #
35
100
  # @param held [Hash] the `Recipe` record: `domain`, and `tenant`, `schema`, `out` and
36
101
  # `environment` when set
37
102
  # @return [Hash{Symbol => Hash}] `output:` one line per file written
38
- # @raise [ConsoleCapture::Failure] when the tree is not a checkout, or the generator refuses
39
- # (no `deployed_to` target, conflicting settings, a schema with no tenant)
103
+ # @raise [ConsoleCapture::Failure] when the generator refuses (no `deployed_to` target,
104
+ # conflicting settings, a schema with no tenant)
40
105
  def generate(**held)
41
- flags = GENERATE_FLAGS.filter_map { |flag, key| "#{flag}=#{plain(held[key])}" unless plain(held[key]).nil? }
42
- child(:generate, [*flags, plain(held[:domain])])
106
+ tree = Codebase::Tree.new
107
+ root = tree.checkout? ? tree.root : Dir.pwd
108
+ flags = GENERATE_FLAGS.filter_map do |flag, key|
109
+ value = plain(held[key])
110
+ next if value.nil?
111
+
112
+ "#{flag}=#{key == :out ? File.expand_path(value) : value}"
113
+ end
114
+ domain = project_path(plain(held[:domain]), root)
115
+ run(Codebase::Tree.new(root: root), :generate, [*flags, domain])
43
116
  end
44
117
 
45
118
  # Lints generated deploy Makefiles for prod-touching recipes that hide failure, through
@@ -86,17 +159,315 @@ module Hecks
86
159
  raise ConsoleCapture::Failure, e.message
87
160
  end
88
161
 
162
+ # Runs a project's generated `smoke-after-deploy.sh`, through `hecks deploy smoke_run.run`.
163
+ #
164
+ # The script is the one the AwsBox projection writes, found beside the Makefile or, failing
165
+ # that, the only one under the project (`script` names it otherwise). It waits for the roll
166
+ # to settle, then dispatches and follows the smoke workflow, and only ever reads AWS. Its
167
+ # options travel in the environment variables it documents.
168
+ #
169
+ # @param held [Hash] the `SmokeRun` record: `project`, and `script`, `taskdef`, `skip`,
170
+ # `async` and `dry_run` when set
171
+ # @return [Hash{Symbol => Hash}] `report:` what the script printed
172
+ # @raise [ConsoleCapture::Failure] when no script, or more than one, is found, or it ends
173
+ # non-zero; the message names its status and what it printed
174
+ def smoke(**held)
175
+ run_script(SMOKE_SCRIPT, held, smoke_env(held), "smoke", SMOKE_STATUS)
176
+ end
177
+
178
+ # Rolls one service with a project's generated `deploy-service.sh`, through
179
+ # `hecks deploy service_roll.run`.
180
+ #
181
+ # The script pushes the image under a fresh tag, sets the stack's tag parameter and rolls
182
+ # the box, so it writes to AWS (a spec puts stand-in programs first on `PATH`).
183
+ # `SMOKE_BY_COMMAND=1` leaves the smoke to the record's policy; the task definition and
184
+ # tag come from the script's last line.
185
+ #
186
+ # @param held [Hash] the `ServiceRoll` record: `project`, `service`, and `script`,
187
+ # `existing_tag` and `local_image` when set
188
+ # @return [Hash{Symbol => Hash}] `report:`, and `taskdef:` and `tag:` when named
189
+ # @raise [ConsoleCapture::Failure] when no script is found or it ends non-zero
190
+ def roll_service(**held)
191
+ env = { "EXISTING_TAG" => plain(held[:existing_tag]), "LOCAL_IMAGE" => plain(held[:local_image]),
192
+ "SMOKE_BY_COMMAND" => "1" }.compact
193
+ answer = run_script(SERVICE_SCRIPT, held, env, "service roll", SERVICE_STATUS, args: [plain(held[:service])])
194
+ match = answer[:report][:value].match(/^==> rolled taskdef=(\S*) tag=(\S+)$/)
195
+ answer.merge(taskdef: wrapped(match&.[](1)), tag: wrapped(match&.[](2))).merge(carried(held, "service_roll"))
196
+ end
197
+
198
+ # Rolls the whole box with a project's generated `deploy-box.sh`, through
199
+ # `hecks deploy box_roll.run`.
200
+ #
201
+ # @param held [Hash] the `BoxRoll` record: `project`, and `script`, `taskdef`, `tags` when set
202
+ # @return [Hash{Symbol => Hash}] `report:` what the script printed
203
+ # @raise [ConsoleCapture::Failure] when no script, or more than one, is found, or it ends
204
+ # non-zero; the message names its status and what it printed
205
+ def roll_box(**held)
206
+ answer = run_script(BOX_SCRIPT, held, {}, "box roll", BOX_STATUS, args: box_args(held))
207
+ answer.merge(carried(held, "box_roll")).merge(taskdef: wrapped(plain(held[:taskdef])))
208
+ end
209
+
210
+ # Copies a project's schemas into RDS with its generated `restore-to-rds.sh`, through
211
+ # `hecks deploy data_copy.restore`.
212
+ #
213
+ # The script overwrites the target database's schemas, so the adapter refuses unless the
214
+ # record says `confirm`, naming what would be overwritten. A `dry_run` answers the plan and
215
+ # runs nothing (confirmed or not). The script runs with `VERIFY_BY_COMMAND=1`: the record's
216
+ # policy requests the comparison, unless `skip_verify` or a dry run.
217
+ #
218
+ # @param held [Hash] the `DataCopy` record
219
+ # @return [Hash{Symbol => Hash}] `report:`, `planned:` and what the policies carry on
220
+ # @raise [ConsoleCapture::Failure] when no script is found, the copy is not confirmed, or the
221
+ # script ends non-zero
222
+ def copy_data(**held)
223
+ script = script_for(RESTORE_SCRIPT, plain(held[:project]), plain(held[:script]))
224
+ plan = copy_plan(script, held)
225
+ return copy_answer(held, "#{plan}\ndry run: nothing was run", planned: true) if plain(held[:dry_run]) == true
226
+
227
+ unless plain(held[:confirm]) == true
228
+ raise ConsoleCapture::Failure,
229
+ "refusing to restore: #{plan}\npass confirm=true to run it (dry_run=true prints this plan)"
230
+ end
231
+
232
+ answer = run_script(RESTORE_SCRIPT, held, copy_env(held), "restore", RESTORE_STATUS, args: copy_args(held))
233
+ copy_answer(held, answer[:report][:value], planned: false)
234
+ end
235
+
236
+ # Compares two databases with a project's generated `verify-copy.sh`, through
237
+ # `hecks deploy data_copy.verify`. Read-only on both. Differing databases are an answer
238
+ # (`drifted:`), not a refusal.
239
+ #
240
+ # @param held [Hash] the `CopyVerification` record
241
+ # @return [Hash{Symbol => Hash}] `report:` and `drifted:`
242
+ # @raise [ConsoleCapture::Failure] when no script is found or it fails for another reason
243
+ def compare_copy(**held)
244
+ env = { "A_DB" => database(held[:source_db]), "B_DB" => database(held[:target_db]) }.compact
245
+ answer = run_script(VERIFY_SCRIPT, held, env, "verify", {}, args: copy_args(held), answering: [DRIFT_STATUS])
246
+ { report: answer[:report], drifted: { value: answer.key?(:status) } }
247
+ end
248
+
249
+ # Reports which bluebook releases a deploy changes, through `hecks deploy bluebook_diff.run`.
250
+ #
251
+ # With `old` and `new` (two `package.verify` outputs) the comparison is made here, offline.
252
+ # With neither, the project's `bluebooks-diff.sh` reads the running image's bluebooks
253
+ # (read-only) and its report is classified. Never a failure: what cannot be compared is
254
+ # `unavailable`. Only one of `old` and `new` is refused.
255
+ #
256
+ # @param held [Hash] the `BluebookDiff` record
257
+ # @return [Hash{Symbol => Hash}] `outcome:` (unchanged, changed or unavailable) and `report:`
258
+ # @raise [ConsoleCapture::Failure] when only one of `old` and `new` is given
259
+ def compare_bluebooks(**held)
260
+ old = plain(held[:old])
261
+ new = plain(held[:new])
262
+ raise ConsoleCapture::Failure, "give old= and new= together, or neither" if old.nil? != new.nil?
263
+
264
+ old ? diff_files(old, new) : diff_script(held)
265
+ end
266
+
267
+ # Runs a project's `preview.sh` with one verb, through `hecks deploy preview_run.<verb>`.
268
+ #
269
+ # `name`, `url` and `list` read. `deploy`, `destroy` and `login` write to AWS or read its
270
+ # secrets, so they refuse unless the record says `confirm`, naming the plan; `dry_run` answers
271
+ # that plan and runs nothing. The branch travels in `BRANCH`, as the script reads it.
272
+ #
273
+ # @param held [Hash] the `PreviewRun` record
274
+ # @return [Hash{Symbol => Hash}] `outcome:`, `report:` and `planned:`
275
+ # @raise [ConsoleCapture::Failure] when no script is found, a write is not confirmed, or the
276
+ # script ends non-zero
277
+ def run_preview(**held)
278
+ action = plain(held[:action]).to_s
279
+ script = script_for(PREVIEW_SCRIPT, plain(held[:project]), plain(held[:script]))
280
+ if PREVIEW_WRITES.include?(action)
281
+ plan = DeployPlans.preview(File.read(script), action, plain(held[:branch]))
282
+ return planned_answer("#{action}: #{plan}") if plain(held[:dry_run]) == true
283
+
284
+ require_confirm(held, "#{action} a preview", plan)
285
+ end
286
+ env = { "BRANCH" => plain(held[:branch]) }.compact
287
+ answer = run_script(PREVIEW_SCRIPT, held, env, "preview #{action}", PREVIEW_STATUS, args: [action])
288
+ { outcome: { value: PREVIEW_OUTCOMES.fetch(action) }, report: answer[:report], planned: { value: false } }
289
+ end
290
+
291
+ # Rolls a companion Compose project onto the box with a project's `deploy-<companion>.sh`,
292
+ # through `hecks deploy companion_roll.run`.
293
+ #
294
+ # The script sends the project and its secret references to the box over SSM and starts it, so
295
+ # the adapter refuses unless the record says `confirm`, naming the plan; `dry_run` answers the
296
+ # plan and runs nothing. The task definition is the script's one argument.
297
+ #
298
+ # @param held [Hash] the `CompanionRoll` record
299
+ # @return [Hash{Symbol => Hash}] `report:` and `planned:`
300
+ # @raise [ConsoleCapture::Failure] when no script is found, the roll is not confirmed, or the
301
+ # script ends non-zero
302
+ def roll_companion(**held)
303
+ companion = plain(held[:companion]).to_s
304
+ name = "deploy-#{companion}.sh"
305
+ script = script_for(name, plain(held[:project]), plain(held[:script]))
306
+ plan = DeployPlans.companion(File.read(script), companion, plain(held[:taskdef]))
307
+ return planned_answer(plan) if plain(held[:dry_run]) == true
308
+
309
+ require_confirm(held, "roll #{companion}", plan)
310
+ answer = run_script(name, held, {}, "companion roll", COMPANION_STATUS, args: [plain(held[:taskdef])])
311
+ answer.merge(planned: { value: false })
312
+ end
313
+
89
314
  private
90
315
 
316
+ # A dry run's answer: the plan, and nothing run.
317
+ def planned_answer(plan)
318
+ { outcome: { value: "planned" }, report: { value: "#{plan}\ndry run: nothing was run" }, planned: { value: true } }
319
+ end
320
+
321
+ def require_confirm(held, what, plan)
322
+ return if plain(held[:confirm]) == true
323
+
324
+ raise ConsoleCapture::Failure,
325
+ "refusing to #{what}: #{plan}\npass confirm=true to run it (dry_run=true prints this plan)"
326
+ end
327
+
328
+ def diff_files(old, new)
329
+ report = BluebookReport.new(File.read(File.expand_path(old)), File.read(File.expand_path(new)))
330
+ diff_answer(report.changed? ? "changed" : "unchanged", report.text)
331
+ rescue SystemCallError, JSON::ParserError, TypeError, KeyError => e
332
+ diff_answer("unavailable", "bluebooks: could not compare #{old} and #{new} (#{e.message.lines.first.strip})")
333
+ end
334
+
335
+ # The script reads the registry and the local image; its report says what it found.
336
+ def diff_script(held)
337
+ text = run_script(DIFF_SCRIPT, held, {}, "bluebook diff", {}).dig(:report, :value)
338
+ return diff_answer("unavailable", text) if text.match?(DIFF_UNAVAILABLE)
339
+
340
+ diff_answer(text.include?("no bluebook changes.") ? "unchanged" : "changed", text)
341
+ rescue ConsoleCapture::Failure => e
342
+ diff_answer("unavailable", "bluebooks: #{e.message.lines.first.strip}; nothing to compare.")
343
+ end
344
+
345
+ def diff_answer(outcome, text) = { outcome: { value: outcome }, report: { value: text } }
346
+
347
+ # What a data copy's answer hands on to the policies that follow it.
348
+ def copy_answer(held, report, planned:)
349
+ carried = %i[project bastion source source_secret target target_secret source_db target_db]
350
+ .to_h { |key| [key, { value: plain(held[key]).to_s }] }
351
+ verify = !planned && plain(held[:skip_verify]) != true
352
+ carried.merge(report: { value: report }, planned: { value: planned }, run_verify: { value: verify })
353
+ end
354
+
355
+ # A database name the record gave, or nil when it left the script's default.
356
+ def database(argument) = plain(argument).to_s.empty? ? nil : plain(argument)
357
+
358
+ def copy_args(held) = %i[bastion source source_secret target target_secret].map { |key| plain(held[key]) }
359
+
360
+ def copy_env(held)
361
+ { "FORCE" => flag(held[:force]), "SRC_DB" => database(held[:source_db]), "DST_DB" => database(held[:target_db]),
362
+ "VERIFY_BY_COMMAND" => "1" }.compact
363
+ end
364
+
365
+ # What the script would overwrite, read from the script itself (its schemas and databases).
366
+ def copy_plan(script, held)
367
+ text = File.read(script)
368
+ schemas = text[/^SCHEMAS="([^"]*)"/, 1].to_s.split.join(", ")
369
+ databases = text.match(/^SRC_DB=\$\{SRC_DB:-([^}]*)\}; DST_DB=\$\{DST_DB:-([^}]*)\}/)
370
+ source_db = database(held[:source_db]) || databases&.[](1)
371
+ target_db = database(held[:target_db]) || databases&.[](2)
372
+ drop = plain(held[:force]) == true ? "; force=true drops each target schema first" : ""
373
+ "copy schemas #{schemas} from database #{source_db} on #{plain(held[:source])} into database " \
374
+ "#{target_db} on #{plain(held[:target])} through bastion #{plain(held[:bastion])}, " \
375
+ "OVERWRITING those schemas there#{drop}"
376
+ end
377
+
378
+ # What a roll's answer hands on to the policies that follow it: the project to find the smoke
379
+ # in, whether to request the smoke (not when the record opted out or the project has no smoke
380
+ # script, either of which is noted on the record), and which roll asked.
381
+ def carried(held, kind)
382
+ skipped = plain(held[:skip_smoke]) == true
383
+ script = smoke_script?(plain(held[:project]))
384
+ note = "smoke skipped: skip_smoke=true" if skipped
385
+ note ||= "smoke skipped: no smoke script" unless script
386
+ { project: { value: plain(held[:project]) }, skip_smoke: { value: skipped },
387
+ run_smoke: { value: note.nil? }, smoke: wrapped(note), kind: { value: kind } }
388
+ end
389
+
390
+ # Whether the project has a generated smoke script the smoke could run.
391
+ def smoke_script?(project)
392
+ script_for(SMOKE_SCRIPT, project, nil)
393
+ true
394
+ rescue ConsoleCapture::Failure
395
+ false
396
+ end
397
+
398
+ def wrapped(text) = text.to_s.empty? ? nil : { value: text }
399
+
400
+ # What `deploy-box.sh` takes: a task definition, or `name=tag` words.
401
+ def box_args(held) = [plain(held[:taskdef]), *plain(held[:tags]).to_s.split].compact
402
+
403
+ # Runs one generated script, found for the record's project, from its own directory. Answers
404
+ # what it printed, or refuses with its status, that status's meaning and its output.
405
+ def run_script(name, held, env, label, statuses, args: [], answering: [])
406
+ script = script_for(name, plain(held[:project]), plain(held[:script]))
407
+ result = Shell.new.capture("bash", script, *args, env: env, chdir: File.dirname(script))
408
+ report = [result.out, result.err].map(&:strip).reject(&:empty?).join("\n")
409
+ return { report: { value: report } } if result.ok?
410
+
411
+ code = result.status.exitstatus
412
+ return { report: { value: report }, status: code } if answering.include?(code)
413
+
414
+ raise ConsoleCapture::Failure, "#{label} ended #{code} (#{statuses.fetch(code, 'unexpected')})\n#{report}"
415
+ end
416
+
417
+ # The script a project runs: the override, else the named one in the project itself, else the
418
+ # only one beneath it (not under `node_modules`, `vendor` or `.git`).
419
+ def script_for(name, project, override)
420
+ return existing_script(override) if override
421
+
422
+ root = File.expand_path(project.to_s)
423
+ beside = File.join(root, name)
424
+ return beside if File.file?(beside)
425
+
426
+ found = Dir.glob(File.join(root, "**", name)).grep_v(%r{/(node_modules|vendor|\.git)/})
427
+ return found.first if found.one?
428
+
429
+ raise ConsoleCapture::Failure, ambiguity(name, root, found.sort)
430
+ end
431
+
432
+ def ambiguity(name, root, found)
433
+ return "no #{name} under #{root}; generate the AwsBox recipe or pass script=<path>" if found.empty?
434
+
435
+ "#{found.size} #{name} files under #{root}; pass script=<path>: #{found.join(', ')}"
436
+ end
437
+
438
+ def existing_script(path)
439
+ script = File.expand_path(path.to_s)
440
+ File.file?(script) ? script : raise(ConsoleCapture::Failure, "no such script: #{script}")
441
+ end
442
+
443
+ # The variables the generated script reads, set only when the record asks for them.
444
+ def smoke_env(held)
445
+ { "TASKDEF" => plain(held[:taskdef]), "SKIP_POST_DEPLOY_SMOKE" => flag(held[:skip]),
446
+ "SMOKE_ASYNC" => flag(held[:async]), "DRY_RUN" => flag(held[:dry_run]) }.compact
447
+ end
448
+
449
+ def flag(argument) = plain(argument) == true ? "1" : nil
450
+
91
451
  def child(ask, argv)
92
452
  tree = Codebase::Tree.new
93
453
  tree.require_checkout!
454
+ run(tree, ask, argv)
455
+ end
456
+
457
+ def run(tree, ask, argv)
94
458
  result = Codebase::RubyChild.new(tree).capture(SCRIPTS.fetch(ask), *argv)
95
459
  raise ConsoleCapture::Failure, message_of(result) unless result.ok?
96
460
 
97
461
  { output: { value: result.out } }
98
462
  end
99
463
 
464
+ # A project path the tool can read from `root`: relative to it when it lies beneath it, so a
465
+ # recipe made in a checkout names `examples/pizzas`, else absolute.
466
+ def project_path(path, root)
467
+ absolute = File.expand_path(path)
468
+ absolute.start_with?("#{root}/") ? absolute.delete_prefix("#{root}/") : absolute
469
+ end
470
+
100
471
  def message_of(result)
101
472
  text = [result.err, result.out].map(&:strip).reject(&:empty?).join("\n")
102
473
  text.empty? ? "the tool ended with status #{result.status.exitstatus}" : text
@@ -17,6 +17,13 @@ module Hecks
17
17
  # The roles that count as an administrator besides the one the grant command is gated to.
18
18
  OWNER_ROLES = %w[Owner].freeze
19
19
 
20
+ # What the refusal says when no chapter provides membership: the line to add to one.
21
+ NO_MEMBERSHIP = "this domain attaches no chapter that provides \"membership\". Add a line like " \
22
+ "`provides \"membership\", admit: \"Person.Admit\", grant: \"Person.GrantAccess\", " \
23
+ "people: \"Person.All\"` to the chapter that keeps who may sign in: admit and grant " \
24
+ "name its commands (the grant command is gated to the administrator role), people " \
25
+ "names the query that lists everyone (docs/running-a-rules-service.md)"
26
+
20
27
  # What a bootstrap did.
21
28
  #
22
29
  # @!attribute [r] email
@@ -64,7 +71,7 @@ module Hecks
64
71
  def membership_provider
65
72
  domain = @registry.bluebooks.values.first&.name
66
73
  @registry.membership_provider_for(domain) or
67
- raise Runtime::NotFound, "this domain attaches no chapter that provides \"membership\""
74
+ raise Runtime::NotFound, NO_MEMBERSHIP
68
75
  end
69
76
 
70
77
  def verbs_of(provider)
@@ -79,6 +79,28 @@ module Hecks
79
79
  raise Runtime::NotFound, e.message
80
80
  end
81
81
 
82
+ # Prints the content digest and shape labels of one package, as its `bluebook.lock` records
83
+ # them: `Lock.digest_of` over the package's `bluebook/*.bluebook` files alone, and the labels
84
+ # `Shape.labels` gives the same files. The package is read from `<root>/<package>/bluebook`
85
+ # (a registry), else from `<root>/vendor/embryonaut_bluebooks/<package>/bluebook` (a project
86
+ # that vendors it).
87
+ #
88
+ # @param held [Hash] the `Registry` query's arguments: `package`, and `root` (the current
89
+ # directory when absent)
90
+ # @return [Hash{Symbol => String}] `text:` a `digest:` line, then a `shape:` line each
91
+ # @raise [Runtime::NotFound] when the package has no `bluebook/` with `*.bluebook` files, or
92
+ # they do not load
93
+ def digest(**held)
94
+ name = plain(held[:package])
95
+ dir = package_bluebook_dir(File.expand_path(plain(held[:root]) || Dir.pwd), name)
96
+ digest = EmbryonautBluebook::Lock.digest_of(dir) or
97
+ raise Runtime::NotFound, "#{dir} holds no *.bluebook files"
98
+ lines = ["digest: #{digest}", *EmbryonautBluebook::Shape.labels(dir).map { |label| "shape: #{label}" }]
99
+ { text: "#{lines.join("\n")}\n" }
100
+ rescue Vendoring::Error => e
101
+ raise Runtime::NotFound, e.message
102
+ end
103
+
82
104
  # Judges every package of a bluebook registry against its latest release, through
83
105
  # `Hecks::EmbryonautBluebook::Registry#check`.
84
106
  #
@@ -136,6 +158,13 @@ module Hecks
136
158
 
137
159
  private
138
160
 
161
+ def package_bluebook_dir(root, name)
162
+ places = [File.join(name, "bluebook"), File.join("vendor", "embryonaut_bluebooks", name, "bluebook")]
163
+ found = places.map { |place| File.join(root, place) }.find { |dir| File.directory?(dir) }
164
+ found or raise Runtime::NotFound,
165
+ "#{name} has no bluebook/ directory in #{root} (looked in #{places.join(' and ')})"
166
+ end
167
+
139
168
  def registry(held)
140
169
  EmbryonautBluebook::Registry.new(File.expand_path(plain(held[:root]) || Dir.pwd))
141
170
  end
@@ -23,6 +23,9 @@ module Hecks
23
23
  # checkout; an installed gem has none.
24
24
  CHECKOUT_MARKER = "hecks.gemspec"
25
25
 
26
+ # The line `hecks model_check` prints before each domain it examines.
27
+ REPORT_HEADER = /^── /
28
+
26
29
  # Longest a `Follow` query may wait for new entries, so one call cannot hold a launcher open.
27
30
  MAX_FOLLOW_WAIT = 60
28
31
 
@@ -30,7 +33,8 @@ module Hecks
30
33
  #
31
34
  # @param held [Hash] the `ModelCheckRun` record: `domains` (comma separated paths), `strict`
32
35
  # and `profile`
33
- # @return [Hash{Symbol => Hash}] `report:` the analysis as printed
36
+ # @return [Hash{Symbol => Hash}] `report:` the analysis as printed, `checked:` how many
37
+ # domains it headed
34
38
  # @raise [ConsoleCapture::Failure] when a finding was left, or the analysis could not run
35
39
  def check(**held)
36
40
  argv = plain(held[:domains]).to_s.split(",").map(&:strip).reject(&:empty?)
@@ -43,7 +47,7 @@ module Hecks
43
47
  text = ConsoleCapture.answer do
44
48
  CLI::ModelCheck.call(argv, program: "hecks model_check", root: checkout_root)
45
49
  end
46
- { report: { value: text } }
50
+ { report: { value: text }, checked: { value: text.scan(REPORT_HEADER).size } }
47
51
  end
48
52
 
49
53
  # Dispatches one verb, or executes a step list, against a domain.
@@ -22,7 +22,7 @@ module Hecks
22
22
  SCRIPTS = { project: "project_site" }.freeze
23
23
 
24
24
  # The tool's flag for each `SiteProjection` field that takes a value.
25
- FLAGS = { "out" => :out, "template" => :template, "extension" => :extension }.freeze
25
+ FLAGS = { "out" => :out, "template" => :template, "cms" => :cms, "root" => :root, "extension" => :extension }.freeze
26
26
 
27
27
  # Accepts the arguments every driven adapter is built with and keeps none of them.
28
28
  #
@@ -33,8 +33,8 @@ module Hecks
33
33
 
34
34
  # Writes a project's `routes.ts` from its declared route table, or with `check` only compares.
35
35
  #
36
- # @param held [Hash] the `SiteProjection` record: `domain`, and `out`, `template`, `extension`
37
- # and `check` when set
36
+ # @param held [Hash] the `SiteProjection` record: `domain`, and `out`, `template`, `cms`,
37
+ # `root`, `extension` and `check` when set
38
38
  # @return [Hash{Symbol => Hash}] `output:` one line per file written or found current
39
39
  # @raise [ConsoleCapture::Failure] when the route table is refused (an unknown cache class,
40
40
  # a duplicate path), a path or the extension is refused, or under `check` a file is out
@@ -55,13 +55,13 @@ module Hecks
55
55
  # changed on either side.
56
56
  #
57
57
  # @param held [Hash] the `SiteProjection` record: `domain`, and `live` or `distribution`,
58
- # with `expect_new` (path patterns, comma separated) and `refs` (`!Ref Name=id` words,
59
- # comma separated) when set
58
+ # with `template` (stands in for the Edge row's own; never read), `expect_new` (path
59
+ # patterns, comma separated) and `refs` (`!Ref Name=id` words, comma separated) when set
60
60
  # @return [Hash{Symbol => Hash}] `output:` the report of a distribution that matches
61
61
  # @raise [ConsoleCapture::Failure] when the project has no edge, the live configuration
62
62
  # cannot be read, or the two differ; the message is the report
63
63
  def compare(**held)
64
- edge = edge_of(located(:domain, held[:domain]))
64
+ edge = edge_of(located(:domain, held[:domain]), located(:template, held[:template]))
65
65
  live = live_configuration(held)
66
66
  live_edge = Projections::Site::LiveEdge
67
67
  comparison = live_edge.new(edge, live, expect_new: words(held[:expect_new]),
@@ -74,7 +74,7 @@ module Hecks
74
74
  private
75
75
 
76
76
  # The project's checked edge, read the way `project_site` reads it.
77
- def edge_of(root)
77
+ def edge_of(root, template = nil)
78
78
  registry = nil
79
79
  outcome = ConsoleCapture.capture { registry = Tools::SiteRoutes.registry_for(root) }
80
80
  raise ConsoleCapture::Failure, outcome.output.strip unless outcome.ok?
@@ -82,7 +82,7 @@ module Hecks
82
82
  site = Projections::Site
83
83
  chapter = site::Table.chapter(registry)
84
84
  table = site::Table.read(chapter, registry: registry)
85
- site::Edge.read(chapter, table: table, vocabulary: site::Table.vocabulary(registry)) ||
85
+ site::Edge.read(chapter, table: table, template: template, vocabulary: site::Table.vocabulary(registry)) ||
86
86
  raise(ConsoleCapture::Failure, "#{root} declares no Edge rows")
87
87
  rescue Projections::Site::Table::Invalid => e
88
88
  raise ConsoleCapture::Failure, e.message
@@ -121,6 +121,7 @@ Hecks.bluebook "Hecks" do
121
121
  attribute :profile, ProfileName, optional: true
122
122
  attribute :strict, Switch, optional: true
123
123
  attribute :report, Report, optional: true
124
+ attribute :checked, Checked, optional: true
124
125
  attribute :refusal, Report, optional: true
125
126
  identified_by :run
126
127
 
@@ -146,6 +147,13 @@ Hecks.bluebook "Hecks" do
146
147
  attribute :value, String
147
148
  end
148
149
 
150
+ # How many domains the report examined, counted from its headers so a reader need not.
151
+ value_object "Checked" do
152
+ attribute :value, Integer
153
+
154
+ invariant("a count is not negative") { value >= 0 }
155
+ end
156
+
149
157
  lifecycle :status, default: "requested" do
150
158
  transition "Pass" => "clean", from: "requested"
151
159
  transition "Flag" => "flagged", from: "requested"
@@ -173,9 +181,11 @@ Hecks.bluebook "Hecks" do
173
181
  goal "Record that the analysis left no error"
174
182
 
175
183
  reference_to ModelCheckRun
176
- attribute :report, Report
184
+ attribute :report, Report
185
+ attribute :checked, Checked
177
186
 
178
187
  sets :report
188
+ sets :checked
179
189
 
180
190
  emits ModelCheckPassed
181
191
  end
@@ -214,7 +224,7 @@ Hecks.bluebook "Hecks" do
214
224
 
215
225
  policy "RecordTheClean" do
216
226
  on "ModelCheckRun.ModelCheckAnswered"
217
- trigger ModelCheckRun::Pass, with: { report: :report }
227
+ trigger ModelCheckRun::Pass, with: { report: :report, checked: :checked }
218
228
  end
219
229
 
220
230
  policy "RecordTheFlag" do
@@ -1420,6 +1430,17 @@ policy "RecordTheEra" do
1420
1430
  returns Document
1421
1431
  end
1422
1432
 
1433
+ # `hecks package.digest <package> [root=path]`: a read of the package's files, so it keeps no
1434
+ # record. The digest is `Lock.digest_of` over `<package>/bluebook/*.bluebook` only (the
1435
+ # `bluebook.lock` digest line), then the package's shape labels; `root` is the registry, or a
1436
+ # project that vendors the package.
1437
+ query "Digest" do
1438
+ description "Print a package's content digest and shape labels, as its bluebook.lock records them."
1439
+ attribute :package, PackageName
1440
+ attribute :root, Location, optional: true
1441
+ returns Document
1442
+ end
1443
+
1423
1444
  query "Releasing" do
1424
1445
  description "What releasing one run did: its status, and the report or the refusal."
1425
1446
  attribute :run, RegistryKey