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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runsheets
|
|
4
|
+
# Replaces secret input values in captured output before it is stored.
|
|
5
|
+
#
|
|
6
|
+
# Plain string replacement: a secret that a command prints base64-encoded,
|
|
7
|
+
# URL-encoded or split across lines is not caught. Output arrives in
|
|
8
|
+
# chunks, so #feed holds back any tail that could be the start of a
|
|
9
|
+
# secret until the next chunk (or #flush) settles it, so however the
|
|
10
|
+
# output is split, no whole secret gets through.
|
|
11
|
+
class Redactor
|
|
12
|
+
# The redactor for a run: every secret input that has a value.
|
|
13
|
+
def self.for(inputs, runbook)
|
|
14
|
+
secrets = inputs.to_h.select { |name, value| runbook.input(name)&.secret? && !value.to_s.empty? }
|
|
15
|
+
new(secrets)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :secrets
|
|
19
|
+
|
|
20
|
+
# +secrets+ maps an input name to its value.
|
|
21
|
+
def initialize(secrets)
|
|
22
|
+
@secrets = secrets.to_h { |name, value| [name.to_s, value.to_s.b] }.reject { |_, v| v.empty? }.freeze
|
|
23
|
+
@values = @secrets.sort_by { |_, v| -v.bytesize }.freeze
|
|
24
|
+
@names = @values.reverse.to_h(&:reverse).freeze # value => name, the first name winning a shared value
|
|
25
|
+
@pattern = Redactor.pattern(@values.map(&:last))
|
|
26
|
+
@pending = +"".b
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# One binary Regexp matching any of +values+, longest first, so a secret
|
|
30
|
+
# that contains another is matched whole.
|
|
31
|
+
def self.pattern(values) = values.empty? ? nil : Regexp.new(values.map { Regexp.escape(it) }.join("|").b)
|
|
32
|
+
|
|
33
|
+
def empty? = @secrets.empty?
|
|
34
|
+
|
|
35
|
+
# Longest tail of a chunk that could still be the start of a secret.
|
|
36
|
+
def hold_back = @values.map { |_, v| v.bytesize }.max.to_i - 1
|
|
37
|
+
|
|
38
|
+
# Every occurrence of every secret replaced, in one left-to-right pass.
|
|
39
|
+
def redact(text)
|
|
40
|
+
return text if empty?
|
|
41
|
+
|
|
42
|
+
text.b.gsub(@pattern) { "[redacted #{@names[it]}]" }.force_encoding(text.encoding)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Streaming: returns the part of the output so far that is safe to
|
|
46
|
+
# write, keeping back a tail that may be a partial secret.
|
|
47
|
+
def feed(chunk)
|
|
48
|
+
return chunk if empty?
|
|
49
|
+
|
|
50
|
+
@pending << chunk.b
|
|
51
|
+
settled = last_match_end(@pending)
|
|
52
|
+
keep = partial_secret_length(@pending.byteslice(settled..))
|
|
53
|
+
safe = @pending.byteslice(0, @pending.bytesize - keep)
|
|
54
|
+
@pending = @pending.byteslice(@pending.bytesize - keep, keep) || +"".b
|
|
55
|
+
redact(safe)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whatever #feed was holding back, redacted. Call at end of stream.
|
|
59
|
+
def flush
|
|
60
|
+
out = redact(@pending)
|
|
61
|
+
@pending = +"".b
|
|
62
|
+
out
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Where the last secret #redact would replace in +text+ ends, or 0.
|
|
66
|
+
# Matches are found left to right as #redact finds them, so one that
|
|
67
|
+
# overlaps an earlier match is not counted.
|
|
68
|
+
def last_match_end(text)
|
|
69
|
+
at = 0
|
|
70
|
+
ends = 0
|
|
71
|
+
while (match = @pattern.match(text, at))
|
|
72
|
+
ends = at = match.end(0)
|
|
73
|
+
end
|
|
74
|
+
ends
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Length of the longest suffix of +text+ that is a proper prefix of
|
|
78
|
+
# some secret.
|
|
79
|
+
def partial_secret_length(text)
|
|
80
|
+
limit = [hold_back, text.bytesize].min
|
|
81
|
+
limit.downto(1) do |k|
|
|
82
|
+
tail = text.byteslice(-k, k)
|
|
83
|
+
return k if @values.any? { |_, v| v.bytesize > k && v.start_with?(tail) }
|
|
84
|
+
end
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "kramdown"
|
|
6
|
+
require "kramdown-parser-gfm"
|
|
7
|
+
require "rouge"
|
|
8
|
+
|
|
9
|
+
module Runsheets
|
|
10
|
+
# Markdown to HTML, with executable blocks wrapped in the markup the page's
|
|
11
|
+
# JavaScript needs (data attributes, toolbar, output area).
|
|
12
|
+
module Renderer
|
|
13
|
+
Result = Data.define(:html, :blocks)
|
|
14
|
+
|
|
15
|
+
ROUGE_THEME = "base16.monokai.dark"
|
|
16
|
+
|
|
17
|
+
# A random value for this process, stamped on every block wrapper and
|
|
18
|
+
# control the page's script may act on. Raw HTML in markdown can copy the
|
|
19
|
+
# markup of a Run button, but not this value, so the script ignores it.
|
|
20
|
+
MARK = SecureRandom.hex(16)
|
|
21
|
+
|
|
22
|
+
# Rouge formatter that wraps block output the way kramdown's layout
|
|
23
|
+
# expects (<div class="highlight"><pre class="highlight"><code>) without
|
|
24
|
+
# going through the deprecated HTMLLegacy formatter. kramdown passes
|
|
25
|
+
# wrap: false for inline code spans.
|
|
26
|
+
class CodeFormatter < Rouge::Formatters::HTML
|
|
27
|
+
def initialize(opts = {})
|
|
28
|
+
super()
|
|
29
|
+
@css_class = opts[:css_class] || "highlight"
|
|
30
|
+
@wrap = opts[:wrap] != false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stream(tokens, &)
|
|
34
|
+
return super unless @wrap
|
|
35
|
+
|
|
36
|
+
yield %(<div class="#{@css_class}"><pre class="#{@css_class}"><code>)
|
|
37
|
+
super
|
|
38
|
+
yield "</code></pre></div>"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
KRAMDOWN_OPTIONS = {
|
|
43
|
+
input: "GFM",
|
|
44
|
+
hard_wrap: false,
|
|
45
|
+
syntax_highlighter: "rouge",
|
|
46
|
+
syntax_highlighter_opts: { css_class: "highlight", formatter: CodeFormatter }
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# kramdown HTML converter that recognises the "lang?rs=N" marker Fences
|
|
50
|
+
# plants and wraps the Nth block.
|
|
51
|
+
class HtmlConverter < Kramdown::Converter::Html
|
|
52
|
+
public_class_method :new
|
|
53
|
+
|
|
54
|
+
MARKER = /\A(.*?)\?rs=(\d+)\z/
|
|
55
|
+
|
|
56
|
+
attr_accessor :blocks
|
|
57
|
+
|
|
58
|
+
def convert_codeblock(el, indent)
|
|
59
|
+
match = el.options[:lang].to_s.match(MARKER)
|
|
60
|
+
block = match && blocks&.[](match[2].to_i)
|
|
61
|
+
return super unless block
|
|
62
|
+
|
|
63
|
+
if match[1].empty?
|
|
64
|
+
el.options.delete(:lang)
|
|
65
|
+
el.attr.delete("class")
|
|
66
|
+
else
|
|
67
|
+
el.options[:lang] = match[1]
|
|
68
|
+
el.attr["class"] = "language-#{match[1]}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
Renderer.wrap_block(block, super, indent)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Render markdown. Blocks get ids "#{id_prefix}-1", "#{id_prefix}-2", ...
|
|
76
|
+
# +interpreters+ decides which languages can execute.
|
|
77
|
+
def self.render(markdown, id_prefix:, interpreters: Block::INTERPRETERS)
|
|
78
|
+
rewritten, fences = Fences.extract(markdown)
|
|
79
|
+
blocks = fences.map.with_index do |fence, i|
|
|
80
|
+
Block.new(id: "#{id_prefix}-#{i + 1}", index: i, info: fence.info, code: fence.code, line: fence.line, interpreters:)
|
|
81
|
+
end
|
|
82
|
+
link_expectations(blocks)
|
|
83
|
+
|
|
84
|
+
doc = Kramdown::Document.new(rewritten, **KRAMDOWN_OPTIONS)
|
|
85
|
+
options = Kramdown::Options.merge(doc.options.merge(doc.root.options[:options] || {}))
|
|
86
|
+
converter = HtmlConverter.new(doc.root, options)
|
|
87
|
+
converter.blocks = blocks
|
|
88
|
+
|
|
89
|
+
Result.new(html: strip_meta(converter.convert(doc.root)), blocks:)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Render markdown as plain HTML: nothing is executable, so no block is
|
|
93
|
+
# wrapped. For prose outside a runbook, such as a library folder's README.
|
|
94
|
+
def self.render_plain(markdown) = strip_meta(Kramdown::Document.new(markdown, **KRAMDOWN_OPTIONS).to_html)
|
|
95
|
+
|
|
96
|
+
# Remove <meta> tags from rendered markdown. A meta refresh would
|
|
97
|
+
# navigate the page wherever the markdown says, and no Content-Security-
|
|
98
|
+
# Policy stops one; a runbook has no use for any other <meta>.
|
|
99
|
+
def self.strip_meta(html) = html.gsub(/<meta\b[^>]*>/i, "")
|
|
100
|
+
|
|
101
|
+
def self.h(value) = CGI.escapeHTML(value.to_s)
|
|
102
|
+
|
|
103
|
+
# The attribute that marks an element as the page's own (see MARK).
|
|
104
|
+
def self.mark_attr = %( data-rs-mark="#{MARK}")
|
|
105
|
+
|
|
106
|
+
# An expect block illustrates the nearest executable block above it.
|
|
107
|
+
def self.link_expectations(blocks)
|
|
108
|
+
last = nil
|
|
109
|
+
blocks.each do |block|
|
|
110
|
+
if block.executable?
|
|
111
|
+
last = block
|
|
112
|
+
elsif block.expect? && last
|
|
113
|
+
block.expect_for = last.id
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
blocks
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The <div class="rs-block"> wrapper around a highlighted code block.
|
|
120
|
+
def self.wrap_block(block, code_html, indent = 0)
|
|
121
|
+
pad = " " * indent
|
|
122
|
+
classes = ["rs-block", "rs-#{block.kind}"]
|
|
123
|
+
classes << "rs-executable" if block.executable?
|
|
124
|
+
classes << "rs-warned" if block.warnings.any?
|
|
125
|
+
attrs = %(id="block-#{h block.id}"#{mark_attr} data-block="#{h block.id}" data-kind="#{block.kind}" ) +
|
|
126
|
+
%(data-digest="#{block.digest}" data-lang="#{h block.lang}")
|
|
127
|
+
attrs += %( data-expect-for="#{h block.expect_for}") if block.expect_for
|
|
128
|
+
|
|
129
|
+
<<~HTML
|
|
130
|
+
#{pad}<div class="#{classes.join(' ')}" #{attrs}>
|
|
131
|
+
#{pad}<div class="rs-toolbar">#{toolbar(block)}</div>
|
|
132
|
+
#{code_html.chomp}
|
|
133
|
+
#{pad}<div class="rs-result" hidden><div class="rs-panes"><div class="rs-pane rs-actual"><div class="rs-pane-title">output</div><pre class="rs-output" data-role="output"></pre></div><div class="rs-pane rs-expected" data-role="expected" hidden><div class="rs-pane-title">expected</div><pre></pre></div></div><div class="rs-exit" data-role="exit"></div></div>
|
|
134
|
+
#{pad}</div>
|
|
135
|
+
HTML
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
NOTES = {
|
|
139
|
+
terminal: "run this in your own terminal, then confirm",
|
|
140
|
+
expect: "expected output",
|
|
141
|
+
background: "runs until stopped or the run ends"
|
|
142
|
+
}.freeze
|
|
143
|
+
|
|
144
|
+
def self.toolbar(block)
|
|
145
|
+
parts = ["<span class=\"rs-badge rs-#{block.kind}\">#{h block.label}</span>"]
|
|
146
|
+
block.warnings.each { parts << "<span class=\"rs-warning\" title=\"authoring warning\">#{h it}</span>" }
|
|
147
|
+
if block.expect_for
|
|
148
|
+
parts << "<span class=\"rs-note\">expected output of <a href=\"#block-#{h block.expect_for}\">#{h block.expect_for}</a></span>"
|
|
149
|
+
elsif NOTES.key?(block.kind)
|
|
150
|
+
parts << "<span class=\"rs-note\">#{h NOTES[block.kind]}</span>"
|
|
151
|
+
end
|
|
152
|
+
parts << '<span class="rs-spacer"></span>'
|
|
153
|
+
parts << '<button type="button" class="rs-btn rs-copy" data-action="copy" title="Copy to clipboard">Copy</button>'
|
|
154
|
+
parts.concat(action_buttons(block))
|
|
155
|
+
parts << '<span class="rs-status" data-role="status" role="status" aria-live="polite"></span>'
|
|
156
|
+
parts.join
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The buttons that act on a block: Start and Stop, Run, or I ran this,
|
|
160
|
+
# each labelled with the block id for screen readers.
|
|
161
|
+
def self.action_buttons(block)
|
|
162
|
+
id = h(block.id)
|
|
163
|
+
if block.background?
|
|
164
|
+
[%(<button type="button" class="rs-btn rs-run" data-action="execute" aria-label="Start #{id}">Start</button>),
|
|
165
|
+
%(<button type="button" class="rs-btn rs-stop" data-action="stop" hidden>Stop</button>)]
|
|
166
|
+
elsif block.executable?
|
|
167
|
+
label = block.destructive? ? "Run (destructive)" : "Run"
|
|
168
|
+
style = block.destructive? ? "rs-btn rs-run rs-danger" : "rs-btn rs-run"
|
|
169
|
+
[%(<button type="button" class="#{style}" data-action="execute" aria-label="#{label} #{id}">#{label}</button>)]
|
|
170
|
+
elsif block.acknowledgeable?
|
|
171
|
+
[%(<button type="button" class="rs-btn rs-ack" data-action="acknowledge" aria-label="I ran #{id}">I ran this</button>)]
|
|
172
|
+
else
|
|
173
|
+
[]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The first level-one heading of a markdown document, or nil.
|
|
178
|
+
def self.title_of(markdown) = without_fences(markdown)[/^#[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*\r?$/, 1]
|
|
179
|
+
|
|
180
|
+
# Markdown with its fenced code blocks taken out.
|
|
181
|
+
def self.without_fences(markdown) = markdown.to_s.gsub(/^ {0,3}(`{3,}|~{3,})[^\n]*\n.*?^ {0,3}\1[`~]*[ \t]*\r?$/m, "")
|
|
182
|
+
|
|
183
|
+
# Point relative <img src> and <a href> values at the /files/ route so
|
|
184
|
+
# assets that sit next to a markdown file resolve, and links (not images)
|
|
185
|
+
# to markdown files at the /docs/ route, which renders them. base_dir is the file's
|
|
186
|
+
# directory relative to the runbook root ('' for the root).
|
|
187
|
+
def self.rewrite_relative_urls(html, base_dir, prefix: "/files")
|
|
188
|
+
html.gsub(/(<(img|a)\b[^>]*\b(?:src|href)=")([^"]*)(")/) do
|
|
189
|
+
before, tag, url, after = Regexp.last_match.captures
|
|
190
|
+
if url.match?(%r{\A([a-z][a-z0-9+.-]*:|/|#|\?)}i)
|
|
191
|
+
"#{before}#{url}#{after}"
|
|
192
|
+
else
|
|
193
|
+
joined = join_relative(base_dir, url)
|
|
194
|
+
route = tag == "a" && markdown_path?(joined.to_s) ? "/docs" : prefix
|
|
195
|
+
"#{before}#{joined ? "#{route}/#{joined}" : url}#{after}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def self.markdown_path?(path) = path.match?(/\.md(?:[#?]|\z)/i)
|
|
201
|
+
|
|
202
|
+
# Make every /docs/ link in +html+ that leads to a plain document under
|
|
203
|
+
# +root+ open in a new tab; links to a runbook or one of its files stay
|
|
204
|
+
# in the tab, since they navigate the runbooks rather than open
|
|
205
|
+
# reference reading.
|
|
206
|
+
def self.open_documents_in_new_tab(html, root)
|
|
207
|
+
html.gsub(%r{(<a\b[^>]*\bhref="/docs/([^"#?]*)[^"]*")}) do
|
|
208
|
+
link, path = Regexp.last_match.captures
|
|
209
|
+
Runbook.plain_document?(File.join(root, CGI.unescapeURIComponent(path)), root) ? %(#{link} target="_blank" rel="noopener") : link
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Join a relative href onto a base directory, resolving '.' and '..'.
|
|
214
|
+
# Returns nil if the href climbs above the root.
|
|
215
|
+
def self.join_relative(base_dir, href)
|
|
216
|
+
parts = base_dir.to_s.split("/").reject { it.empty? || it == "." }
|
|
217
|
+
href.to_s.split("/").each do |part|
|
|
218
|
+
case part
|
|
219
|
+
when "", "." then next
|
|
220
|
+
when ".." then parts.pop.nil? and return nil
|
|
221
|
+
else parts << part
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
parts.join("/")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def self.code_stylesheet = Rouge::Theme.find(ROUGE_THEME).render(scope: ".highlight")
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runsheets
|
|
4
|
+
# One runbook's part of a session: the run the engineer established by
|
|
5
|
+
# selecting the runbook, its inputs (secrets included, in memory only),
|
|
6
|
+
# its record, and the executions it started. A run stays open until the
|
|
7
|
+
# session ends; there is no finishing it by hand.
|
|
8
|
+
#
|
|
9
|
+
# Everything it does is written to the run's record and the session log.
|
|
10
|
+
# It knows nothing about HTTP.
|
|
11
|
+
class Run
|
|
12
|
+
# Raised when a destructive block is executed without the typed
|
|
13
|
+
# confirmation. Carries the code the operator has to type.
|
|
14
|
+
class ConfirmationRequired < RunError
|
|
15
|
+
attr_reader :challenge, :block_id
|
|
16
|
+
|
|
17
|
+
def initialize(block_id, challenge)
|
|
18
|
+
@block_id = block_id
|
|
19
|
+
@challenge = challenge
|
|
20
|
+
super("destructive block #{block_id} needs confirmation: type #{challenge}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
STOP_WAIT = Executor::GRACE + 1.0
|
|
25
|
+
|
|
26
|
+
attr_reader :session, :runbook, :record, :inputs
|
|
27
|
+
|
|
28
|
+
# Establish the run: resolve +given+ inputs, write the record's first
|
|
29
|
+
# version under the session's id, and log it.
|
|
30
|
+
def initialize(session:, runbook:, inputs: {})
|
|
31
|
+
@session = session
|
|
32
|
+
@runbook = runbook
|
|
33
|
+
@executions = {}
|
|
34
|
+
@challenges = {}
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
@closing = false
|
|
37
|
+
@inputs = Run.resolve_inputs(runbook, inputs, carried: session.carried_inputs)
|
|
38
|
+
@redactor = Redactor.for(@inputs, runbook)
|
|
39
|
+
@record = RunRecord.start(session.runs_root, runbook, session_id: session.id, inputs: @inputs)
|
|
40
|
+
log_opened
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Inputs as they will be exported. For each declared input: the value
|
|
44
|
+
# given, else one given for the same name earlier in the session
|
|
45
|
+
# (+carried+, never a secret), else the environment, else the default.
|
|
46
|
+
def self.resolve_inputs(runbook, given = {}, carried: {})
|
|
47
|
+
given = given.to_h.transform_keys(&:to_s)
|
|
48
|
+
runbook.inputs.to_h do |input|
|
|
49
|
+
name = input.name
|
|
50
|
+
values = [given[name], (carried[name] unless input.secret?), ENV[name], input.default]
|
|
51
|
+
[name, Run.clean(values.find { !it.to_s.empty? })]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# An input value as it can be exported and recorded: valid UTF-8, no NUL.
|
|
56
|
+
def self.clean(value) = value.to_s.dup.force_encoding("UTF-8").scrub.delete("\u0000")
|
|
57
|
+
|
|
58
|
+
# +given+ with every blank secret filled from +current+.
|
|
59
|
+
def self.keep_blank_secrets(runbook, given, current)
|
|
60
|
+
runbook.inputs.select(&:secret?).each_with_object(given.dup) do |input, out|
|
|
61
|
+
out[input.name] = current[input.name] if out[input.name].to_s.empty?
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def slug = runbook.slug
|
|
66
|
+
def open? = record.active?
|
|
67
|
+
|
|
68
|
+
# Raise unless the run is still open.
|
|
69
|
+
def ensure_open!
|
|
70
|
+
raise RunError, "the run for #{slug} has ended" if @closing || !open?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The runbook reloaded from disk (see Session#refresh_runbook!).
|
|
74
|
+
def runbook=(runbook)
|
|
75
|
+
@mutex.synchronize { @runbook = runbook }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Change the inputs partway. Recorded and logged; secrets as "[secret]".
|
|
79
|
+
# A secret left blank keeps its current value.
|
|
80
|
+
def change_inputs(given)
|
|
81
|
+
given = Run.keep_blank_secrets(runbook, given.to_h.transform_keys(&:to_s), @inputs)
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@inputs = Run.resolve_inputs(runbook, given, carried: {})
|
|
84
|
+
@redactor = Redactor.for(@inputs, runbook)
|
|
85
|
+
record.change_inputs(@inputs, runbook)
|
|
86
|
+
record.write!
|
|
87
|
+
end
|
|
88
|
+
log.info("inputs changed #{inputs_text}", tags: [slug])
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Names of secret inputs that have a value, for display as "set".
|
|
93
|
+
def secret_inputs_set = runbook.inputs.select(&:secret?).map(&:name).reject { @inputs[it].to_s.empty? }
|
|
94
|
+
|
|
95
|
+
# Start executing a block. Returns the Execution.
|
|
96
|
+
#
|
|
97
|
+
# A destructive block needs +confirm+ to equal the challenge issued for
|
|
98
|
+
# it; the first attempt without one raises ConfirmationRequired carrying
|
|
99
|
+
# the challenge. +digest+, when given, is the Block#digest the page
|
|
100
|
+
# showed; a block that no longer matches it is refused.
|
|
101
|
+
def execute(block_id, confirm: nil, digest: nil)
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
step, block = executable_block!(block_id)
|
|
104
|
+
Run.same_block!(block, digest)
|
|
105
|
+
if block.destructive? && !confirmed?(block_id, confirm)
|
|
106
|
+
log.warn("destructive block #{block_id} refused: #{confirm.nil? ? 'needs' : 'wrong'} confirmation code", tags: [slug, step.slug])
|
|
107
|
+
raise ConfirmationRequired.new(block_id, challenge_for(block_id))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
execution = build_execution(step, block)
|
|
111
|
+
record.record_execution(execution, step:, confirmed: block.destructive?)
|
|
112
|
+
@executions[execution.id] = execution
|
|
113
|
+
log_execute(execution, step, block)
|
|
114
|
+
execution.on_end { ended(it) }
|
|
115
|
+
session.executor.start(execution, code: block.code, env: environment_for(execution), cwd: working_directory(step),
|
|
116
|
+
redactor: Redactor.new(@redactor.secrets))
|
|
117
|
+
record.write!
|
|
118
|
+
execution
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The step and block for an id, or a RunError saying why it cannot run.
|
|
123
|
+
def executable_block!(block_id)
|
|
124
|
+
ensure_open!
|
|
125
|
+
|
|
126
|
+
step, block = runbook.find_block(block_id)
|
|
127
|
+
raise RunError, "unknown block #{block_id}" unless block
|
|
128
|
+
raise RunError, "block #{block_id} is not executable (#{block.kind})" unless block.executable?
|
|
129
|
+
|
|
130
|
+
blank = blank_inputs(block)
|
|
131
|
+
raise RunError, "blank input#{'s' if blank.size > 1} referenced by block: #{blank.join(', ')}" if blank.any?
|
|
132
|
+
|
|
133
|
+
[step, block]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A pending Execution for a block, with its files allocated in the run.
|
|
137
|
+
def build_execution(step, block)
|
|
138
|
+
cmd_path, log_path = record.paths_for(block)
|
|
139
|
+
id = SecureRandom.hex(6)
|
|
140
|
+
Execution.new(
|
|
141
|
+
id:, block_id: block.id, step_slug: step.slug,
|
|
142
|
+
command: runbook.interpreter_for(block.lang), cmd_path:, log_path:,
|
|
143
|
+
timeout: block.background? ? nil : step.timeout, background: block.background?,
|
|
144
|
+
tee: log.output_stream(["##{id}"])
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The confirmation code currently expected for a destructive block,
|
|
149
|
+
# issuing one if none is outstanding.
|
|
150
|
+
def challenge_for(block_id) = @challenges[block_id] ||= SecureRandom.hex(2)
|
|
151
|
+
|
|
152
|
+
# Stop a running execution. Returns the Execution; the state changes to
|
|
153
|
+
# :stopped once the reaper has ended the process group.
|
|
154
|
+
def stop(execution_id)
|
|
155
|
+
execution = @executions[execution_id] or raise RunError, "no execution #{execution_id}"
|
|
156
|
+
log.info("stop requested", tags: [slug, execution.step_slug, "##{execution.id}"])
|
|
157
|
+
session.executor.stop(execution)
|
|
158
|
+
execution
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Record that the operator ran a terminal block themselves.
|
|
162
|
+
def acknowledge(block_id, note: nil, digest: nil)
|
|
163
|
+
note = Run.note(note)
|
|
164
|
+
ack = @mutex.synchronize do
|
|
165
|
+
ensure_open!
|
|
166
|
+
|
|
167
|
+
step, block = runbook.find_block(block_id)
|
|
168
|
+
raise RunError, "unknown block #{block_id}" unless block
|
|
169
|
+
raise RunError, "block #{block_id} is not a terminal block (#{block.kind})" unless block.acknowledgeable?
|
|
170
|
+
|
|
171
|
+
Run.same_block!(block, digest)
|
|
172
|
+
|
|
173
|
+
record.acknowledge(block, step:, note:)
|
|
174
|
+
record.write!
|
|
175
|
+
record.acks[block.id]
|
|
176
|
+
end
|
|
177
|
+
log.info("confirmed terminal block #{block_id} run by hand#{": #{note}" if note}", tags: [slug, ack[:step]])
|
|
178
|
+
ack
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def ack(block_id) = record.acks[block_id]
|
|
182
|
+
|
|
183
|
+
# A live execution of this run by id, or nil.
|
|
184
|
+
def execution(id) = @executions[id]
|
|
185
|
+
|
|
186
|
+
def executions = @executions.values
|
|
187
|
+
def running_executions = executions.select(&:running?)
|
|
188
|
+
|
|
189
|
+
# Only numbered steps can be marked: the landing page and the extra
|
|
190
|
+
# documents are not part of the procedure's progress.
|
|
191
|
+
def mark_step(step_slug, status:, note: nil)
|
|
192
|
+
note = Run.note(note)
|
|
193
|
+
@mutex.synchronize do
|
|
194
|
+
ensure_open!
|
|
195
|
+
|
|
196
|
+
step = runbook.step(step_slug) or raise RunError, "unknown step #{step_slug}"
|
|
197
|
+
raise RunError, "#{step_slug} is not a numbered step" unless step.position
|
|
198
|
+
|
|
199
|
+
record.mark_step(step, status:, note:)
|
|
200
|
+
record.write!
|
|
201
|
+
end
|
|
202
|
+
log.info("marked #{status}#{": #{note}" if note}", tags: [slug, step_slug])
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def step_status(step_slug) = record.step_status[step_slug]
|
|
206
|
+
|
|
207
|
+
# End the run with the session: stop whatever it left running, and
|
|
208
|
+
# close the record with the status worked out from what was done.
|
|
209
|
+
# Returns that status.
|
|
210
|
+
def close!
|
|
211
|
+
@mutex.synchronize { @closing = true }
|
|
212
|
+
stop_all
|
|
213
|
+
status = record.closing_status(runbook.steps.map(&:slug))
|
|
214
|
+
record.finish!(status:)
|
|
215
|
+
status
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Declared inputs the block refers to that have no value.
|
|
219
|
+
def blank_inputs(block)
|
|
220
|
+
declared = runbook.inputs.map(&:name)
|
|
221
|
+
(block.referenced_variables & declared).select { @inputs[it].to_s.empty? }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def environment_for(execution)
|
|
225
|
+
@inputs.merge(
|
|
226
|
+
"RUNSHEETS_SESSION_ID" => session.id,
|
|
227
|
+
"RUNSHEETS_RUN_ID" => record.id,
|
|
228
|
+
"RUNSHEETS_RUN_DIR" => record.dir,
|
|
229
|
+
"RUNSHEETS_RUNBOOK" => slug,
|
|
230
|
+
"RUNSHEETS_STEP" => execution.step_slug,
|
|
231
|
+
"RUNSHEETS_BLOCK" => execution.block_id
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def working_directory(step)
|
|
236
|
+
step.cwd ? File.expand_path(step.cwd, runbook.dir) : runbook.dir
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Raise RunError unless +block+ is still the one whose Block#digest the
|
|
240
|
+
# page sent. No digest (a script calling the API) skips the check.
|
|
241
|
+
def self.same_block!(block, digest)
|
|
242
|
+
return if digest.nil? || digest == block.digest
|
|
243
|
+
|
|
244
|
+
raise RunError, "block #{block.id} changed on disk since this page loaded; reload the page"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# A note as recorded: stripped, nil when blank.
|
|
248
|
+
def self.note(text)
|
|
249
|
+
text = text.to_s.strip
|
|
250
|
+
text.empty? ? nil : text
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The inputs as the log shows them: NAME=value, secrets as [secret].
|
|
254
|
+
def inputs_text
|
|
255
|
+
runbook.inputs.map { "#{it.name}=#{it.secret? ? '[secret]' : @inputs[it.name]}" }.join(" ")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# How an ended execution reads in the log, and at what level.
|
|
259
|
+
def self.ending(execution)
|
|
260
|
+
took = execution.duration ? format(" in %.2fs", execution.duration) : ""
|
|
261
|
+
case execution.state
|
|
262
|
+
when :finished
|
|
263
|
+
execution.exit_status.zero? ? ["info", "finished exit 0#{took}"] : ["warn", "finished exit #{execution.exit_status}#{took}"]
|
|
264
|
+
when :timed_out then ["warn", "timed out after #{execution.timeout}s"]
|
|
265
|
+
when :stopped then ["info", "stopped#{took}"]
|
|
266
|
+
else ["error", "failed to start: #{execution.error}"]
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
private
|
|
271
|
+
|
|
272
|
+
def log = session.log
|
|
273
|
+
|
|
274
|
+
def log_opened
|
|
275
|
+
log.info("run opened #{inputs_text}".strip, tags: [slug])
|
|
276
|
+
runbook.warnings.each { log.warn("authoring warning: #{it}", tags: [slug]) }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def log_execute(execution, step, block)
|
|
280
|
+
tags = [slug, step.slug, "##{execution.id}"]
|
|
281
|
+
log.info("execute #{block.lang} via #{execution.command.join(' ')}#{' (confirmed)' if block.destructive?}#{' (background)' if block.background?}", tags:)
|
|
282
|
+
log.info(block.code.chomp.lines.map { "$ #{it.chomp}" }.join("\n"), tags: ["##{execution.id}"])
|
|
283
|
+
log.debug("environment #{environment_text(execution)}", tags:)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def environment_text(execution)
|
|
287
|
+
secrets = runbook.inputs.select(&:secret?).map(&:name)
|
|
288
|
+
environment_for(execution).map { |k, v| "#{k}=#{secrets.include?(k) ? '[secret]' : v}" }.join(" ")
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Runs on the reaper thread once an execution has ended: log how it
|
|
292
|
+
# ended and save the record.
|
|
293
|
+
def ended(execution)
|
|
294
|
+
level, text = Run.ending(execution)
|
|
295
|
+
log.add(level, text, tags: ["##{execution.id}"])
|
|
296
|
+
record.write!
|
|
297
|
+
rescue StandardError => e
|
|
298
|
+
log.error("could not save the record after #{execution.id}: #{e.class}: #{e.message}", tags: [slug])
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
public
|
|
302
|
+
|
|
303
|
+
# Ask everything still running to stop, without waiting: the session
|
|
304
|
+
# does this for every run before closing them, so they stop together.
|
|
305
|
+
def stop_running! = running_executions.each { session.executor.stop(it) }
|
|
306
|
+
|
|
307
|
+
private
|
|
308
|
+
|
|
309
|
+
# Does +confirm+ match the code issued for this block? A match retires
|
|
310
|
+
# the code.
|
|
311
|
+
def confirmed?(block_id, confirm)
|
|
312
|
+
return false unless confirm.is_a?(String) && confirm.strip == challenge_for(block_id)
|
|
313
|
+
|
|
314
|
+
@challenges.delete(block_id)
|
|
315
|
+
true
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Stop every running execution and wait for the reapers, briefly.
|
|
319
|
+
def stop_all
|
|
320
|
+
running = running_executions
|
|
321
|
+
running.each { session.executor.stop(it) }
|
|
322
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + STOP_WAIT
|
|
323
|
+
running.each do |execution|
|
|
324
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
325
|
+
execution.wait(left) if left.positive?
|
|
326
|
+
end
|
|
327
|
+
record.write!
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|