hecks 3.4.2 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
  4. data/lib/hecks/deploy/oidc.json +225 -0
  5. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  6. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  7. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
  8. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  9. data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
  10. data/lib/hecks/hecks/custodian.bluebook +12 -2
  11. data/lib/hecks/hecks/hecks.hecksagon +57 -0
  12. data/lib/hecks/hecks/hecks.world +5 -1
  13. data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
  14. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  15. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  16. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  17. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  18. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  19. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  20. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
  21. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  22. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  23. data/lib/hecks/projections/deploy/box.rb +8 -14
  24. data/lib/hecks/projections/site/admin.rb +140 -0
  25. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  26. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  27. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  28. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  29. data/lib/hecks/projections/site/payload/model.rb +128 -0
  30. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  31. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  32. data/lib/hecks/projections/site/root_rows.rb +59 -0
  33. data/lib/hecks/projections/site/site_admin.rb +292 -0
  34. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  35. data/lib/hecks/projections/site/site_host.rb +58 -0
  36. data/lib/hecks/projections/site/site_root.rb +179 -0
  37. data/lib/hecks/site/bluebook/site.bluebook +7 -1
  38. data/lib/hecks/tools/site_routes.rb +78 -8
  39. data/lib/hecks/version.rb +1 -1
  40. data/rust/host/HECKS_RELEASE +1 -1
  41. metadata +17 -1
@@ -5,6 +5,8 @@ require_relative "shell"
5
5
  require_relative "codebase/tree"
6
6
  require_relative "codebase/ruby_child"
7
7
  require "hecks/projections/deploy/template_diff"
8
+ require_relative "bluebook_report"
9
+ require_relative "deploy_plans"
8
10
 
9
11
  module Hecks
10
12
  module Adapters
@@ -32,6 +34,55 @@ module Hecks
32
34
  SMOKE_STATUS = { 20 => "the roll did not settle", 21 => "gh missing or no repository, smoke not run",
33
35
  22 => "the smoke failed", 23 => "the smoke's result is unknown" }.freeze
34
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
+
35
86
  # Accepts the arguments every driven adapter is built with and keeps none of them.
36
87
  #
37
88
  # @param aggregate [Object, nil] unused
@@ -121,37 +172,267 @@ module Hecks
121
172
  # @raise [ConsoleCapture::Failure] when no script, or more than one, is found, or it ends
122
173
  # non-zero; the message names its status and what it printed
123
174
  def smoke(**held)
124
- script = smoke_script(plain(held[:project]), plain(held[:script]))
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
+
314
+ private
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
125
334
 
126
- result = Shell.new.capture("bash", script, env: smoke_env(held), chdir: File.dirname(script))
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))
127
408
  report = [result.out, result.err].map(&:strip).reject(&:empty?).join("\n")
128
409
  return { report: { value: report } } if result.ok?
129
410
 
130
411
  code = result.status.exitstatus
131
- raise ConsoleCapture::Failure, "smoke ended #{code} (#{SMOKE_STATUS.fetch(code, 'unexpected')})\n#{report}"
132
- end
412
+ return { report: { value: report }, status: code } if answering.include?(code)
133
413
 
134
- private
414
+ raise ConsoleCapture::Failure, "#{label} ended #{code} (#{statuses.fetch(code, 'unexpected')})\n#{report}"
415
+ end
135
416
 
136
- # The script a project's smoke runs: the override, else `smoke-after-deploy.sh` in the project
137
- # itself, else the only one beneath it (not under `node_modules`, `vendor` or `.git`).
138
- def smoke_script(project, override)
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)
139
420
  return existing_script(override) if override
140
421
 
141
422
  root = File.expand_path(project.to_s)
142
- beside = File.join(root, SMOKE_SCRIPT)
423
+ beside = File.join(root, name)
143
424
  return beside if File.file?(beside)
144
425
 
145
- found = Dir.glob(File.join(root, "**", SMOKE_SCRIPT)).grep_v(%r{/(node_modules|vendor|\.git)/})
426
+ found = Dir.glob(File.join(root, "**", name)).grep_v(%r{/(node_modules|vendor|\.git)/})
146
427
  return found.first if found.one?
147
428
 
148
- raise ConsoleCapture::Failure, ambiguity(root, found.sort)
429
+ raise ConsoleCapture::Failure, ambiguity(name, root, found.sort)
149
430
  end
150
431
 
151
- def ambiguity(root, found)
152
- return "no #{SMOKE_SCRIPT} under #{root}; generate the AwsBox recipe or pass script=<path>" if found.empty?
432
+ def ambiguity(name, root, found)
433
+ return "no #{name} under #{root}; generate the AwsBox recipe or pass script=<path>" if found.empty?
153
434
 
154
- "#{found.size} #{SMOKE_SCRIPT} files under #{root}; pass script=<path>: #{found.join(', ')}"
435
+ "#{found.size} #{name} files under #{root}; pass script=<path>: #{found.join(', ')}"
155
436
  end
