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,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
|