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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # Full-text search over runbooks. A query is words and "quoted phrases",
5
+ # matched case-insensitively; a runbook matches when every one of them
6
+ # appears somewhere in it: its title, its front matter text, or any of
7
+ # its documents (the preamble, the steps, verify and rollback), code
8
+ # included. Plain documents are not searched.
9
+ #
10
+ # Every function takes the pieces it needs and returns plain data, so
11
+ # each can be tested on its own.
12
+ module Search
13
+ # One runbook that matched: +hits+ are the documents that hold at least
14
+ # one term, best first.
15
+ Result = Data.define(:runbook, :slug, :score, :hits)
16
+
17
+ # One document of a runbook (the preamble, a step, verify or rollback)
18
+ # that holds a term.
19
+ Hit = Data.define(:step, :title, :snippet, :score)
20
+
21
+ # How much a term found in each place counts toward a runbook's rank.
22
+ WEIGHTS = { title: 10, meta: 5, step_title: 3, body: 1 }.freeze
23
+
24
+ # Characters of context kept on each side of a match in a snippet.
25
+ CONTEXT = 70
26
+
27
+ # A parsed query: its terms, and what can be asked of a text about them.
28
+ Query = Data.define(:words) do
29
+ def self.parse(query) = new(words: Search.terms(query))
30
+
31
+ def empty? = words.empty?
32
+
33
+ # Does +text+ (already lowercased) hold every term?
34
+ def all_in?(text) = words.all? { text.include?(it) }
35
+
36
+ # How many times the terms occur in +text+, all together.
37
+ def count(text)
38
+ haystack = Search.normalize(text)
39
+ words.sum { haystack.scan(it).size }
40
+ end
41
+
42
+ # A line of +text+ around the first match, whitespace collapsed and
43
+ # markdown syntax dropped, with "…" where it was cut. nil when nothing
44
+ # matches.
45
+ def snippet(text, context: CONTEXT)
46
+ flat = Search.plain(text).gsub(/\s+/, " ").strip
47
+ first = words.filter_map { flat =~ Regexp.new(Regexp.escape(it), Regexp::IGNORECASE) }.min
48
+ return nil unless first
49
+
50
+ from = [first - context, 0].max
51
+ to = [first + context, flat.size].min
52
+ "#{'…' if from.positive?}#{flat[from...to].strip}#{'…' if to < flat.size}"
53
+ end
54
+
55
+ # +text+ HTML-escaped, with each term wrapped in <mark>. Longer terms
56
+ # are tried first, so a phrase wins over a word inside it.
57
+ def highlight(text)
58
+ return Renderer.h(text) if empty?
59
+
60
+ pattern = Regexp.union(words.sort_by { -it.size }.map { Regexp.new(Regexp.escape(it), Regexp::IGNORECASE) })
61
+ text.to_s.split(/(#{pattern})/).each_slice(2).map do |plain, match|
62
+ "#{Renderer.h(plain)}#{"<mark>#{Renderer.h(match)}</mark>" if match}"
63
+ end.join
64
+ end
65
+ end
66
+
67
+ module_function
68
+
69
+ # The terms of a query: "quoted phrases" whole, other words one by one,
70
+ # lowercased, blanks and duplicates dropped.
71
+ def terms(query)
72
+ query.to_s.scan(/"([^"]*)"|(\S+)/).map { |phrase, word| normalize(phrase || word.delete('"')).strip }
73
+ .reject(&:empty?).uniq
74
+ end
75
+
76
+ # Search +runbooks+ (a list of [slug, Runbook] pairs) for the query
77
+ # string. Results are best first; ties go by title.
78
+ def run(runbooks, query)
79
+ query = Query.parse(query)
80
+ return [] if query.empty?
81
+
82
+ runbooks.filter_map { |slug, runbook| result_for(slug, runbook, query) }
83
+ .sort_by { [-it.score, it.runbook.title.downcase] }
84
+ end
85
+
86
+ # The Result for one runbook, or nil when some term appears nowhere in it.
87
+ def result_for(slug, runbook, query)
88
+ return nil unless query.all_in?(text_of(runbook))
89
+
90
+ hits = hits_for(runbook, query)
91
+ score = (query.count(runbook.title) * WEIGHTS[:title]) + (query.count(meta_text(runbook)) * WEIGHTS[:meta]) + hits.sum(&:score)
92
+ Result.new(runbook:, slug:, score:, hits:)
93
+ end
94
+
95
+ # Everything searchable in a runbook, lowercased, as one string.
96
+ def text_of(runbook)
97
+ normalize([runbook.title, meta_text(runbook), *runbook.documents.values.flat_map { [it.title, it.body] }].join("\n"))
98
+ end
99
+
100
+ # The runbook's front matter prose: what an engineer reads before the
101
+ # first step.
102
+ def meta_text(runbook)
103
+ [runbook.when_to_use, *runbook.prerequisites, runbook.blast_radius, runbook.escalation, *runbook.tags,
104
+ *runbook.inputs.flat_map { [it.name, it.prompt] }].compact.join("\n")
105
+ end
106
+
107
+ # The documents that hold a term, best first, each with a snippet.
108
+ def hits_for(runbook, query)
109
+ hits = runbook.documents.values.filter_map do |doc|
110
+ score = (query.count(doc.title) * WEIGHTS[:step_title]) + (query.count(doc.body) * WEIGHTS[:body])
111
+ next if score.zero?
112
+
113
+ Hit.new(step: doc, title: doc.title, snippet: query.snippet(doc.body) || doc.title, score:)
114
+ end
115
+ hits.sort_by { -it.score }
116
+ end
117
+
118
+ # Text as search compares it: whitespace runs (line breaks included) as
119
+ # one space, case folded, so a phrase matches across a wrapped line.
120
+ def normalize(text) = text.to_s.gsub(/\s+/, " ").downcase(:fold)
121
+
122
+ # Markdown reduced to the words a reader sees: link text without its
123
+ # target, no fence lines, heading marks or HTML comments.
124
+ def plain(text)
125
+ text.to_s.gsub(/<!--.*?-->/m, " ").gsub(/^\s*(```|~~~).*$/, " ").gsub(/^\s*\#{1,6}\s+/, "")
126
+ .gsub(/!?\[([^\]]*)\]\([^)]*\)/, '\1')
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "socket"
5
+
6
+ module Runsheets
7
+ # The engineering session: everything that happens between `runsheets`
8
+ # starting and stopping. It belongs to one engineer and opens with a note
9
+ # saying why. Selecting a runbook establishes that runbook's Run; the
10
+ # engineer can switch between runbooks freely, and every run stays open
11
+ # until the session ends.
12
+ #
13
+ # <runs_root>/sessions/<session-id>/
14
+ # session.json engineer, host, times, status, notes, the runs
15
+ # session.log every action and its output, appended as it happens
16
+ #
17
+ # The runbook on screen is #runbook; the methods the pages and routes call
18
+ # for "this runbook" (execute, mark_step, run, ...) go to its run.
19
+ class Session
20
+ ConfirmationRequired = Run::ConfirmationRequired
21
+
22
+ SESSIONS = "sessions"
23
+ STATUSES = %w[running ended interrupted].freeze
24
+
25
+ attr_reader :engineer, :started_at, :ended_at, :notes, :runs_root, :library, :token, :executor, :log, :dir, :runbook
26
+
27
+ # Start a session: create its directory and log, close any earlier
28
+ # session left running by a killed process, and record +why+ as the
29
+ # first note. +runbook+ is the one runbook served, when there is no
30
+ # library. +echo+ is the IO the log is echoed to (nil for none).
31
+ def initialize(engineer:, why:, runs_root: Runsheets.runs_dir, library: nil, runbook: nil, token: SecureRandom.hex(16),
32
+ executor: Executor.new, log_level: "info", echo: nil, now: Time.now)
33
+ raise RunError, "who is starting the session? An engineer name is needed" if engineer.to_s.strip.empty?
34
+ raise RunError, "why is the session being started? A note is needed" if why.to_s.strip.empty?
35
+
36
+ @engineer = engineer.to_s.strip
37
+ @runs_root = runs_root
38
+ @library = library
39
+ @token = token
40
+ @executor = executor
41
+ @started_at = now
42
+ @notes = []
43
+ @runs = {}
44
+ @mutex = Mutex.new
45
+ @ending = false
46
+ @dir = RunRecord.create_unique_dir(File.join(Session.root(runs_root), now.strftime(RunRecord::TIMESTAMP)))
47
+ @log = SessionLog.new(File.join(@dir, "session.log"), level: log_level, echo:)
48
+ start(why, Session.close_interrupted(runs_root, except: @dir))
49
+ show(runbook) if runbook
50
+ end
51
+
52
+ # Where sessions are kept under a runs directory.
53
+ def self.root(runs_root) = File.join(runs_root, SESSIONS)
54
+
55
+ # Close every session (and its runs) still marked running whose process
56
+ # is gone: it was killed, so nothing closed it. A session whose process
57
+ # is still alive on this host is another runsheets, and is left alone,
58
+ # as is the session at +except+ (a directory). Returns the ids closed.
59
+ def self.close_interrupted(runs_root, except: nil)
60
+ paths = Dir.glob(File.join(root(runs_root), "*", "session.json")) - [File.join(except.to_s, "session.json")]
61
+ paths.filter_map do |path|
62
+ data = JSON.parse(File.read(path))
63
+ next unless data.is_a?(Hash) && data["status"] == "running" && !running_here?(data)
64
+
65
+ interrupt(File.dirname(path), data, runs_root:)
66
+ data["id"]
67
+ rescue StandardError # unreadable, or not a session record: leave it
68
+ nil
69
+ end
70
+ end
71
+
72
+ # Mark a killed session, and the runs it lists, interrupted. The end
73
+ # time is the last write to its log.
74
+ def self.interrupt(dir, data, runs_root:)
75
+ root = File.expand_path(runs_root)
76
+ log = File.join(dir, "session.log")
77
+ ended = File.file?(log) ? File.mtime(log) : Time.now
78
+ runs = Array(data["runs"]).grep(Hash)
79
+ runs.each do |run|
80
+ next unless File.expand_path(run["dir"].to_s).start_with?("#{root}/")
81
+
82
+ record = RunRecord.load(run["dir"])
83
+ record.finish!(status: "interrupted", at: ended) if record.active?
84
+ run["status"] = record.status
85
+ rescue StandardError
86
+ next
87
+ end
88
+ Runsheets.write_atomic(File.join(dir, "session.json"),
89
+ JSON.pretty_generate(data.merge("status" => "interrupted", "ended_at" => ended.iso8601(3), "runs" => runs)))
90
+ end
91
+
92
+ # The records of the sessions kept under +runs_root+, newest first,
93
+ # leaving out the one at +except+; unreadable ones are skipped. The
94
+ # first note (the why) is copied into each as "why".
95
+ def self.past(runs_root, except: nil)
96
+ sessions = Dir.glob(File.join(root(runs_root), "*", "session.json")).filter_map do |path|
97
+ next if File.dirname(path) == except
98
+
99
+ data = JSON.parse(File.read(path))
100
+ next unless data.is_a?(Hash)
101
+
102
+ data.merge("why" => Array(data["notes"]).grep(Hash).first&.fetch("text", nil))
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ sessions.sort_by { it["started_at"].to_s }.reverse
107
+ end
108
+
109
+ # Is the session in +data+ still being written by a live process on
110
+ # this host?
111
+ def self.running_here?(data) = data["host"] == Socket.gethostname && alive?(data["pid"])
112
+
113
+ def self.alive?(pid)
114
+ return false unless pid.is_a?(Integer)
115
+
116
+ Process.kill(0, pid)
117
+ true
118
+ rescue Errno::ESRCH
119
+ false
120
+ rescue Errno::EPERM
121
+ true
122
+ end
123
+
124
+ def id = File.basename(dir)
125
+ def host = Socket.gethostname
126
+ def status = ended_at ? "ended" : "running"
127
+ def ended? = !ended_at.nil?
128
+ def why = notes.first&.fetch(:text)
129
+ def elapsed(now = Time.now) = (ended_at || now) - started_at
130
+
131
+ # Add a timestamped note to the session.
132
+ def note!(text, at: Time.now)
133
+ text = text.to_s.strip
134
+ raise RunError, "a note needs some text" if text.empty?
135
+
136
+ @mutex.synchronize do
137
+ notes << { at:, text: }
138
+ write!
139
+ end
140
+ log.info("note: #{text}", tags: ["session"])
141
+ self
142
+ end
143
+
144
+ # --- runbooks and runs ---------------------------------------------
145
+
146
+ # The runs of this session, in the order their runbooks were selected.
147
+ def runs = @runs.values
148
+
149
+ def run_for(slug) = @runs[slug]
150
+
151
+ # The run of the runbook on screen, if it has one.
152
+ def current = runbook && @runs[runbook.slug]
153
+
154
+ # Put +runbook+ on screen without establishing a run. The run it may
155
+ # already have takes the fresh copy.
156
+ def show(runbook)
157
+ @mutex.synchronize do
158
+ @runbook = runbook
159
+ @runs[runbook.slug]&.runbook = runbook
160
+ end
161
+ runbook
162
+ end
163
+
164
+ # Select a runbook: put it on screen and establish its run with
165
+ # +inputs+, or return to the run it already has. Returns the Run.
166
+ def open(runbook, inputs: {})
167
+ show(runbook)
168
+ @mutex.synchronize do
169
+ raise RunError, "this session is ending" if @ending || ended?
170
+
171
+ existing = @runs[runbook.slug]
172
+ return existing if existing
173
+
174
+ run = @runs[runbook.slug] = Run.new(session: self, runbook:, inputs:)
175
+ write!
176
+ run
177
+ end
178
+ end
179
+
180
+ # Non-secret input values given earlier in the session, by name. A run
181
+ # opened later is offered them as defaults.
182
+ def carried_inputs = runs.map { it.record.inputs }.reduce({}, :merge)
183
+
184
+ # Inputs as the start form shows them for the runbook on screen.
185
+ def resolve_inputs(given = {}) = Run.resolve_inputs(runbook, given, carried: carried_inputs)
186
+
187
+ # Reload the runbook on screen if any of its markdown files changed,
188
+ # so an edit is reflected on the next page. Block ids stay stable unless
189
+ # fences are added or removed, so its run carries on. A runbook that no
190
+ # longer loads is kept as it was.
191
+ def refresh_runbook!
192
+ return runbook unless runbook&.stale?
193
+
194
+ log.debug("reloaded #{runbook.slug}: its files changed", tags: ["session"])
195
+ show(Runbook.load(runbook.single_file? ? runbook.main_path : runbook.dir, slug: runbook.slug, root: runbook.root))
196
+ rescue StandardError => e # whatever the edit broke, the page keeps the last good copy
197
+ log.error("#{runbook.slug} no longer loads: #{e.message}", tags: ["session"])
198
+ runbook.checked!
199
+ runbook
200
+ end
201
+
202
+ # --- the runbook on screen ------------------------------------------
203
+
204
+ def active? = !current.nil? && current.open?
205
+
206
+ # The record of the runbook on screen's run.
207
+ def run = current&.record
208
+
209
+ def execute(block_id, confirm: nil, digest: nil) = current!.execute(block_id, confirm:, digest:)
210
+ def acknowledge(block_id, note: nil, digest: nil) = current!.acknowledge(block_id, note:, digest:)
211
+ def mark_step(slug, status:, note: nil) = current!.mark_step(slug, status:, note:)
212
+ def change_inputs(given) = current!.change_inputs(given)
213
+ def step_status(slug) = current&.step_status(slug)
214
+ def ack(block_id) = current&.ack(block_id)
215
+ def secret_inputs_set = current&.secret_inputs_set || []
216
+ def history = RunRecord.list(runs_root, runbook.slug)
217
+
218
+ # The run of the runbook on screen, or a RunError saying there is none.
219
+ def current!
220
+ current or raise RunError, "start the run for #{runbook&.title || 'a runbook'} before executing blocks"
221
+ end
222
+
223
+ # --- executions across every run ------------------------------------
224
+
225
+ def executions = runs.flat_map(&:executions)
226
+ def running_executions = executions.select(&:running?)
227
+
228
+ # [run, execution] for each execution still running, in any runbook.
229
+ def running = runs.flat_map { |run| run.running_executions.map { [run, it] } }
230
+
231
+ def execution(id) = runs.lazy.filter_map { it.execution(id) }.first
232
+
233
+ # The text of the expect block for +execution+'s block, in the runbook
234
+ # of the run that started it, or nil.
235
+ def expected_output(execution)
236
+ run = runs.find { it.execution(execution.id) } or return
237
+ run.runbook.step(execution.step_slug)&.expected_output(execution.block_id)
238
+ end
239
+
240
+ # Stop an execution, whichever runbook started it.
241
+ def stop(execution_id)
242
+ run = runs.find { it.execution(execution_id) } or raise RunError, "no execution #{execution_id}"
243
+ run.stop(execution_id)
244
+ end
245
+
246
+ # --- ending ----------------------------------------------------------
247
+
248
+ # End the session: close every run (stopping what it left running) with
249
+ # the status its work earns, and write the record. +how+ says what ended
250
+ # it, for the log. Ending twice does nothing.
251
+ def end!(how, at: Time.now)
252
+ return self unless begin_ending
253
+
254
+ runs.each(&:stop_running!)
255
+ closed = runs.map { "#{it.slug} #{close_run(it)}" }
256
+ @mutex.synchronize do
257
+ @ended_at = at
258
+ write!
259
+ end
260
+ log.info("ended (#{how}) after #{format('%.0f', elapsed)}s; runs: #{closed.empty? ? 'none' : closed.join(', ')}", tags: ["session"])
261
+ self
262
+ end
263
+
264
+ def to_h
265
+ {
266
+ id:, engineer:, host:, pid: Process.pid, runsheets: VERSION, status:,
267
+ started_at: started_at.iso8601(3), ended_at: ended_at&.iso8601(3),
268
+ library: library&.dir, log: log.path,
269
+ notes: notes.map { { at: it[:at].iso8601(3), text: it[:text] } },
270
+ runs: runs.map { { runbook: it.slug, title: it.runbook.title, dir: it.record.dir, status: it.record.status } }
271
+ }
272
+ end
273
+
274
+ def write!
275
+ Runsheets.write_atomic(File.join(dir, "session.json"), JSON.pretty_generate(to_h))
276
+ self
277
+ end
278
+
279
+ private
280
+
281
+ # Claim the ending: true for the first caller only. From here on no run
282
+ # is opened.
283
+ def begin_ending
284
+ @mutex.synchronize do
285
+ return false if @ending || ended?
286
+
287
+ @ending = true
288
+ end
289
+ end
290
+
291
+ # Close a run, and say what became of it even when closing fails.
292
+ def close_run(run)
293
+ run.close!
294
+ rescue StandardError => e
295
+ log.error("could not close #{run.slug}: #{e.class}: #{e.message}", tags: ["session"])
296
+ "error"
297
+ end
298
+
299
+ def start(why, interrupted)
300
+ where = library ? "library=#{library.dir}" : nil
301
+ log.info(["started engineer=#{engineer.inspect} host=#{host} pid=#{Process.pid} runsheets=#{VERSION}", where, "log=#{log.path}"].compact.join(" "),
302
+ tags: ["session"])
303
+ interrupted.each { log.warn("earlier session #{it} was left running by a process that is gone; closed it as interrupted", tags: ["session"]) }
304
+ note!(why, at: started_at)
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Runsheets
6
+ # The session's log: a plain text file appended as things happen, and
7
+ # the same lines echoed to the terminal that started runsheets. Each line
8
+ # is a timestamp, a Rails log level, the tags saying where it happened,
9
+ # and the event:
10
+ #
11
+ # 2026-10-10 14:02:41.200 INFO [db-maintenance 010-check #1a2b] execute sql via psql -f …
12
+ # 2026-10-10 14:02:41.540 INFO [#1a2b] > database | role
13
+ #
14
+ # Two standard Loggers sit behind it, one per destination, each with its
15
+ # own level. Multi-line text becomes one line per source line with the
16
+ # same prefix, so every line stands alone under grep.
17
+ class SessionLog
18
+ LEVELS = %w[debug info warn error fatal].freeze
19
+
20
+ # Turns a stream of output chunks into log lines, one per complete line
21
+ # of output, each marked "> ". Whatever is left without a newline is
22
+ # written when the stream closes. Thread-safe enough for one writer (the
23
+ # executor's pump) and one closer.
24
+ class OutputStream
25
+ def initialize(log, tags)
26
+ @log = log
27
+ @tags = tags
28
+ @pending = +"".b
29
+ end
30
+
31
+ # Bytes are kept as bytes until a line is complete, so a character
32
+ # split across two reads is decoded whole.
33
+ def write(chunk)
34
+ @pending << chunk.to_s.b
35
+ lines = @pending.split("\n".b, -1)
36
+ @pending = lines.pop || +"".b
37
+ lines.each { line(it) }
38
+ chunk.to_s.bytesize
39
+ end
40
+
41
+ def close
42
+ line(@pending) unless @pending.empty?
43
+ @pending = +"".b
44
+ end
45
+
46
+ private
47
+
48
+ def line(bytes) = @log.info("> #{bytes.force_encoding('UTF-8').scrub.chomp("\r")}", tags: @tags)
49
+ end
50
+
51
+ attr_reader :path, :level
52
+
53
+ # +path+ is the log file, opened for appending (and created). +echo+ is
54
+ # an IO for the terminal copy, or nil for none. +level+ and
55
+ # +echo_level+ are the floors for each, as level names.
56
+ def initialize(path, level: "info", echo: nil, echo_level: "info")
57
+ @path = path
58
+ @level = SessionLog.level_name(level)
59
+ @io = File.open(path, "a")
60
+ @io.sync = true
61
+ @file = Logger.new(@io, level: @level, formatter: method(:format))
62
+ @echo = echo && Logger.new(echo, level: SessionLog.level_name(echo_level), formatter: method(:format))
63
+ end
64
+
65
+ # A level name, checked: one of LEVELS. Raises ConfigError otherwise.
66
+ def self.level_name(value)
67
+ name = value.to_s.strip.downcase
68
+ raise ConfigError, "log level must be one of #{LEVELS.join(', ')}, got #{value.inspect}" unless LEVELS.include?(name)
69
+
70
+ name
71
+ end
72
+
73
+ # The bracketed prefix for a list of tags: [a b #c], or [] dropped.
74
+ def self.tag_text(tags)
75
+ words = Array(tags).compact.map { it.to_s.gsub(/[[:cntrl:]]/, "?") }.reject(&:empty?)
76
+ words.empty? ? "" : "[#{words.join(' ')}]"
77
+ end
78
+
79
+ # One log line as written: time to the millisecond, level, tags, text.
80
+ def self.line(time, severity, tags, text)
81
+ prefix = [time.strftime("%Y-%m-%d %H:%M:%S.%L"), severity.ljust(5), tag_text(tags)].reject(&:empty?).join(" ")
82
+ "#{prefix} #{text}\n"
83
+ end
84
+
85
+ LEVELS.each do |name|
86
+ define_method(name) { |text, tags: []| add(name, text, tags:) }
87
+ end
88
+
89
+ # Write +text+ at +severity+, one log line per line of text.
90
+ def add(severity, text, tags: [])
91
+ level = Logger::Severity.const_get(severity.to_s.upcase)
92
+ lines = text.to_s.split("\n")
93
+ lines = [""] if lines.empty?
94
+ lines.each do |line|
95
+ @file.add(level, line, tags)
96
+ @echo&.add(level, line, tags)
97
+ end
98
+ self
99
+ end
100
+
101
+ # A sink for one execution's output (see OutputStream).
102
+ def output_stream(tags) = OutputStream.new(self, tags)
103
+
104
+ def close
105
+ @io.close unless @io.closed?
106
+ self
107
+ end
108
+
109
+ private
110
+
111
+ # Logger's formatter: the tags travel as the progname.
112
+ def format(severity, time, tags, text) = SessionLog.line(time, severity, tags, text)
113
+ end
114
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # Splits a single-file runbook into its preamble and sections.
5
+ #
6
+ # One markdown file: front matter, a preamble, then one `## Heading` per
7
+ # step. A section headed "Verify" or "Rollback" (or carrying
8
+ # `role: verify` / `role: rollback`) plays the part verify.md and
9
+ # rollback.md play in a directory runbook. Step attributes that a
10
+ # directory runbook puts in front matter go in an HTML comment right
11
+ # after the heading, as a YAML flow mapping:
12
+ #
13
+ # ## Drain the ECS services
14
+ # <!-- kind: automated, timeout: 600, destructive: true -->
15
+ #
16
+ # The comment is invisible on GitHub and in any other renderer, so the
17
+ # file stays an ordinary markdown document everywhere else.
18
+ module SingleFile
19
+ Section = Data.define(:title, :data, :body, :line, :warnings) do
20
+ def role = data["role"]&.to_s&.downcase || (ROLES.include?(title.strip.downcase) ? title.strip.downcase : nil)
21
+ def extra? = !role.nil?
22
+ end
23
+
24
+ ROLES = %w[verify rollback].freeze
25
+ HEADING = /\A##[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*\z/
26
+ # An attribute comment starts with a key; any other comment is prose.
27
+ COMMENT = /\A[ \t]*<!--([ \t]*[\w-]+[ \t]*:.*?)-->[ \t]*\z/
28
+
29
+ # Returns [preamble markdown, sections].
30
+ def self.split(markdown)
31
+ preamble = []
32
+ sections = []
33
+ current = nil
34
+ open = nil
35
+
36
+ markdown.each_line(chomp: true).with_index(1) do |line, number|
37
+ if open
38
+ open = nil if Fences.closes?(line, open)
39
+ (current ? current[:body] : preamble) << line
40
+ elsif (match = line.match(Fences::OPEN))
41
+ open = { char: match[2][0], len: match[2].size }
42
+ (current ? current[:body] : preamble) << line
43
+ elsif (match = line.match(HEADING))
44
+ current = { title: match[1], body: [], line: number }
45
+ sections << current
46
+ else
47
+ (current ? current[:body] : preamble) << line
48
+ end
49
+ end
50
+
51
+ current[:unclosed] = true if open && current
52
+ ["#{preamble.join("\n")}\n", sections.map { build(it) }]
53
+ end
54
+
55
+ # Pull the attribute comment off the front of a section body.
56
+ def self.build(raw)
57
+ body = raw[:body].dup
58
+ data = {}
59
+ warnings = []
60
+ warnings << "a code fence opened in this section is never closed, so every heading after it is part of it" if raw[:unclosed]
61
+ body.shift while body.first&.strip&.empty?
62
+ if body.first && (match = body.first.match(COMMENT))
63
+ data, warning = parse_attributes(match[1])
64
+ warnings << warning if warning
65
+ body.shift
66
+ end
67
+ Section.new(title: raw[:title].strip, data:, body: "#{body.join("\n")}\n", line: raw[:line], warnings:)
68
+ end
69
+ private_class_method :build
70
+
71
+ # "kind: verify, timeout: 300" => { "kind" => "verify", "timeout" => 300 }.
72
+ # Returns [data, warning or nil].
73
+ def self.parse_attributes(text)
74
+ inner = text.strip
75
+ return [{}, nil] if inner.empty?
76
+
77
+ parsed = YAML.safe_load("{#{inner}}", permitted_classes: [Date, Time], aliases: false)
78
+ return [{}, "attribute comment is not a mapping: <!-- #{inner} -->"] unless parsed.is_a?(Hash)
79
+
80
+ [parsed.transform_keys(&:to_s), nil]
81
+ rescue Psych::Exception => e
82
+ [{}, "attribute comment could not be parsed (#{e.message.lines.first&.strip}): <!-- #{inner} -->"]
83
+ end
84
+
85
+ # A step slug for a section: position-numbered like a directory step,
86
+ # so block ids and record files look the same either way.
87
+ def self.slug_for(title, position)
88
+ words = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
89
+ words = "step" if words.empty?
90
+ format("%<number>03d-%<words>s", number: position * 10, words:)
91
+ end
92
+ end
93
+ end