runsheets 0.0.1

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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +98 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +277 -0
  5. data/bin/runsheets +12 -0
  6. data/examples/about-the-examples.md +40 -0
  7. data/examples/directory_based_runbooks/hello/rollback.md +11 -0
  8. data/examples/directory_based_runbooks/hello/runbook.md +40 -0
  9. data/examples/directory_based_runbooks/hello/steps/010-say-hello.md +33 -0
  10. data/examples/directory_based_runbooks/hello/steps/020-inspect-ruby.md +28 -0
  11. data/examples/directory_based_runbooks/hello/steps/030-take-a-breath.md +11 -0
  12. data/examples/directory_based_runbooks/hello/steps/035-keep-a-clock-running.md +21 -0
  13. data/examples/directory_based_runbooks/hello/steps/040-exercise-failure.md +26 -0
  14. data/examples/directory_based_runbooks/hello/steps/045-check-the-greeting.md +18 -0
  15. data/examples/directory_based_runbooks/hello/verify.md +18 -0
  16. data/examples/directory_based_runbooks/staging-teardown/rollback.md +28 -0
  17. data/examples/directory_based_runbooks/staging-teardown/runbook.md +57 -0
  18. data/examples/directory_based_runbooks/staging-teardown/steps/010-confirm-nothing-to-preserve.md +18 -0
  19. data/examples/directory_based_runbooks/staging-teardown/steps/020-snapshot-the-database.md +47 -0
  20. data/examples/directory_based_runbooks/staging-teardown/steps/030-stop-the-pipeline.md +32 -0
  21. data/examples/directory_based_runbooks/staging-teardown/steps/040-drain-the-ecs-services.md +35 -0
  22. data/examples/directory_based_runbooks/staging-teardown/steps/050-record-the-final-row-counts.md +41 -0
  23. data/examples/directory_based_runbooks/staging-teardown/steps/060-delete-the-stack.md +56 -0
  24. data/examples/directory_based_runbooks/staging-teardown/verify.md +40 -0
  25. data/examples/single_md_file_runbooks/db-maintenance.md +163 -0
  26. data/examples/single_md_file_runbooks/disk-space-triage.md +90 -0
  27. data/examples/single_md_file_runbooks/disk-usage-glossary.md +48 -0
  28. data/examples/single_md_file_runbooks/policies/cleanup-policy.md +33 -0
  29. data/examples/single_md_file_runbooks/policies/images/triage-flow.svg +44 -0
  30. data/examples/single_md_file_runbooks/runbooks_with_scripts/.loki +17 -0
  31. data/examples/single_md_file_runbooks/runbooks_with_scripts/asgard-tasks.md +135 -0
  32. data/examples/single_md_file_runbooks/runbooks_with_scripts/checks.loki +27 -0
  33. data/examples/single_md_file_runbooks/runbooks_with_scripts/greet.loki +6 -0
  34. data/lib/runsheets/assets.rb +780 -0
  35. data/lib/runsheets/block.rb +118 -0
  36. data/lib/runsheets/cli.rb +424 -0
  37. data/lib/runsheets/config/defaults.yml +44 -0
  38. data/lib/runsheets/config.rb +139 -0
  39. data/lib/runsheets/diff.rb +67 -0
  40. data/lib/runsheets/execution.rb +144 -0
  41. data/lib/runsheets/executor.rb +157 -0
  42. data/lib/runsheets/expectation.rb +101 -0
  43. data/lib/runsheets/fences.rb +172 -0
  44. data/lib/runsheets/front_matter.rb +29 -0
  45. data/lib/runsheets/library.rb +268 -0
  46. data/lib/runsheets/pages/chooser.rb +408 -0
  47. data/lib/runsheets/pages/search.rb +119 -0
  48. data/lib/runsheets/pages/tree.rb +88 -0
  49. data/lib/runsheets/pages.rb +947 -0
  50. data/lib/runsheets/redactor.rb +88 -0
  51. data/lib/runsheets/renderer.rb +229 -0
  52. data/lib/runsheets/run.rb +330 -0
  53. data/lib/runsheets/run_record.rb +371 -0
  54. data/lib/runsheets/runbook.rb +301 -0
  55. data/lib/runsheets/search.rb +129 -0
  56. data/lib/runsheets/session.rb +307 -0
  57. data/lib/runsheets/session_log.rb +114 -0
  58. data/lib/runsheets/single_file.rb +93 -0
  59. data/lib/runsheets/step.rb +91 -0
  60. data/lib/runsheets/version.rb +5 -0
  61. data/lib/runsheets/web.rb +537 -0
  62. data/lib/runsheets.rb +90 -0
  63. data/sig/runsheets.rbs +4 -0
  64. metadata +226 -0
