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