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