@@ -0,0 +1,371 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "monitor"
5
+
6
+ module Runsheets
7
+ # The runsheet: everything that happened to one runbook during one
8
+ # session. The run directory is named by the session id, so a runbook's
9
+ # history stays under its slug and each run points back to its session.
10
+ #
11
+ # <runs_root>/<runbook-slug>/<session-id>/
12
+ # run.json machine-readable record
13
+ # run.md human-readable transcript
14
+ # blocks/
15
+ # <block-id>.<n>.cmd the exact code that ran
16
+ # <block-id>.<n>.out its stdout + stderr, secrets redacted
17
+ #
18
+ # Runs live outside the runbook so captured output never lands next to the
19
+ # docs in a repository.
20
+ class RunRecord
21
+ TIMESTAMP = "%Y%m%dT%H%M%S"
22
+ # How a run ends. A run ends with its session, and its status is worked
23
+ # out from what was done (see #closing_status); "interrupted" is a run
24
+ # whose process was killed, found by the next start. Records from before
25
+ # sessions may say "abandoned".
26
+ STATUSES = %w[running completed partial opened interrupted abandoned].freeze
27
+ FINAL_STATUSES = (STATUSES - ["running"]).freeze
28
+ TRANSCRIPT_TAIL = 64 * 1024
29
+
30
+ attr_reader :dir, :runbook_slug, :runbook_title, :kind, :session_id, :started_at, :finished_at, :status,
31
+ :inputs, :events, :executions, :step_status, :acks
32
+
33
+ # Create the run directory, named by the session, and write the initial
34
+ # record. Secret inputs are never stored.
35
+ def self.start(runs_root, runbook, session_id:, inputs: {}, now: Time.now)
36
+ dir = create_unique_dir(File.join(runs_root, runbook.slug, session_id))
37
+ FileUtils.mkdir_p(File.join(dir, "blocks"))
38
+ new(dir:, runbook_slug: runbook.slug, runbook_title: runbook.title, session_id:, started_at: now,
39
+ inputs: visible_inputs(inputs, runbook)).write!
40
+ end
41
+
42
+ # The inputs as recorded: secret ones left out.
43
+ def self.visible_inputs(inputs, runbook) = inputs.reject { |name, _| runbook.input(name)&.secret? }.transform_keys(&:to_s)
44
+
45
+ # Read a record back from its run.json.
46
+ def self.load(dir)
47
+ data = JSON.parse(File.read(File.join(dir, "run.json")))
48
+ new(
49
+ dir:, runbook_slug: data["runbook"], runbook_title: data["title"], kind: data["kind"] || "run", session_id: data["session"],
50
+ started_at: Time.iso8601(data["started_at"]),
51
+ finished_at: data["finished_at"] && Time.iso8601(data["finished_at"]),
52
+ status: data["status"], inputs: data["inputs"] || {},
53
+ events: (data["events"] || []).map { it.transform_keys(&:to_sym) },
54
+ executions: (data["executions"] || []).map { it.transform_keys(&:to_sym) },
55
+ step_status: data["steps"] || {},
56
+ acks: (data["acks"] || {}).transform_values { it.transform_keys(&:to_sym) }
57
+ )
58
+ end
59
+
60
+ # Previous runs of a runbook, newest first.
61
+ def self.list(runs_root, slug)
62
+ dir = File.join(runs_root, slug)
63
+ return [] unless File.directory?(dir)
64
+
65
+ Dir.children(dir).map { File.join(dir, it, "run.json") }.select { File.file?(it) }.filter_map do |path|
66
+ load(File.dirname(path))
67
+ rescue StandardError # a record that is not one: unreadable, or JSON of the wrong shape
68
+ nil
69
+ end.sort_by(&:started_at).reverse
70
+ end
71
+
72
+ # Create +dir+, or +dir+-2, -3, ... if it exists, and return the one
73
+ # created. Creating is the check, so two callers never share one.
74
+ def self.create_unique_dir(dir)
75
+ FileUtils.mkdir_p(File.dirname(dir))
76
+ candidate = dir
77
+ n = 1
78
+ begin
79
+ Dir.mkdir(candidate)
80
+ candidate
81
+ rescue Errno::EEXIST
82
+ n += 1
83
+ candidate = "#{dir}-#{n}"
84
+ retry
85
+ end
86
+ end
87
+
88
+ def self.unique_dir(dir)
89
+ return dir unless File.exist?(dir)
90
+
91
+ n = 2
92
+ n += 1 while File.exist?("#{dir}-#{n}")
93
+ "#{dir}-#{n}"
94
+ end
95
+
96
+ # A code fence longer than any run of backticks in +text+, so the text
97
+ # cannot close it.
98
+ def self.fence_for(text) = "`" * [3, (text.to_s.scan(/`+/).map(&:size).max || 0) + 1].max
99
+
100
+ # +text+ as an inline code span that no backtick inside can close.
101
+ def self.code_span(text)
102
+ text = text.to_s.tr("\n", " ")
103
+ ticks = "`" * ((text.scan(/`+/).map(&:size).max || 0) + 1)
104
+ pad = text.start_with?("`") || text.end_with?("`") ? " " : ""
105
+ "#{ticks}#{pad}#{text}#{pad}#{ticks}"
106
+ end
107
+
108
+ # +text+ (a note) as markdown that renders as itself: on one line, with
109
+ # the characters that make HTML or markdown escaped.
110
+ MARKUP = { "&" => "&amp;", "<" => "&lt;", ">" => "&gt;" }.freeze
111
+
112
+ def self.plain(text) = text.to_s.gsub(/\s*\n\s*/, " ").gsub(/[&<>`*_\[\]\\]/) { MARKUP[it] || "\\#{it}" }
113
+
114
+ # Whether an execution hash (as stored in the record) counts as a
115
+ # failure. A background process the operator stopped is not one.
116
+ def self.failure?(hash)
117
+ case hash[:state]
118
+ when "timed_out", "failed" then true
119
+ when "finished" then hash[:exit_status] != 0
120
+ else false
121
+ end
122
+ end
123
+
124
+ def initialize(dir:, runbook_slug:, runbook_title:, started_at:, kind: "run", session_id: nil, inputs: {}, status: "running",
125
+ finished_at: nil, events: [], executions: [], step_status: {}, acks: {})
126
+ @dir = dir
127
+ @runbook_slug = runbook_slug
128
+ @runbook_title = runbook_title
129
+ @kind = kind
130
+ @session_id = session_id
131
+ @started_at = started_at
132
+ @finished_at = finished_at
133
+ @status = status
134
+ @inputs = inputs
135
+ @events = events
136
+ @executions = executions
137
+ @step_status = step_status
138
+ @acks = acks
139
+ @live = {}
140
+ @lock = Monitor.new
141
+ end
142
+
143
+ def id = File.basename(dir)
144
+ def active? = status == "running"
145
+ def completed? = status == "completed"
146
+ # A verification run, from before sessions (they no longer exist).
147
+ def verify? = kind == "verify"
148
+ def blocks_dir = File.join(dir, "blocks")
149
+ def duration = (finished_at || Time.now) - started_at
150
+
151
+ # [cmd path, log path] for the next execution of a block.
152
+ def paths_for(block)
153
+ n = executions.count { it[:block_id] == block.id } + 1
154
+ base = File.join(blocks_dir, "#{block.id}.#{n}")
155
+ ["#{base}.cmd", "#{base}.out"]
156
+ end
157
+
158
+ # +confirmed+ records that the operator typed the destructive
159
+ # confirmation before this execution.
160
+ def record_execution(execution, step:, confirmed: false, at: Time.now)
161
+ event = { type: "execute", at: at.iso8601(3), step: step.slug, block: execution.block_id, execution: execution.id }
162
+ event[:confirmed] = true if confirmed
163
+ @lock.synchronize do
164
+ events << event
165
+ @live[execution.id] = execution
166
+ executions << execution.to_h.merge(step: step.slug)
167
+ end
168
+ self
169
+ end
170
+
171
+ def mark_step(step, status:, note: nil, at: Time.now)
172
+ @lock.synchronize do
173
+ events << { type: "step", at: at.iso8601(3), step: step.slug, status:, note: }.compact
174
+ step_status[step.slug] = status
175
+ end
176
+ self
177
+ end
178
+
179
+ # The operator confirms they ran a terminal block themselves.
180
+ def acknowledge(block, step:, note: nil, at: Time.now)
181
+ ack = { at: at.iso8601(3), step: step.slug, note: }.compact
182
+ @lock.synchronize do
183
+ events << { type: "ack", block: block.id, **ack }
184
+ acks[block.id] = ack
185
+ end
186
+ self
187
+ end
188
+
189
+ # The inputs changed partway: recorded as an event, secrets left out.
190
+ def change_inputs(inputs, runbook, at: Time.now)
191
+ visible = RunRecord.visible_inputs(inputs, runbook)
192
+ @lock.synchronize do
193
+ events << { type: "inputs", at: at.iso8601(3), inputs: visible }
194
+ @inputs = visible
195
+ end
196
+ self
197
+ end
198
+
199
+ def finish!(status: "completed", at: Time.now)
200
+ raise ArgumentError, "unknown status #{status}" unless FINAL_STATUSES.include?(status)
201
+
202
+ @lock.synchronize do
203
+ @status = status
204
+ @finished_at = at
205
+ write!
206
+ end
207
+ end
208
+
209
+ # How this run ends, given the slugs of the runbook's numbered steps:
210
+ # completed when every one was marked done or skipped, partial when
211
+ # anything at all was done, opened when nothing was.
212
+ def closing_status(step_slugs)
213
+ return "completed" if step_slugs.any? && (step_slugs - step_status.keys).empty?
214
+ return "partial" if executions.any? || step_status.any? || acks.any?
215
+
216
+ "opened"
217
+ end
218
+
219
+ def failed_executions = executions.select { RunRecord.failure?(it) }
220
+ def steps_done = step_status.count { |_, s| s == "done" }
221
+
222
+ # The most recent execution of each block, keyed by block id.
223
+ def latest_executions = executions.to_h { [it[:block_id], it] }
224
+
225
+ # Blocks whose most recent execution failed. A failure that was re-run
226
+ # successfully does not count.
227
+ def unresolved_failures = latest_executions.values.select { RunRecord.failure?(it) }
228
+
229
+ # The step the run was last working on: the step of the latest event.
230
+ def last_step = events.reverse_each.find { it[:step] }&.[](:step)
231
+
232
+ # How the runbook has drifted since this run: for the latest execution
233
+ # of each block, whether the block's code is still what ran. Returns
234
+ # entries only for blocks that changed or disappeared.
235
+ def drift(runbook)
236
+ latest_executions.filter_map do |block_id, hash|
237
+ recorded = read_block_file(hash[:cmd])
238
+ _, block = runbook.find_block(block_id)
239
+ if block.nil?
240
+ { block_id:, status: :missing, recorded: }
241
+ elsif Diff.changed?(recorded, block.code)
242
+ { block_id:, status: :changed, recorded:, current: block.code, diff: Diff.lines(recorded, block.code) }
243
+ end
244
+ end
245
+ end
246
+
247
+ def execution(id) = @live[id] || executions.find { it[:id] == id }
248
+
249
+ # Pull the latest state from live executions into their hashes.
250
+ def refresh!
251
+ executions.each do |hash|
252
+ live = @live[hash[:id]]
253
+ hash.merge!(live.to_h) if live
254
+ end
255
+ self
256
+ end
257
+
258
+ def write!
259
+ @lock.synchronize do
260
+ refresh!
261
+ Runsheets.write_atomic(File.join(dir, "run.json"), JSON.pretty_generate(to_h))
262
+ Runsheets.write_atomic(File.join(dir, "run.md"), transcript)
263
+ end
264
+ self
265
+ end
266
+
267
+ def to_h
268
+ {
269
+ runbook: runbook_slug, title: runbook_title, id:, session: session_id, kind:, status:,
270
+ started_at: started_at.iso8601(3), finished_at: finished_at&.iso8601(3),
271
+ duration: duration.round(3), inputs:, steps: step_status, acks:, events:, executions:
272
+ }
273
+ end
274
+
275
+ def summary
276
+ { id:, kind:, started_at:, finished_at:, status:, duration:, executions: executions.size,
277
+ failures: failed_executions.size, unresolved: unresolved_failures.size, steps_done:,
278
+ last_step: }
279
+ end
280
+
281
+ # The human-readable run.md.
282
+ def transcript
283
+ lines = transcript_header
284
+ lines << "" << "## Timeline" << ""
285
+ events.each { lines.concat(event_lines(it)) }
286
+ "#{lines.join("\n")}\n"
287
+ end
288
+
289
+ private
290
+
291
+ def transcript_header
292
+ lines = ["# #{runbook_title} — #{verify? ? 'verification' : 'run'} #{id}", "",
293
+ "- Runbook: `#{runbook_slug}`"]
294
+ lines << "- Session: `#{session_id}`" if session_id
295
+ lines << "- Kind: verification (verify documents only)" if verify?
296
+ lines.push("- Started: #{started_at.iso8601}",
297
+ "- Finished: #{finished_at&.iso8601 || 'in progress'}",
298
+ "- Status: #{status}")
299
+ return lines if inputs.empty?
300
+
301
+ lines << "- Inputs:"
302
+ inputs.each { |k, v| lines << " - #{RunRecord.code_span(k)} = #{RunRecord.code_span(v)}" }
303
+ lines
304
+ end
305
+
306
+ # The transcript lines for one event, ending with a blank line.
307
+ def event_lines(event)
308
+ case event[:type]
309
+ when "execute" then execution_transcript(event)
310
+ when "step" then ["- #{event[:at]} step #{RunRecord.code_span(event[:step])} marked #{event[:status]}#{note_suffix(event)}", ""]
311
+ when "ack" then ["- #{event[:at]} `#{event[:block]}` (#{event[:step]}) confirmed run in the operator's terminal#{note_suffix(event)}", ""]
312
+ when "inputs" then ["- #{event[:at]} inputs changed: #{event[:inputs].map do |k, v|
313
+ "#{RunRecord.code_span(k)} = #{RunRecord.code_span(v)}"
314
+ end.join(', ')}", ""]
315
+ else []
316
+ end
317
+ end
318
+
319
+ def note_suffix(event) = event[:note] ? " — #{RunRecord.plain(event[:note])}" : ""
320
+
321
+ def execution_transcript(event)
322
+ hash = executions.find { it[:id] == event[:execution] } || {}
323
+ lines = [execution_title(event, hash), ""]
324
+ code = read_block_file(hash[:cmd]).chomp
325
+ fence = RunRecord.fence_for(code)
326
+ lines << "#{fence}#{command_lang(hash)}" << code << fence << ""
327
+ output = read_block_file(hash[:log], tail: TRANSCRIPT_TAIL)
328
+ fence = RunRecord.fence_for(output)
329
+ lines << "Output:" << "" << "#{fence}text" << output.chomp << fence << "" unless output.empty?
330
+ lines
331
+ end
332
+
333
+ def execution_title(event, hash)
334
+ title = "### #{event[:at]} `#{event[:block]}` (#{event[:step]}) — #{verdict_for(hash)}"
335
+ title += " in #{hash[:duration]}s" if hash[:duration]
336
+ tags = execution_tags(event, hash)
337
+ tags.empty? ? title : "#{title} [#{tags.join(', ')}]"
338
+ end
339
+
340
+ def verdict_for(hash)
341
+ case hash[:state]
342
+ when "finished" then hash[:exit_status] == 0 ? "ok" : "exit #{hash[:exit_status]}"
343
+ when "timed_out" then "timed out"
344
+ when "stopped" then "stopped"
345
+ when "failed" then "failed to start: #{hash[:error]}"
346
+ else hash[:state].to_s
347
+ end
348
+ end
349
+
350
+ def execution_tags(event, hash)
351
+ tags = []
352
+ tags << "background" if hash[:background]
353
+ tags << "confirmed" if event[:confirmed]
354
+ tags
355
+ end
356
+
357
+ def command_lang(hash) = File.basename(Array(hash[:command]).first.to_s)
358
+
359
+ def read_block_file(name, tail: nil)
360
+ return "" unless name
361
+
362
+ path = File.join(blocks_dir, name)
363
+ return "" unless File.file?(path)
364
+
365
+ size = File.size(path)
366
+ return File.binread(path).force_encoding("UTF-8").scrub unless tail && size > tail
367
+
368
+ "[… #{size - tail} earlier bytes omitted]\n#{File.binread(path, tail, size - tail).force_encoding('UTF-8').scrub}"
369
+ end
370
+ end
371
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # A runbook. Either a directory:
5
+ #
6
+ # runbook.md front matter + preamble (required)
7
+ # steps/*.md ordered by filename
8
+ # verify.md whole-procedure checks (optional)
9
+ # rollback.md what to do when it fails partway (optional)
10
+ # assets/ images referenced by the markdown
11
+ #
12
+ # or a single markdown file whose `## Headings` are the steps (see
13
+ # SingleFile). Everything downstream sees the same object either way.
14
+ class Runbook
15
+ Input = Data.define(:name, :prompt, :default, :secret) do
16
+ def self.from(hash)
17
+ hash = hash.transform_keys(&:to_s)
18
+ new(name: hash["name"].to_s, prompt: hash["prompt"] || hash["name"].to_s,
19
+ default: hash["default"]&.to_s, secret: hash["secret"] == true)
20
+ end
21
+
22
+ def secret? = secret
23
+ def valid? = name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
24
+ end
25
+
26
+ MAIN_FILE = "runbook.md"
27
+ STEPS_DIR = "steps"
28
+ EXTRAS = %w[verify rollback].freeze
29
+
30
+ attr_reader :dir, :root, :slug, :main_path, :data, :landing, :steps, :extras, :inputs, :warnings, :loaded_at
31
+
32
+ # Load a runbook directory or a single-file runbook. The slug names the
33
+ # runbook in run records and URLs: the directory or file name, unless
34
+ # +slug:+ says otherwise (a Library passes the path inside it). +root:+
35
+ # is the directory relative links resolve against and /files/ and /docs/
36
+ # serve from: the library when the runbook is in one, else the runbook's
37
+ # own directory.
38
+ def self.load(path, slug: nil, root: nil)
39
+ path = File.expand_path(path)
40
+ if File.directory?(path)
41
+ new(dir: path, main_path: File.join(path, MAIN_FILE), slug: slug || File.basename(path), single_file: false, **{ root: }.compact)
42
+ elsif File.file?(path)
43
+ slug ||= File.basename(path, ".*")
44
+ slug = File.basename(File.dirname(path)) if slug == File.basename(MAIN_FILE, ".*")
45
+ new(dir: File.dirname(path), main_path: path, slug:, single_file: true, **{ root: }.compact)
46
+ else
47
+ raise RunbookError, "no such runbook: #{path}"
48
+ end
49
+ end
50
+
51
+ # Is the markdown file at +path+ a runbook rather than a plain document?
52
+ # A file that cannot be read is neither, and is left out.
53
+ def self.runbook_file?(path)
54
+ runbook_text?(File.read(path, encoding: "BOM|UTF-8"))
55
+ rescue SystemCallError
56
+ false
57
+ end
58
+
59
+ # Is +path+ a plain document under +root+: an existing markdown file
60
+ # that is not a runbook and not one of a runbook directory's files
61
+ # (no runbook.md in its directory or any directory above it, up to
62
+ # +root+)?
63
+ def self.plain_document?(path, root)
64
+ path = File.expand_path(path)
65
+ root = File.expand_path(root)
66
+ return false unless File.file?(path) && path.start_with?("#{root}/") && !runbook_file?(path)
67
+
68
+ dirs = Pathname(File.dirname(path)).ascend.take_while { it.to_s.start_with?(root) }
69
+ dirs.none? { File.file?(it.join(MAIN_FILE)) }
70
+ end
71
+
72
+ # A runbook starts with YAML front matter that has a title; anything
73
+ # else is a plain document. Front matter that does not parse counts as
74
+ # a runbook: it was meant to be one, and loading it reports why not.
75
+ def self.runbook_text?(text)
76
+ !FrontMatter.parse(text).data["title"].to_s.strip.empty?
77
+ rescue RunbookError
78
+ true
79
+ end
80
+
81
+ def initialize(dir:, main_path:, slug:, single_file:, root: dir)
82
+ @dir = dir
83
+ @root = File.expand_path(root)
84
+ @main_path = main_path
85
+ @slug = slug
86
+ @single_file = single_file
87
+ raise RunbookError, "#{MAIN_FILE} not found in #{dir}" unless File.file?(main_path)
88
+
89
+ @loaded_at = Time.now
90
+ parsed = FrontMatter.parse(File.read(main_path, encoding: "BOM|UTF-8"))
91
+ @data = parsed.data
92
+ raise RunbookError, "#{File.basename(main_path)} is not a runbook: it needs YAML front matter with a title" if data["title"].to_s.strip.empty?
93
+
94
+ Runbook.check_shapes(data, File.basename(main_path))
95
+ @section_warnings = []
96
+ if single_file
97
+ load_single_file(parsed.body)
98
+ else
99
+ @landing = Step.new(slug: "runbook", text: parsed.body, path: main_path, root: @root, interpreters:)
100
+ @steps = load_steps
101
+ @extras = load_extras
102
+ end
103
+ @inputs = Array(data["inputs"]).map { Input.from(it) }.freeze
104
+ @warnings = validate.freeze
105
+ @source_count = source_files.size
106
+ end
107
+
108
+ # Raise RunbookError unless the front matter's structured keys have the
109
+ # shape the rest of runsheets reads: +inputs+ a list of mappings, and
110
+ # +interpreters+ a mapping of language to command string. +file+ names
111
+ # the file in the message.
112
+ def self.check_shapes(data, file)
113
+ inputs = data["inputs"]
114
+ unless inputs.nil? || (inputs.is_a?(Array) && inputs.all?(Hash))
115
+ raise RunbookError, "#{file}: inputs must be a list of mappings, each with a name (- name: REGION)"
116
+ end
117
+
118
+ interpreters = data["interpreters"]
119
+ return if interpreters.nil? || (interpreters.is_a?(Hash) && interpreters.each_value.all?(String))
120
+
121
+ raise RunbookError, "#{file}: interpreters must map a language to a command (sql: psql -f)"
122
+ end
123
+
124
+ def single_file? = @single_file
125
+
126
+ def title = (data["title"] || slug).to_s
127
+ def when_to_use = data["when_to_use"]
128
+ def prerequisites = Array(data["prerequisites"])
129
+ def blast_radius = data["blast_radius"]
130
+ def escalation = data["escalation"]
131
+ def tags = Array(data["tags"]).map(&:to_s)
132
+ def preamble_html = landing.html
133
+
134
+ def verify = extras["verify"]
135
+ def rollback = extras["rollback"]
136
+
137
+ def destructive? = !blast_radius.nil? || steps.any?(&:destructive?)
138
+
139
+ # The checks the Checks page gathers: verify-kind steps in order, then
140
+ # verify.md.
141
+ def verify_documents = [*steps.select(&:verify?), verify].compact
142
+
143
+ def verify_document?(step) = verify_documents.include?(step)
144
+
145
+ def verify_blocks = verify_documents.flat_map(&:executable_blocks)
146
+
147
+ # The checks "Run all" runs: plain run blocks. A destructive check needs
148
+ # its code typed and a background one never finishes, so both are left
149
+ # for the operator to run by hand.
150
+ def runnable_checks = verify_blocks.select { it.kind == :run }
151
+
152
+ # The markdown files this runbook was built from (those that exist).
153
+ def source_files
154
+ return [main_path].select { File.file?(it) } if single_file?
155
+
156
+ [main_path, *Dir.glob(File.join(dir, STEPS_DIR, "*.md")), *EXTRAS.map { File.join(dir, "#{it}.md") }].select { File.file?(it) }
157
+ end
158
+
159
+ # True once any source file has been written, added or removed since
160
+ # this runbook was loaded.
161
+ def stale?
162
+ files = source_files
163
+ return true if files.size != @source_count
164
+
165
+ files.any? { File.mtime(it) > loaded_at }
166
+ rescue SystemCallError # a file vanished between listing and reading
167
+ true
168
+ end
169
+
170
+ # A reload was tried and failed: count the files as seen, so the next
171
+ # attempt waits for another edit rather than retrying on every page.
172
+ def checked!
173
+ @loaded_at = Time.now
174
+ @source_count = source_files.size rescue @source_count
175
+ self
176
+ end
177
+
178
+ # Every document that can be shown as a page, keyed by slug.
179
+ def documents = @documents ||= [landing, *steps, *extras.values].to_h { [it.slug, it] }
180
+
181
+ def step(slug) = documents[slug]
182
+
183
+ # The document read from the file at +path+, or nil. In a single-file
184
+ # runbook every document shares the file, so this is the landing page.
185
+ def document_at(path)
186
+ real = Runbook.real_path(path)
187
+ documents.each_value.find { it.path && Runbook.real_path(it.path) == real }
188
+ end
189
+
190
+ def self.real_path(path)
191
+ File.realpath(path)
192
+ rescue SystemCallError
193
+ File.expand_path(path)
194
+ end
195
+
196
+ # [step, block] for a block id, or nil.
197
+ def find_block(id)
198
+ documents.each_value do |step|
199
+ block = step.block(id)
200
+ return [step, block] if block
201
+ end
202
+ nil
203
+ end
204
+
205
+ # Previous and next numbered steps around a document (nil at the ends or
206
+ # for documents that are not numbered steps).
207
+ def neighbors(step)
208
+ index = steps.index(step)
209
+ return [nil, nil] unless index
210
+
211
+ [index.positive? ? steps[index - 1] : nil, steps[index + 1]]
212
+ end
213
+
214
+ # Language => command that runs a file of that language: the defaults
215
+ # plus whatever the front matter adds or overrides.
216
+ def interpreters
217
+ @interpreters ||= Block::INTERPRETERS.merge(
218
+ (data["interpreters"] || {}).to_h { |lang, command| [lang.to_s, Shellwords.split(command.to_s)] }
219
+ ).freeze
220
+ end
221
+
222
+ def interpreter_for(lang) = interpreters[lang]
223
+
224
+ def input(name) = inputs.find { it.name == name }
225
+
226
+ def to_h
227
+ { slug:, dir:, main_path:, single_file: single_file?, title:, when_to_use:, prerequisites:, blast_radius:, escalation:,
228
+ tags:, inputs: inputs.map(&:to_h),
229
+ steps: steps.map(&:to_h), extras: extras.keys, warnings: }
230
+ end
231
+
232
+ private
233
+
234
+ def load_steps
235
+ steps_dir = File.join(dir, STEPS_DIR)
236
+ return [] unless File.directory?(steps_dir)
237
+
238
+ names = Dir.glob("*.md", base: steps_dir).sort_by { [it[/\A\d+/].to_i, it] }
239
+ names.map.with_index(1) { |name, position| Step.load(File.join(steps_dir, name), root:, position:, interpreters:) }
240
+ end
241
+
242
+ def load_extras
243
+ EXTRAS.filter_map do |name|
244
+ path = File.join(dir, "#{name}.md")
245
+ [name, Step.load(path, root:, interpreters:)] if File.file?(path)
246
+ end.to_h
247
+ end
248
+
249
+ # Preamble up to the first ## heading; each heading is a step, except
250
+ # the Verify and Rollback sections which become the extras.
251
+ def load_single_file(body)
252
+ preamble, sections = SingleFile.split(body)
253
+ @landing = Step.new(slug: "runbook", text: preamble, path: main_path, root:, interpreters:)
254
+ @steps = []
255
+ @extras = {}
256
+ sections.each { add_section(it) }
257
+ @extras = EXTRAS.filter_map { [it, @extras[it]] if @extras[it] }.to_h
258
+ end
259
+
260
+ def add_section(section)
261
+ section.warnings.each { section_warning(section, it) }
262
+ section.extra? ? add_extra(section) : add_step(section)
263
+ end
264
+
265
+ def add_step(section)
266
+ position = @steps.size + 1
267
+ @steps << section_step(section, slug: SingleFile.slug_for(section.title, position), position:)
268
+ end
269
+
270
+ def add_extra(section)
271
+ role = section.role
272
+ return section_warning(section, "unknown role '#{role}'; expected verify or rollback") unless EXTRAS.include?(role)
273
+ return section_warning(section, "a second #{role} section; the first one is used") if @extras.key?(role)
274
+
275
+ @extras[role] = section_step(section, slug: role)
276
+ end
277
+
278
+ def section_step(section, slug:, position: nil)
279
+ data = { "title" => section.title }.merge(section.data)
280
+ Step.new(slug:, text: section.body, path: main_path, root:, position:, data:, interpreters:)
281
+ end
282
+
283
+ def section_warning(section, message)
284
+ @section_warnings << "section '#{section.title}' (line #{section.line}): #{message}"
285
+ end
286
+
287
+ def validate
288
+ warnings = []
289
+ warnings << (single_file? ? "no steps found: add a ## heading per step" : "no steps found in #{STEPS_DIR}/") if steps.empty?
290
+ inputs.reject(&:valid?).each { warnings << "input name '#{it.name}' is not a valid environment variable name" }
291
+ dupes = steps.map(&:slug).tally.select { |_, n| n > 1 }.keys
292
+ warnings << "duplicate step slugs: #{dupes.join(', ')}" if dupes.any?
293
+ steps.map(&:slug).intersection(%w[runbook verify rollback]).each { warnings << "step #{it} has the name of a special document; give it a number" }
294
+ warnings.concat(@section_warnings)
295
+ documents.each_value do |step|
296
+ step.warnings.each { warnings << "#{step.slug}: #{it}" }
297
+ end
298
+ warnings
299
+ end
300
+ end
301
+ end