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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +98 -0
- data/LICENSE.txt +21 -0
- data/README.md +277 -0
- data/bin/runsheets +12 -0
- data/examples/about-the-examples.md +40 -0
- data/examples/directory_based_runbooks/hello/rollback.md +11 -0
- data/examples/directory_based_runbooks/hello/runbook.md +40 -0
- data/examples/directory_based_runbooks/hello/steps/010-say-hello.md +33 -0
- data/examples/directory_based_runbooks/hello/steps/020-inspect-ruby.md +28 -0
- data/examples/directory_based_runbooks/hello/steps/030-take-a-breath.md +11 -0
- data/examples/directory_based_runbooks/hello/steps/035-keep-a-clock-running.md +21 -0
- data/examples/directory_based_runbooks/hello/steps/040-exercise-failure.md +26 -0
- data/examples/directory_based_runbooks/hello/steps/045-check-the-greeting.md +18 -0
- data/examples/directory_based_runbooks/hello/verify.md +18 -0
- data/examples/directory_based_runbooks/staging-teardown/rollback.md +28 -0
- data/examples/directory_based_runbooks/staging-teardown/runbook.md +57 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/010-confirm-nothing-to-preserve.md +18 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/020-snapshot-the-database.md +47 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/030-stop-the-pipeline.md +32 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/040-drain-the-ecs-services.md +35 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/050-record-the-final-row-counts.md +41 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/060-delete-the-stack.md +56 -0
- data/examples/directory_based_runbooks/staging-teardown/verify.md +40 -0
- data/examples/single_md_file_runbooks/db-maintenance.md +163 -0
- data/examples/single_md_file_runbooks/disk-space-triage.md +90 -0
- data/examples/single_md_file_runbooks/disk-usage-glossary.md +48 -0
- data/examples/single_md_file_runbooks/policies/cleanup-policy.md +33 -0
- data/examples/single_md_file_runbooks/policies/images/triage-flow.svg +44 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/.loki +17 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/asgard-tasks.md +135 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/checks.loki +27 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/greet.loki +6 -0
- data/lib/runsheets/assets.rb +780 -0
- data/lib/runsheets/block.rb +118 -0
- data/lib/runsheets/cli.rb +424 -0
- data/lib/runsheets/config/defaults.yml +44 -0
- data/lib/runsheets/config.rb +139 -0
- data/lib/runsheets/diff.rb +67 -0
- data/lib/runsheets/execution.rb +144 -0
- data/lib/runsheets/executor.rb +157 -0
- data/lib/runsheets/expectation.rb +101 -0
- data/lib/runsheets/fences.rb +172 -0
- data/lib/runsheets/front_matter.rb +29 -0
- data/lib/runsheets/library.rb +268 -0
- data/lib/runsheets/pages/chooser.rb +408 -0
- data/lib/runsheets/pages/search.rb +119 -0
- data/lib/runsheets/pages/tree.rb +88 -0
- data/lib/runsheets/pages.rb +947 -0
- data/lib/runsheets/redactor.rb +88 -0
- data/lib/runsheets/renderer.rb +229 -0
- data/lib/runsheets/run.rb +330 -0
- data/lib/runsheets/run_record.rb +371 -0
- data/lib/runsheets/runbook.rb +301 -0
- data/lib/runsheets/search.rb +129 -0
- data/lib/runsheets/session.rb +307 -0
- data/lib/runsheets/session_log.rb +114 -0
- data/lib/runsheets/single_file.rb +93 -0
- data/lib/runsheets/step.rb +91 -0
- data/lib/runsheets/version.rb +5 -0
- data/lib/runsheets/web.rb +537 -0
- data/lib/runsheets.rb +90 -0
- data/sig/runsheets.rbs +4 -0
- 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 = { "&" => "&", "<" => "<", ">" => ">" }.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
|