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.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +1510 -3
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +57 -0
- data/lib/hecks/deploy/oidc.json +225 -0
- data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
- data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +295 -14
- data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
- data/lib/hecks/hecks/adapters/site_toolchain.rb +3 -3
- data/lib/hecks/hecks/custodian.bluebook +12 -2
- data/lib/hecks/hecks/hecks.hecksagon +57 -0
- data/lib/hecks/hecks/hecks.world +5 -1
- data/lib/hecks/projections/deploy/box/hosting.rb +13 -4
- data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +6 -3
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
- data/lib/hecks/projections/deploy/box.rb +8 -14
- data/lib/hecks/projections/site/admin.rb +140 -0
- data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
- data/lib/hecks/projections/site/admin_cms.rb +72 -0
- data/lib/hecks/projections/site/payload/fields.rb +234 -0
- data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
- data/lib/hecks/projections/site/payload/model.rb +128 -0
- data/lib/hecks/projections/site/payload/specs.rb +179 -0
- data/lib/hecks/projections/site/payload_driver.rb +93 -0
- data/lib/hecks/projections/site/root_rows.rb +59 -0
- data/lib/hecks/projections/site/site_admin.rb +292 -0
- data/lib/hecks/projections/site/site_host/templates.rb +165 -0
- data/lib/hecks/projections/site/site_host.rb +58 -0
- data/lib/hecks/projections/site/site_root.rb +179 -0
- data/lib/hecks/site/bluebook/site.bluebook +7 -1
- data/lib/hecks/tools/site_routes.rb +78 -8
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
end
|
|
412
|
+
return { report: { value: report }, status: code } if answering.include?(code)
|
|
133
413
|
|
|
134
|
-
|
|
414
|
+
raise ConsoleCapture::Failure, "#{label} ended #{code} (#{statuses.fetch(code, 'unexpected')})\n#{report}"
|
|
415
|
+
end
|
|
135
416
|
|
|
136
|
-
# The script a project
|
|
137
|
-
#
|
|
138
|
-
def
|
|
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,
|
|
423
|
+
beside = File.join(root, name)
|
|
143
424
|
return beside if File.file?(beside)
|
|
144
425
|
|
|
145
|
-
found = Dir.glob(File.join(root, "**",
|
|
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 #{
|
|
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} #{
|
|
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`, `
|
|
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,
|
|
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
|
data/lib/hecks/hecks/hecks.world
CHANGED
|
@@ -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"
|
|
37
|
-
"URL"
|
|
38
|
-
"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
|
|
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
|