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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/memory/shared_elements.rb +121 -17
- data/lib/hecks/deploy/bluebook/deploy.bluebook +1630 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +65 -0
- data/lib/hecks/deploy/oidc.json +240 -0
- data/lib/hecks/doors/cli_runner.rb +13 -4
- data/lib/hecks/doors/launcher_options.rb +14 -2
- 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 +379 -8
- data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
- data/lib/hecks/hecks/adapters/git.rb +29 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
- data/lib/hecks/hecks/adapters/site_toolchain.rb +8 -8
- data/lib/hecks/hecks/custodian.bluebook +23 -2
- data/lib/hecks/hecks/hecks.hecksagon +66 -0
- data/lib/hecks/hecks/hecks.world +6 -1
- data/lib/hecks/hecks/site.world +4 -2
- data/lib/hecks/projections/deploy/box/hosting.rb +13 -3
- 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 +11 -5
- 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 +26 -13
- 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/runtime/value.rb +7 -0
- data/lib/hecks/site/bluebook/site.bluebook +13 -2
- data/lib/hecks/tools/site_routes.rb +78 -8
- data/lib/hecks/version.rb +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- 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.
|
|
16
|
-
# ask is refused with "needs a hecks 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
|
|
33
|
-
# `
|
|
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
|
|
39
|
-
#
|
|
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
|
-
|
|
42
|
-
|
|
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,
|
|
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`, `
|
|
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 `
|
|
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,
|
|
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
|