156
437
 
157
438
  def existing_script(path)
@@ -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
@@ -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
@@ -404,11 +404,68 @@ Hecks.hecksagon "Hecks" do
404
404
  Deploy::SmokeRun.port "DeployToolchain" do
405
405
  asks "Smoke", to: SmokeRun do
406
406
  attribute :run, RunKey
407
+ attribute :after, Origin, default: { value: "alone" }
407
408
  answers "SmokeAnswered"
408
409
  refuses "SmokeRefused"
409
410
  end
410
411
  end
411
412
 
413
+ Deploy::ServiceRoll.port "DeployToolchain" do
414
+ asks "RollService", to: ServiceRoll do
415
+ attribute :run, RunKey
416
+ answers "ServiceRollAnswered"
417
+ refuses "ServiceRollRefused"
418
+ end
419
+ end
420
+
421
+ Deploy::BoxRoll.port "DeployToolchain" do
422
+ asks "RollBox", to: BoxRoll do
423
+ attribute :run, RunKey
424
+ answers "BoxRollAnswered"
425
+ refuses "BoxRollRefused"
426
+ end
427
+ end
428
+
429
+ Deploy::DataCopy.port "DeployToolchain" do
430
+ asks "CopyData", to: DataCopy do
431
+ attribute :run, RunKey
432
+ answers "DataCopyAnswered"
433
+ refuses "DataCopyRefused"
434
+ end
435
+ end
436
+
437
+ Deploy::CopyVerification.port "DeployToolchain" do
438
+ asks "CompareCopy", to: CopyVerification do
439
+ attribute :run, RunKey
440
+ answers "CopyVerificationAnswered"
441
+ refuses "CopyVerificationRefused"
442
+ end
443
+ end
444
+
445
+ Deploy::BluebookDiff.port "DeployToolchain" do
446
+ asks "CompareBluebooks", to: BluebookDiff do
447
+ attribute :run, RunKey
448
+ answers "BluebookDiffAnswered"
449
+ refuses "BluebookDiffRefused"
450
+ end
451
+ end
452
+
453
+ Deploy::PreviewRun.port "DeployToolchain" do
454
+ asks "RunPreview", to: PreviewRun do
455
+ attribute :run, RunKey
456
+ answers "PreviewRunAnswered"
457
+ refuses "PreviewRunRefused"
458
+ end
459
+ end
460
+
461
+ Deploy::CompanionRoll.port "DeployToolchain" do
462
+ asks "RollCompanion", to: CompanionRoll do
463
+ attribute :run, RunKey
464
+ answers "CompanionRollAnswered"
465
+ refuses "CompanionRollRefused"
466
+ end
467
+ end
468
+
412
469
  Deploy::TemplateComparison.port "DeployToolchain" do
413
470
  asks "Compare", to: TemplateComparison do
414
471
  attribute :run, RunKey
@@ -40,7 +40,11 @@ Hecks.world "Hecks" do
40
40
  failure_states: %w[flagged failed drifted unreachable refused faulted halted stopped
41
41
  abandoned red needs_fix],
42
42
  settled: %w[package.vendor package.revendor package.release
43
- operation.bootstrap_admin smoke_run.run],
43
+ operation.bootstrap_admin smoke_run.run
44
+ service_roll.run box_roll.run data_copy.restore
45
+ data_copy.verify bluebook_diff.run preview_run.name
46
+ preview_run.url preview_run.list preview_run.deploy
47
+ preview_run.destroy preview_run.login companion_roll.run],
44
48
  names: { "mcp" => "door.serve_mcp", "console" => "operation.open_console", "init" => "door.init",
45
49
  "interview" => "door.interview", "package.check" => "registry.check",
46
50
  "package.digest" => "registry.digest",
@@ -33,9 +33,10 @@ module Hecks
33
33
  # @param plan [Settings::Plan] the resolved settings
34
34
  # @return [String] the Makefile fragment the box `Makefile` includes
35
35
  def hosting_mk(plan)
36
- Box.template("hosting.mk.tmpl", "SERVICE" => plan.containers.first.name,
37
- "URL" => plan.hosting.public_url.to_s,
38
- "SMOKE_RECIPE" => Box::SMOKE_RECIPE)
36
+ Box.template("hosting.mk.tmpl", "SERVICE" => plan.containers.first.name,
37
+ "URL" => plan.hosting.public_url.to_s,
38
+ "SMOKE_RECIPE" => Box::SMOKE_RECIPE,
39
+ "SERVICE_RECIPE" => service_recipe)
39
40
  end
40
41
 
41
42
  # @param plan [Settings::Plan] the resolved settings
@@ -50,6 +51,14 @@ module Hecks
50
51
  "ROLL_STEPS" => part(roll))
51
52
  end
52
53
 
