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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # Finds fenced code blocks in markdown and rewrites their info strings.
5
+ #
6
+ # kramdown's GFM parser only accepts a single word as the info string, so
7
+ # "```bash run" is not even recognised as a code block. Before rendering,
8
+ # every fence with an info string is rewritten to "lang?rs=N" where N is
9
+ # the fence's index. kramdown passes "lang?opts" through as the language,
10
+ # which Renderer::HtmlConverter intercepts to wrap block N.
11
+ #
12
+ # Fences are found where kramdown finds them: at the top level (indented
13
+ # up to three spaces), inside list items (indented to the item's content),
14
+ # and inside blockquotes.
15
+ module Fences
16
+ Fence = Data.define(:info, :code, :line)
17
+
18
+ MARKER = "?rs="
19
+ OPEN = /\A( {0,3})(`{3,}|~{3,})[ \t]*(.*?)[ \t]*\z/
20
+ INDENTED = /\A( *)(`{3,}|~{3,})[ \t]*(.*?)[ \t]*\z/
21
+ QUOTE = /\A(?: {0,3}> ?)+/
22
+ LIST_ITEM = /\A( *)([-*+]|\d{1,9}[.)])( {1,4}(?=\S)|[ \t]*\z)/
23
+
24
+ # Reads markdown a line at a time and says what each line is to a
25
+ # fence. Remembers the open fence and the list item a line may belong
26
+ # to, which is all the context a fence needs.
27
+ class Scanner
28
+ def initialize
29
+ @open = nil
30
+ @list_indent = nil
31
+ @after_blank = true
32
+ end
33
+
34
+ def open? = !@open.nil?
35
+
36
+ # One event per line:
37
+ # [:open, fence] a fence opens; fence has :quote, :lead, :fence, :info, :indent
38
+ # [:code, text] a line inside the fence, its blockquote marker removed
39
+ # [:close] the fence's closing line
40
+ # [:text] anything else
41
+ # A fence in a blockquote ends where the blockquote does, as in
42
+ # kramdown; the line that ends it is then read as text.
43
+ def feed(line)
44
+ if @open
45
+ body = Fences.unquote(line, @open[:depth])
46
+ return Fences.closes?(body, @open) ? close : [:code, body] if body
47
+
48
+ @open = nil
49
+ end
50
+ opening(line) || text(line)
51
+ end
52
+
53
+ private
54
+
55
+ def close
56
+ @open = nil
57
+ @after_blank = false
58
+ [:close]
59
+ end
60
+
61
+ def opening(line)
62
+ quote = line[QUOTE].to_s
63
+ match = line[quote.size..].match(INDENTED) or return nil
64
+ indent = match[1].size
65
+ return nil unless indent <= 3 || in_list_item?(indent)
66
+
67
+ @after_blank = false
68
+ @open = { depth: quote.count(">"), char: match[2][0], len: match[2].size, indent:,
69
+ info: (match[3] unless match[3].empty?), quote:, lead: match[1], fence: match[2] }
70
+ [:open, @open]
71
+ end
72
+
73
+ # A fence indented four or more spaces belongs to the list item above
74
+ # it when it starts within three spaces of the item's content.
75
+ def in_list_item?(indent) = !@list_indent.nil? && indent.between?(@list_indent, @list_indent + 3)
76
+
77
+ def text(line)
78
+ blank = line.strip.empty?
79
+ if (item = line.match(LIST_ITEM))
80
+ @list_indent = item[1].size + item[2].size + [item[3].size, 1].max
81
+ elsif !blank && @after_blank && line[/\A */].size < @list_indent.to_i
82
+ @list_indent = nil
83
+ end
84
+ @after_blank = blank
85
+ [:text]
86
+ end
87
+ end
88
+
89
+ # Returns [rewritten markdown, fences]. Fences without an info string are
90
+ # tracked (so their contents are never mistaken for fences) but not
91
+ # returned, since display-only blocks need no identity.
92
+ def self.extract(markdown)
93
+ scanner = Scanner.new
94
+ lines = []
95
+ fences = []
96
+ open = nil
97
+ code = []
98
+
99
+ markdown.each_line(chomp: true).with_index(1) do |line, number|
100
+ event, data = scanner.feed(line)
101
+ if open && event != :code
102
+ finish(fences, open, code)
103
+ open = nil
104
+ code = []
105
+ end
106
+
107
+ case event
108
+ when :open
109
+ open = data.merge(line: number)
110
+ lines << opening_line(line, data, fences.size)
111
+ next
112
+ when :code
113
+ code << data
114
+ end
115
+ lines << line
116
+ end
117
+
118
+ finish(fences, open, code) if open
119
+ ["#{lines.join("\n")}\n", fences]
120
+ end
121
+
122
+ # The first word of an info string.
123
+ def self.lang_of(info) = info.split.first.to_s
124
+
125
+ # True when line is a closing fence for the open one: the same
126
+ # character, at least as long, indented at most three spaces past the
127
+ # opening fence's indent.
128
+ def self.closes?(line, open)
129
+ line.match?(/\A {0,#{open[:indent].to_i + 3}}#{Regexp.escape(open[:char])}{#{open[:len]},}[ \t]*\z/)
130
+ end
131
+
132
+ # +line+ with +depth+ blockquote markers removed, or nil when it has
133
+ # fewer (the blockquote has ended). Depth 0 leaves the line alone, so
134
+ # code inside a top-level fence may start with ">".
135
+ def self.unquote(line, depth)
136
+ return line if depth.zero?
137
+
138
+ quote = line[QUOTE].to_s
139
+ return nil if quote.count(">") < depth
140
+
141
+ rest = line
142
+ depth.times { rest = rest.sub(/\A {0,3}> ?/, "") }
143
+ rest
144
+ end
145
+
146
+ # Remove up to +indent+ leading spaces from each code line, as kramdown does
147
+ # for fences nested inside list items.
148
+ def self.dedent(lines, indent)
149
+ return "" if lines.empty?
150
+
151
+ pattern = /\A {0,#{indent}}/
152
+ "#{lines.map { it.sub(pattern, '') }.join("\n")}\n"
153
+ end
154
+
155
+ # The opening line as kramdown should see it: the info string replaced
156
+ # by its language and the fence's index. A bare fence is left alone.
157
+ def self.opening_line(line, open, index)
158
+ info = open[:info] or return line
159
+ "#{open[:quote]}#{open[:lead]}#{open[:fence]}#{lang_of(info)}#{MARKER}#{index}"
160
+ end
161
+
162
+ # Add the fence that just ended to +fences+, unless it is bare.
163
+ def self.finish(fences, open, code)
164
+ fences << build(open, code) if open[:info]
165
+ end
166
+
167
+ def self.build(open, code)
168
+ Fence.new(info: open[:info], code: dedent(code, open[:indent]), line: open[:line])
169
+ end
170
+ private_class_method :build, :finish
171
+ end
172
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # Splits a markdown document into its YAML front matter and body.
5
+ module FrontMatter
6
+ Result = Data.define(:data, :body)
7
+
8
+ PATTERN = /\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\z)/m
9
+
10
+ # Returns a Result whose data is a Hash with string keys (empty when the
11
+ # document has no front matter) and whose body is the remaining text.
12
+ def self.parse(text)
13
+ text = text.to_s.scrub.delete_prefix("\uFEFF")
14
+ match = text.match(PATTERN)
15
+ return Result.new(data: {}, body: text) unless match
16
+
17
+ data = YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: false) || {}
18
+ raise RunbookError, "front matter must be a YAML mapping" unless data.is_a?(Hash)
19
+
20
+ Result.new(data:, body: match.post_match)
21
+ rescue Psych::AliasesNotEnabled
22
+ raise RunbookError, "front matter may not use YAML aliases (& and *)"
23
+ rescue Psych::SyntaxError => e
24
+ raise RunbookError, "front matter is not valid YAML: #{e.message}"
25
+ rescue Psych::Exception => e # a tag or class safe_load refuses
26
+ raise RunbookError, "front matter could not be read: #{e.message}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # A directory tree of runbooks. Every markdown file that starts with
5
+ # front matter holding a title is a single-file runbook, and every
6
+ # directory holding runbook.md is a runbook directory; other markdown
7
+ # files are plain documents and stay out of the tree. Any other directory
8
+ # is a folder, searched the same way, to any depth.
9
+ # The web page shows the tree for the operator to choose from.
10
+ #
11
+ # ops/ <- the library
12
+ # README.md <- describes the folder, not a runbook
13
+ # deploy.md <- runbook "deploy"
14
+ # glossary.md <- no front matter: a document, not listed
15
+ # database/
16
+ # backup/runbook.md <- runbook "database/backup"
17
+ # restore.md <- runbook "database/restore"
18
+ #
19
+ # A runbook's slug is its path inside the library, so run records of
20
+ # database/backup and network/backup never share a directory. Folders
21
+ # that hold no runbooks anywhere beneath them are left out; hidden
22
+ # entries are skipped. A directory that itself holds runbook.md is a
23
+ # runbook, not a library.
24
+ class Library
25
+ README = "README.md"
26
+ MAX_DEPTH = 16
27
+
28
+ # One runbook in the tree. runbook is the loaded Runbook, or nil with
29
+ # error set when it does not load; the reading methods then fall back
30
+ # to what the file system says.
31
+ Entry = Data.define(:slug, :name, :folder, :path, :single_file, :runbook, :error) do
32
+ def single_file? = single_file
33
+ def ok? = error.nil?
34
+ def title = runbook&.title || name
35
+ def when_to_use = runbook&.when_to_use
36
+ def tags = runbook&.tags || []
37
+ def steps = runbook&.steps&.size || 0
38
+ def warnings = runbook&.warnings || []
39
+ def destructive? = runbook&.destructive? || false
40
+ def main_path = single_file ? path : File.join(path, Runbook::MAIN_FILE)
41
+ def depth = slug.count("/")
42
+ def runbook? = true
43
+ def folder? = false
44
+ end
45
+
46
+ # A folder of the tree: its own runbooks and folders, in title order,
47
+ # the README's HTML when it has one, and the directory's mtime when it
48
+ # was scanned. The root has the empty slug.
49
+ Folder = Data.define(:slug, :name, :path, :folders, :entries, :readme_html, :mtime) do
50
+ def root? = slug.empty?
51
+ def title = name
52
+ def depth = root? ? 0 : slug.count("/") + 1
53
+ def runbook? = false
54
+ def folder? = true
55
+ def readme? = !readme_html.nil?
56
+
57
+ # Every runbook beneath this folder, nearest first.
58
+ def runbooks = entries + folders.flat_map(&:runbooks)
59
+
60
+ # Every folder beneath this one, nearest first.
61
+ def subfolders = folders + folders.flat_map(&:subfolders)
62
+
63
+ def size = runbooks.size
64
+ end
65
+
66
+ # Putting a folder's contents in order, and noticing a README edit.
67
+ module Tidy
68
+ module_function
69
+
70
+ # Has the README in +dir+ been edited since +time+? (A folder's mtime
71
+ # does not change when a file in it is edited in place.)
72
+ def readme_changed?(dir, time)
73
+ file = Dir.children(dir).find { it.casecmp?(README) }
74
+ file ? File.mtime(File.join(dir, file)) > time : false
75
+ rescue SystemCallError
76
+ true
77
+ end
78
+
79
+ # Folders or entries in title order, then by name.
80
+ def by_title(nodes) = nodes.sort_by { [it.title.downcase, it.name] }.freeze
81
+
82
+ # Runbooks in one folder whose slugs clash, with each other (deploy.md
83
+ # beside deploy/runbook.md) or with a folder (backup.md beside backup/),
84
+ # become broken entries saying so; the first of a clash keeps the slug.
85
+ def unclash(entries, folder_slugs)
86
+ seen = folder_slugs.to_set
87
+ entries.map do |entry|
88
+ clash = seen.include?(entry.slug)
89
+ seen << entry.slug
90
+ clash ? entry.with(runbook: nil, error: "another runbook or a folder here already has the name #{entry.slug}; rename one") : entry
91
+ end
92
+ end
93
+ end
94
+
95
+ # Something found while scanning: where it is, its slug and name, and
96
+ # the slug of the folder holding it. Becomes an Entry or a Folder.
97
+ Found = Data.define(:slug, :name, :folder, :path)
98
+
99
+ attr_reader :dir, :root, :entries, :folders, :scanned_at
100
+
101
+ # Is path a directory of runbooks rather than a runbook?
102
+ def self.library?(path)
103
+ path = File.expand_path(path)
104
+ File.directory?(path) && !File.file?(File.join(path, Runbook::MAIN_FILE))
105
+ end
106
+
107
+ def self.load(dir) = new(dir)
108
+
109
+ def initialize(dir)
110
+ @dir = File.expand_path(dir)
111
+ @documents = []
112
+ @scanned = {}
113
+ raise RunbookError, "no such directory: #{@dir}" unless File.directory?(@dir)
114
+
115
+ scan!
116
+ raise RunbookError, "no runbooks in #{@dir}: expected .md files with a front matter title, or directories holding #{Runbook::MAIN_FILE}" if entries.empty?
117
+ end
118
+
119
+ # The runbook with this slug, or nil.
120
+ def find(slug) = entries.find { it.slug == slug }
121
+
122
+ # The folder with this slug; "" (or nil) is the root. nil when unknown.
123
+ def folder(slug)
124
+ slug = slug.to_s
125
+ return root if slug.empty?
126
+
127
+ folders.find { it.slug == slug }
128
+ end
129
+
130
+ # Whatever this slug names: an Entry, a Folder, or nil.
131
+ def node(slug) = find(slug.to_s) || folder(slug)
132
+
133
+ # The folders above a slug, outermost first, the root excluded. Works
134
+ # for a runbook's slug and for a folder's.
135
+ def ancestors(slug)
136
+ parts = slug.to_s.split("/")
137
+ parts.pop
138
+ parts.each_index.filter_map { folder(parts[0..it].join("/")) }
139
+ end
140
+
141
+ # Load the runbook an entry stands for, afresh, carrying its library slug.
142
+ def runbook(slug)
143
+ entry = find(slug) or raise RunbookError, "no runbook named #{slug} in #{dir}"
144
+ Runbook.load(entry.path, slug: entry.slug, root: dir)
145
+ end
146
+
147
+ # The runbook whose files include +path+: a single-file runbook that is
148
+ # that file, or a runbook directory holding it. nil for anything else.
149
+ def entry_at(path)
150
+ real = Runbook.real_path(path)
151
+ entries.find do |entry|
152
+ base = Runbook.real_path(entry.path)
153
+ entry.single_file? ? base == real : real.start_with?("#{base}/")
154
+ end
155
+ end
156
+
157
+ def size = entries.size
158
+
159
+ # True once a folder has gained or lost a child, or a runbook's or a
160
+ # plain document's files have changed, since the last scan. A document
161
+ # edited into a runbook joins the tree.
162
+ def stale?
163
+ return true if @scanned.any? { |path, mtime| folder_mtime(path) != mtime }
164
+ return true if @documents.any? { changed_since_scan?(it) }
165
+ return true if [root, *folders].any? { Tidy.readme_changed?(it.path, scanned_at) }
166
+
167
+ entries.any? { entry_stale?(it) }
168
+ end
169
+
170
+ # Rescan the directory if anything changed. Returns self.
171
+ def refresh!
172
+ scan! if stale?
173
+ self
174
+ end
175
+
176
+ private
177
+
178
+ def scan!
179
+ @scanned_at = Time.now
180
+ @documents = []
181
+ @scanned = {}
182
+ root = scan_folder(dir, slug: "", visited: [File.realpath(dir)])
183
+ @root = root
184
+ @folders = root.subfolders.sort_by(&:slug).freeze
185
+ @entries = root.runbooks.sort_by(&:slug).freeze
186
+ end
187
+
188
+ # One folder: its direct runbooks and the folders beneath that hold
189
+ # any, each sorted by title. visited holds the real paths above, so a
190
+ # symlink cycle ends here instead of recursing forever.
191
+ # What one child of a folder is: a Folder, an Entry, or nil (a plain
192
+ # document, recorded for staleness, or anything else).
193
+ def scan_child(found, visited:)
194
+ return scan_directory(found, visited:) if File.directory?(found.path)
195
+
196
+ name = found.name
197
+ return nil unless name.end_with?(".md") && !name.casecmp?(README)
198
+ return (@documents << found.path) && nil unless Runbook.runbook_file?(found.path)
199
+
200
+ entry_for(found.with(name: File.basename(name, ".md"), slug: found.slug.delete_suffix(".md")), single_file: true)
201
+ end
202
+
203
+ def scan_folder(path, slug:, visited:)
204
+ mtime = folder_mtime(path)
205
+ @scanned[path] = mtime # every directory looked in, runbooks or not
206
+ entries = []
207
+ folders = []
208
+ Dir.children(path).sort.each do |name|
209
+ next if name.start_with?(".")
210
+
211
+ found = Found.new(slug: slug.empty? ? name : "#{slug}/#{name}", name:, folder: slug, path: File.join(path, name))
212
+ node = scan_child(found, visited:)
213
+ (node.is_a?(Folder) ? folders : entries) << node if node
214
+ end
215
+ entries = Tidy.unclash(entries, folders.map(&:slug))
216
+ Folder.new(slug:, name: File.basename(path), path:, folders: Tidy.by_title(folders), entries: Tidy.by_title(entries), readme_html: readme_html(path),
217
+ mtime:)
218
+ end
219
+
220
+ # A directory is a runbook when it holds runbook.md, a folder when any
221
+ # runbook lies beneath it, and nothing otherwise.
222
+ def scan_directory(found, visited:)
223
+ path = found.path
224
+ return entry_for(found, single_file: false) if File.file?(File.join(path, Runbook::MAIN_FILE))
225
+
226
+ real = File.realpath(path)
227
+ return nil if visited.include?(real) || visited.size > MAX_DEPTH
228
+
229
+ sub = scan_folder(path, slug: found.slug, visited: [*visited, real])
230
+ sub.size.positive? ? sub : nil
231
+ rescue SystemCallError
232
+ nil
233
+ end
234
+
235
+ def entry_for(found, single_file:)
236
+ runbook = Runbook.load(found.path, slug: found.slug, root: dir)
237
+ Entry.new(**found.to_h, single_file:, runbook:, error: nil)
238
+ rescue StandardError => e # a file that does not load, for any reason, is shown as broken, not dropped
239
+ Entry.new(**found.to_h, single_file:, runbook: nil, error: e.message)
240
+ end
241
+
242
+ def readme_html(path)
243
+ file = Dir.children(path).find { it.casecmp?(README) }
244
+ file && Renderer.render_plain(File.read(File.join(path, file), encoding: "UTF-8"))
245
+ rescue SystemCallError
246
+ nil
247
+ end
248
+
249
+ def changed_since_scan?(path)
250
+ !File.file?(path) || File.mtime(path) > scanned_at
251
+ rescue SystemCallError
252
+ true
253
+ end
254
+
255
+ def folder_mtime(path)
256
+ File.mtime(path)
257
+ rescue SystemCallError
258
+ nil
259
+ end
260
+
261
+ def entry_stale?(entry)
262
+ return entry.runbook.stale? if entry.ok?
263
+
264
+ files = entry.single_file? ? [entry.path] : Dir.glob(File.join(entry.path, "**", "*.md"))
265
+ files.empty? || files.any? { changed_since_scan?(it) }
266
+ end
267
+ end
268
+ end