54
+ # @return [String] the `deploy-service` recipe: the `service_roll.run` command
55
+ def service_recipe
56
+ args = "service=$(SERVICE) $(if $(EXISTING_TAG),existing_tag=$(EXISTING_TAG)) " \
57
+ "$(if $(LOCAL_IMAGE),local_image=$(LOCAL_IMAGE))"
58
+ direct = "bash ./deploy-service.sh $(SERVICE)"
59
+ Box::RollRecipe.call(command: "service_roll.run", args: args, direct: direct)
60
+ end
61
+
53
62
  # @param plan [Settings::Plan] the resolved settings
54
63
  # @param region [String] the validated AWS region
55
64
  # @return [String] `smoke-after-deploy.sh`
@@ -148,7 +157,7 @@ module Hecks
148
157
  resolve_service() {
149
158
  case "$1" in
150
159
  #{arms.join("\n")}
151
- *) echo "unknown service '$1', must be one of: ${SERVICES}" >&2; exit 1 ;;
160
+ *) echo "unknown service '$1', must be one of: ${SERVICES}" >&2; exit 2 ;;
152
161
  esac
153
162
  }
154
163
  BASH
@@ -0,0 +1,62 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ module Box
5
+ # The Makefile recipes that run a roll as `box_roll.run` or `service_roll.run`.
6
+ #
7
+ # The command rolls with the generated script and, through its policy, requests the
8
+ # smoke, so one deploy leaves a `BoxRoll` or `ServiceRoll` (which also records the smoke's
9
+ # outcome) and a `SmokeRun` in the Hecks database. A failed roll or smoke is the command's
10
+ # exit 1, with the script's status in its message. With no database the command never
11
+ # starts, so the recipe runs the script itself, states the database error apart from the
12
+ # deploy's result, and fails (`Error 24` if all passed).
13
+ module RollRecipe
14
+ module_function
15
+
16
+ # @param plan [Settings::Plan] the resolved settings
17
+ # @return [String] the `deploy` target's recipe, tab-indented: the `box_roll.run` command
18
+ def deploy(plan)
19
+ variable = plan.task_definition ? "TASKDEF" : "TAGS"
20
+ args = plan.task_definition ? "$(if $(TASKDEF),taskdef=$(TASKDEF))" : "$(if $(TAGS),tags=\"$(TAGS)\")"
21
+ direct = "bash ./deploy-box.sh $(#{variable})"
22
+ direct += " || exit $$?; TASKDEF=\"$(TASKDEF)\" bash ./smoke-after-deploy.sh" if plan.hosting
23
+ "\t#{call(command: 'box_roll.run', args: args, direct: direct)}"
24
+ end
25
+
26
+ # @param command [String] the Deploy command, such as `box_roll.run`
27
+ # @param args [String] the Makefile text that turns variables into the arguments
28
+ # @param direct [String] the shell that runs the script when there is no database
29
+ # @return [String] the recipe lines, joined for a Makefile rule
30
+ def call(command:, args:, direct:)
31
+ [*run_lines(command, args), *no_database_lines(direct), *outcome_lines].join("\n\t").freeze
32
+ end
33
+
34
+ def run_lines(command, args)
35
+ ["@err=$$(mktemp); run=deploy-$$(date -u +%Y%m%d%H%M%S); \\",
36
+ "$(HECKS) deploy #{command} project=\"$(CURDIR)\" run=\"$$run\" #{args} \\",
37
+ ' $(if $(SKIP_POST_DEPLOY_SMOKE),skip_smoke=true) --wait 2>"$$err"; rc=$$?; \\']
38
+ end
39
+
40
+ def no_database_lines(direct)
41
+ ['if [ $$rc -ne 0 ] && grep -q \'^cannot open Hecks\' "$$err"; then \\',
42
+ ' echo "==> the deploy record was NOT written: $$(grep -m1 \'^cannot open Hecks\' "$$err")" >&2; \\',
43
+ ' echo " one-time setup on this machine: a database AND a non-superuser role that owns it" >&2; \\',
44
+ ' echo " (a superuser skips the era write-fence, so createdb alone fails), then set" >&2; \\',
45
+ ' echo " HECKS_DATABASE=postgres://<role>@localhost/<db>" >&2; \\',
46
+ ' echo " deploying anyway so the deploy is not lost" >&2; \\',
47
+ " rm -f \"$$err\"; #{direct}; rc=$$?; \\",
48
+ ' echo "==> deploy exit $$rc. The deploy record was NOT written: the database is unavailable (see above)." >&2; \\',
49
+ " [ $$rc -ne 0 ] || rc=24; exit $$rc; \\",
50
+ "fi; \\"]
51
+ end
52
+
53
+ def outcome_lines
54
+ ['cat "$$err" >&2; rm -f "$$err"; exit $$rc']
55
+ end
56
+
57
+ private_class_method :run_lines, :no_database_lines, :outcome_lines
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end