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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # One markdown file of a runbook: a numbered step, or one of the extra
5
+ # documents (runbook.md preamble, verify.md, rollback.md).
6
+ class Step
7
+ KINDS = %w[automated manual verify].freeze
8
+ DEFAULT_TIMEOUT = 600
9
+
10
+ attr_reader :slug, :path, :position, :data, :body, :html, :blocks, :warnings
11
+
12
+ # Load a markdown file. +root+ is the directory relative links resolve
13
+ # against (see Runbook.load); +position+ is the 1-based order among the steps.
14
+ def self.load(path, root:, position: nil, interpreters: Block::INTERPRETERS)
15
+ new(slug: File.basename(path, ".*"), text: File.read(path, encoding: "BOM|UTF-8"), path:, root:, position:, interpreters:)
16
+ rescue RunbookError => e
17
+ raise RunbookError, "#{File.basename(path)}: #{e.message}" # say which step
18
+ end
19
+
20
+ # +data+ is merged over the front matter parsed from +text+; a
21
+ # single-file runbook supplies each section's title and attributes
22
+ # this way. +interpreters+ decides which block languages can execute.
23
+ def initialize(slug:, text:, path: nil, root: nil, position: nil, data: nil, interpreters: Block::INTERPRETERS)
24
+ @slug = slug
25
+ @path = path
26
+ @position = position
27
+
28
+ parsed = FrontMatter.parse(text)
29
+ @data = parsed.data.merge((data || {}).transform_keys(&:to_s))
30
+ @body = parsed.body
31
+ result = Renderer.render(@body, id_prefix: slug, interpreters:)
32
+ @blocks = result.blocks
33
+ @html = Renderer.rewrite_relative_urls(result.html, relative_dir(path, root))
34
+ @html = Renderer.open_documents_in_new_tab(@html, root) if root
35
+ @warnings = validate.freeze
36
+ end
37
+
38
+ def title
39
+ data["title"] || Renderer.title_of(body) || slug.sub(/\A\d+[-_]?/, "").tr("-_", " ").capitalize
40
+ end
41
+
42
+ def kind = (data["kind"] || (blocks.any?(&:executable?) ? "automated" : "manual")).to_s
43
+
44
+ def automated? = kind == "automated"
45
+ def manual? = kind == "manual"
46
+ def verify? = kind == "verify"
47
+
48
+ def destructive? = data["destructive"] == true || blocks.any?(&:destructive?)
49
+
50
+ # The step's timeout in seconds. An invalid value is warned about (see
51
+ # #validate) and the default used, rather than timing out at once.
52
+ def timeout
53
+ value = data["timeout"]
54
+ value.nil? || !timeout_valid? ? DEFAULT_TIMEOUT : value.to_i
55
+ end
56
+
57
+ def timeout_valid? = data["timeout"].nil? || (data["timeout"].to_s.match?(/\A\d+\z/) && data["timeout"].to_i.positive?)
58
+ # The working directory the step names, or nil. A value that is not a
59
+ # path is warned about (see #validate) and ignored.
60
+ def cwd = cwd_valid? ? data["cwd"] : nil
61
+ def cwd_valid? = data["cwd"].nil? || data["cwd"].is_a?(String)
62
+ def number = slug[/\A\d+/]
63
+
64
+ def block(id) = blocks.find { it.id == id }
65
+ # The text of the expect block that illustrates block +id+, or nil.
66
+ def expected_output(id) = blocks.find { it.expect_for == id }&.code
67
+ def executable_blocks = blocks.select(&:executable?)
68
+
69
+ def to_h
70
+ { slug:, position:, title:, kind:, destructive: destructive?, timeout:, cwd:, warnings:, blocks: blocks.map(&:to_h) }
71
+ end
72
+
73
+ private
74
+
75
+ def relative_dir(path, root)
76
+ return "" unless path && root
77
+
78
+ dir = File.dirname(File.expand_path(path))
79
+ dir.delete_prefix(File.expand_path(root)).delete_prefix("/")
80
+ end
81
+
82
+ def validate
83
+ warnings = blocks.flat_map { |block| block.warnings.map { "block #{block.id}: #{it}" } }
84
+ warnings << "kind '#{kind}' is not one of #{KINDS.join(', ')}" unless KINDS.include?(kind)
85
+ warnings << "automated step has no executable block" if automated? && executable_blocks.empty?
86
+ warnings << "timeout must be a positive number of seconds (using #{DEFAULT_TIMEOUT})" unless timeout_valid?
87
+ warnings << "cwd must be a path (using the runbook directory)" unless cwd_valid?
88
+ warnings
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,537 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "sinatra/base"
5
+
6
+ module Runsheets
7
+ # The session key: every request must hold it, so another account on the
8
+ # same machine that can reach the port still sees nothing. A browser gets
9
+ # it by opening the ?token= link the CLI prints, which the server turns
10
+ # into an HttpOnly cookie; a script sends the token itself.
11
+ module SessionKey
12
+ # The address to open in a browser: the bind address (bracketed when
13
+ # it is IPv6) with the session key.
14
+ def self.url(bind, port, token)
15
+ host = bind.to_s.include?(":") ? "[#{bind.to_s.delete('[]')}]" : bind
16
+ "http://#{host}:#{port}/?token=#{token}"
17
+ end
18
+
19
+ # Is +value+ the session +token+?
20
+ def self.matches?(value, token) = value.is_a?(String) && !token.to_s.empty? && Rack::Utils.secure_compare(value, token.to_s)
21
+
22
+ # The request side, as Sinatra helpers of Web.
23
+ module Helpers
24
+ # Is +value+ this server's session token?
25
+ def key?(value) = SessionKey.matches?(value, settings.rs_token)
26
+
27
+ def token_ok? = [request.env["HTTP_X_RUNSHEETS_TOKEN"], params["_token"]].any? { key?(it) }
28
+
29
+ # Cookies are shared by every port on a host, so the name carries the
30
+ # port: two servers on one machine keep separate keys.
31
+ def cookie_name = "runsheets_#{settings.port}"
32
+
33
+ # Does this request hold the session key, as the cookie or the token?
34
+ def key_ok? = key?(request.cookies[cookie_name]) || token_ok?
35
+
36
+ # GET /anything?token=KEY (or HEAD): keep the key in an HttpOnly cookie and drop
37
+ # it from the address bar.
38
+ def accept_key_from_link
39
+ return unless (request.get? || request.head?) && key?(params["token"])
40
+
41
+ response.set_cookie(cookie_name, value: settings.rs_token, path: "/", httponly: true, same_site: :lax)
42
+ redirect request.path_info
43
+ end
44
+
45
+ # Stop a request without the key before anything of the session is
46
+ # shown: the error pages carry the token too.
47
+ def refuse_without_key
48
+ return if key_ok?
49
+
50
+ halt 401, (wants_json? ? json({ error: "missing session key: open the link runsheets printed" }, status: 401) : page { Pages.locked(nonce: it) })
51
+ end
52
+ end
53
+ end
54
+
55
+ # The local web application. Binds to loopback, serves the runbook pages,
56
+ # and exposes the execute/poll endpoints the page JavaScript uses.
57
+ #
58
+ # Security posture:
59
+ # - Host header must be a loopback name or the bind address (rack-protection
60
+ # HostAuthorization), which defeats DNS rebinding. A wildcard bind cannot
61
+ # enumerate its hosts, so the check is off for one; the CLI warns.
62
+ # - Every request needs the session key: the browser gets it as an
63
+ # HttpOnly cookie by opening the ?token= link the CLI prints, and a
64
+ # script can send the token itself. Another account on the same machine
65
+ # can reach the port but has neither, so it sees no page and no token.
66
+ # - Every non-GET request must also carry the session token, either in the
67
+ # X-Runsheets-Token header (fetch) or the _token form field. A page on
68
+ # another origin cannot read the token or send the custom header without
69
+ # a CORS preflight this app never answers.
70
+ # - Every page carries a Content-Security-Policy whose script and style
71
+ # sources are a per-response nonce held only by the page's own inline
72
+ # script and stylesheet. Raw HTML in runbook markdown renders, but a
73
+ # <script> in it does not run, so a runbook cannot drive the token.
74
+ class Web < Sinatra::Base
75
+ OUTPUT_TAIL = 256 * 1024
76
+ RUN_ID = /\A[\w.-]+\z/
77
+ LOOPBACK_HOSTS = ["localhost", IPAddr.new("127.0.0.1"), IPAddr.new("::1")].freeze
78
+ WILDCARDS = %w[0.0.0.0 :: [::]].freeze
79
+ # Served files run no script and load nothing, even inline.
80
+ FILE_CSP = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
81
+
82
+ set :rs_session, nil
83
+ set :static, false
84
+ set :show_exceptions, false
85
+ set :raise_errors, false
86
+ set :dump_errors, false
87
+ set :logging, false
88
+ set :server, %w[puma webrick]
89
+ # Puma titles its process "puma <version> (<bind>) [<tag>]" and guesses
90
+ # the tag from the working directory; name it so `pgrep -f runsheets`
91
+ # finds the server wherever it was started.
92
+ set :server_settings, { tag: "runsheets" }
93
+ set :rs_bind, "127.0.0.1"
94
+ # The loopback names are always permitted. Anything the bind address
95
+ # adds is decided per request (see .bind_allows?), because the
96
+ # middleware stack captures this hash the first time the app is built.
97
+ set :host_authorization, { permitted_hosts: LOOPBACK_HOSTS.dup, allow_if: ->(env) { Web.bind_allows?(env) } }
98
+
99
+ set :rs_library, nil
100
+ set :rs_runbook, nil
101
+ set :rs_token, nil
102
+ set :rs_session_options, {}
103
+ set :rs_engineer, nil
104
+ set :rs_stopper, nil
105
+
106
+ # Wire the app up and record the bind address the Host check should
107
+ # honour. +session+ is the engineering session when it has already
108
+ # started, else nil and the start page asks who and why (see
109
+ # .prepare_start). The server serves a +library+ of runbooks or one
110
+ # +runbook+. Returns the class.
111
+ def self.configure_for(session, bind: "127.0.0.1", port: 4567, library: nil, runbook: nil)
112
+ runbook ||= session&.runbook unless library
113
+ raise ArgumentError, "a library or a runbook is needed" if library.nil? && runbook.nil?
114
+
115
+ set :rs_session, session
116
+ set :rs_library, library
117
+ set :rs_runbook, runbook
118
+ set :rs_token, session&.token || SecureRandom.hex(16)
119
+ set :bind, bind
120
+ set :port, port
121
+ set :rs_bind, bind
122
+ self
123
+ end
124
+
125
+ # What a session started from the start page is built with (runs_root,
126
+ # log_level, echo), and who the page suggests as the engineer.
127
+ def self.prepare_start(session_options: {}, engineer: nil)
128
+ set :rs_session_options, session_options
129
+ set :rs_engineer, engineer
130
+ self
131
+ end
132
+
133
+ # What End session does once the session is closed: stop this process,
134
+ # just after the response is sent, the way Ctrl-C would. A test sets
135
+ # :rs_stopper to anything with #call that is not a Proc (Sinatra calls a
136
+ # Proc setting when it is read).
137
+ def self.stop_process = Thread.new { sleep 0.5; Process.kill("INT", Process.pid) } # rubocop:disable Style/Semicolon
138
+
139
+ # The engineering session, once it has started.
140
+ def self.session = settings.rs_session
141
+
142
+ @start_lock = Mutex.new
143
+
144
+ # Start the session from the start page's answers. Puma serves requests
145
+ # on several threads, so a double-submitted form must not make two: the
146
+ # second caller gets the session the first one started.
147
+ def self.start_session(engineer:, why:)
148
+ @start_lock.synchronize { settings.rs_session || set(:rs_session, Session.new(engineer:, why:, **session_sources)).rs_session }
149
+ end
150
+
151
+ # What a session started here is built from, beside who and why.
152
+ def self.session_sources = { library: settings.rs_library, runbook: settings.rs_runbook, token: settings.rs_token, **settings.rs_session_options }
153
+
154
+ # +path+ when it is a path on this server, else +fallback+. "//host" and
155
+ # "/\host" are rejected: browsers read both as another site.
156
+ def self.local_path(path, fallback) = path.to_s.match?(%r{\A/(?![/\\])}) ? path : fallback
157
+
158
+ # Does the bind address let this request's Host through, beyond the
159
+ # loopback names? A non-loopback bind permits its own address. A
160
+ # wildcard bind answers on every interface, so no host list can be
161
+ # right and the check is off; the CLI warns about that.
162
+ def self.bind_allows?(env)
163
+ bind = settings.rs_bind
164
+ return true if wildcard?(bind)
165
+ return false if loopback?(bind)
166
+
167
+ same_host?(Rack::Request.new(env).host, bind)
168
+ rescue StandardError
169
+ false
170
+ end
171
+
172
+ def self.same_host?(host, bind)
173
+ return true if host == bind
174
+
175
+ IPAddr.new(host.to_s) == IPAddr.new(bind.to_s)
176
+ rescue IPAddr::Error
177
+ false
178
+ end
179
+
180
+ def self.wildcard?(bind) = WILDCARDS.include?(bind.to_s)
181
+
182
+ def self.loopback?(bind)
183
+ return true if bind.nil? || bind == "localhost"
184
+
185
+ IPAddr.new(bind.to_s).loopback?
186
+ rescue IPAddr::Error
187
+ false
188
+ end
189
+
190
+ # Resolve a relative path inside root. Returns nil when it escapes root
191
+ # (through ".." or a symlink), names a hidden entry, or is not a
192
+ # regular file. The returned path is the real path.
193
+ def self.resolve_file(root, relpath)
194
+ parts = relpath.to_s.split("/").reject { it.empty? || it == "." }
195
+ return nil if parts.any? { it.start_with?(".") }
196
+
197
+ root = File.realpath(root)
198
+ path = File.expand_path(parts.join("/"), root)
199
+ return nil unless path.start_with?("#{root}/") && File.file?(path)
200
+
201
+ real = File.realpath(path)
202
+ real.start_with?("#{root}/") ? real : nil
203
+ rescue SystemCallError
204
+ nil
205
+ end
206
+
207
+ # inline for an image, a PDF or plain text; attachment for anything
208
+ # else, so an HTML or XML file in a runbook directory can never become a
209
+ # page on this origin.
210
+ def self.disposition_for(path) = %w[.png .jpg .jpeg .gif .webp .svg .pdf .txt].include?(File.extname(path).downcase) ? "inline" : "attachment"
211
+
212
+ # A fresh CSP nonce for one response.
213
+ def self.nonce = SecureRandom.base64(16)
214
+
215
+ def self.csp(nonce)
216
+ "default-src 'none'; script-src 'nonce-#{nonce}'; style-src 'nonce-#{nonce}'; style-src-attr 'unsafe-inline'; " \
217
+ "img-src 'self' data: https: http:; connect-src 'self'; form-action 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'"
218
+ end
219
+
220
+ helpers SessionKey::Helpers
221
+
222
+ helpers do
223
+ def rs = settings.rs_session
224
+ def library = settings.rs_library
225
+ def runbook = rs.runbook
226
+
227
+ def library_route? = request.path_info == "/library" || request.path_info.start_with?("/library/")
228
+
229
+ # The start page and its form: the only pages before a session exists.
230
+ def start_route? = request.path_info == "/session/new" || (request.post? && request.path_info == "/session")
231
+
232
+ # Pages about the session itself, which need no runbook on screen.
233
+ def session_route? = request.path_info == "/session" || request.path_info.start_with?("/session/")
234
+
235
+ # Pages that work before any runbook is on screen: the library, the
236
+ # session, selecting a runbook, search, and the files and documents
237
+ # runbooks link to, which the library's runbook pane already shows.
238
+ def open_route?
239
+ path = request.path_info
240
+ library_route? || session_route? || path == "/runs" || (library && (path == "/search" || path.start_with?("/docs/", "/files/")))
241
+ end
242
+
243
+ # The directory /files/ and /docs/ serve from: the library, or the
244
+ # one runbook's root.
245
+ def content_root = library&.dir || runbook.root
246
+
247
+ # The page's script asks for JSON; a browser navigating asks for HTML
248
+ # (its Accept ends in */*, which matches JSON too, so ask which it prefers).
249
+ def wants_json?
250
+ request.path_info.start_with?("/blocks/", "/executions/") || request.preferred_type("text/html", "application/json") == "application/json"
251
+ end
252
+
253
+ def json(data, status: 200)
254
+ content_type :json
255
+ self.status status
256
+ JSON.generate(data)
257
+ end
258
+
259
+ # Render a page: yields the nonce the page must put on its inline
260
+ # script and style, and sets the headers that go with it. Pages carry
261
+ # the session token, so they are never cached.
262
+ def page
263
+ nonce = Web.nonce
264
+ headers "Content-Security-Policy" => Web.csp(nonce), "Cache-Control" => "no-store"
265
+ content_type :html
266
+ yield nonce
267
+ end
268
+
269
+ # An error as JSON for the page's script; otherwise as a page, in the
270
+ # runbook's layout, the library's frame, or alone.
271
+ def fail_with(message, status)
272
+ env["runsheets.failed"] = true
273
+ body = if wants_json? then json({ error: message }, status:)
274
+ elsif rs&.runbook then page { Pages.error(rs, message, status, nonce: it) }
275
+ else page { Pages.error_page(message, status, library:, session: rs, nonce: it) }
276
+ end
277
+ halt status, body
278
+ end
279
+
280
+ # The run an action from a page belongs to. A page says which runbook
281
+ # it shows (the runbook field), so a tab left on one runbook keeps
282
+ # acting on it after another tab has opened a different one.
283
+ def target_run
284
+ slug = params["runbook"].to_s
285
+ return rs.current! if slug.empty?
286
+
287
+ rs.run_for(slug) or raise RunError, "#{slug} has no run in this session; start it first"
288
+ end
289
+
290
+ # An execution as the page polls it, with its output compared against
291
+ # the block's expect block once it has ended.
292
+ def execution_json(execution)
293
+ size = execution.output_size
294
+ output = execution.output(tail: OUTPUT_TAIL)
295
+ execution.to_h.merge(output:, output_size: size, output_truncated: size > OUTPUT_TAIL, success: execution.success?,
296
+ expected: Expectation.for_page(rs.expected_output(execution), output, execution.state))
297
+ end
298
+
299
+ # The runbook POST /runs selects: by slug from the library, freshly
300
+ # loaded, or the one runbook served.
301
+ def runbook_to_open = (settings.rs_runbook && rs.runbook) || library_runbook(params["slug"].to_s)
302
+
303
+ def library_runbook(slug)
304
+ fail_with("no runbook named #{slug}", 404) unless library.find(slug)
305
+ library.runbook(slug)
306
+ end
307
+
308
+ def after_step_href(step)
309
+ _, nxt = runbook.neighbors(step)
310
+ nxt ? Pages.step_href(nxt) : "/"
311
+ end
312
+ end
313
+
314
+ before do
315
+ reading = request.get? || request.head?
316
+ accept_key_from_link
317
+ refuse_without_key
318
+ fail_with("missing or invalid session token", 403) unless reading || token_ok?
319
+ if start_route?
320
+ redirect "/session" if rs
321
+ next
322
+ end
323
+
324
+ unless rs
325
+ redirect "/session/new" if reading
326
+ fail_with("start the session first: who is starting it, and why", 409)
327
+ end
328
+ fail_with("this session has ended; start runsheets again for a new one", 410) if rs.ended? && request.path_info != "/session"
329
+ rs.log.debug("#{request.request_method} #{request.fullpath}", tags: ["web"])
330
+ next if open_route?
331
+
332
+ redirect "/library" unless rs.runbook
333
+ rs.refresh_runbook! if reading
334
+ end
335
+
336
+ # -- the session ----------------------------------------------------
337
+
338
+ # Who is starting the session, and why. Once it has started, the
339
+ # session page instead.
340
+ get "/session/new" do
341
+ page { Pages.session_new(settings.rs_token, engineer: settings.rs_engineer, nonce: it) }
342
+ end
343
+
344
+ post "/session" do
345
+ engineer = params["engineer"].to_s
346
+ why = params["why"].to_s
347
+ if engineer.strip.empty? || why.strip.empty?
348
+ status 422
349
+ next page { Pages.session_new(settings.rs_token, engineer:, why:, error: "Both are needed: who you are, and why the session is starting.", nonce: it) }
350
+ end
351
+ Web.start_session(engineer:, why:)
352
+ redirect(library ? "/library" : "/")
353
+ end
354
+
355
+ # The notebook so far: the engineer, the notes, the runs, the log.
356
+ get "/session" do
357
+ page { Pages.session(rs, library, settings.rs_token, nonce: it) }
358
+ end
359
+
360
+ post "/session/notes" do
361
+ if params["note"].to_s.strip.empty?
362
+ status 422
363
+ next page { Pages.session(rs, library, settings.rs_token, error: "A note needs some text.", nonce: it) }
364
+ end
365
+ rs.note!(params["note"])
366
+ redirect(Web.local_path(params["back"], "/session#notes"))
367
+ end
368
+
369
+ # End the session: every run closes with the status its work earns,
370
+ # and the server stops.
371
+ post "/session/end" do
372
+ rs.end!("End session button")
373
+ (settings.rs_stopper || Web.method(:stop_process)).call
374
+ page { Pages.session_ended(rs, nonce: it) }
375
+ end
376
+
377
+ # -- library: choosing a runbook -----------------------------------
378
+
379
+ # The library, or a 404 when the server was started on one runbook.
380
+ # Reading it picks up runbooks added, removed or edited since.
381
+ before "/library*" do
382
+ fail_with("runsheets was started on one runbook, not a directory of them", 404) unless library
383
+ library.refresh! if request.get? || request.head?
384
+ end
385
+
386
+ # The tree with nothing selected: the root folder in the main pane.
387
+ get "/library" do
388
+ page { Pages.library(library, rs, settings.rs_token, nonce: it) }
389
+ end
390
+
391
+ # A runbook or a folder selected in the tree, by its path in the library.
392
+ get "/library/*" do
393
+ slug = params["splat"].first.to_s.delete_suffix("/")
394
+ fail_with("nothing named #{slug} in the library", 404) unless library.node(slug)
395
+ page { Pages.library(library, rs, settings.rs_token, nonce: it, selected: slug) }
396
+ end
397
+
398
+ # A destructive block without its typed confirmation: tell the page the
399
+ # code to ask for. 428 Precondition Required.
400
+ error Run::ConfirmationRequired do
401
+ e = env["sinatra.error"]
402
+ json({ error: e.message, challenge: e.challenge, block_id: e.block_id }, status: 428)
403
+ end
404
+
405
+ error RunError do
406
+ message = env["sinatra.error"].message
407
+ rs&.log&.warn("refused #{request.request_method} #{request.path_info}: #{message}", tags: ["web"])
408
+ fail_with(message, 409)
409
+ end
410
+
411
+ error RunbookError do
412
+ message = env["sinatra.error"].message
413
+ rs&.log&.error(message, tags: ["web"])
414
+ fail_with(message, 500)
415
+ end
416
+
417
+ error StandardError do
418
+ e = env["sinatra.error"]
419
+ rs&.log&.error("#{e.class}: #{e.message} at #{e.backtrace&.first}", tags: ["web"])
420
+ fail_with("internal error: #{e.message}", 500)
421
+ end
422
+
423
+ not_found do
424
+ next if env["runsheets.failed"] # fail_with already said what was not found
425
+
426
+ fail_with("not found: #{request.path_info}", 404)
427
+ end
428
+
429
+ # -- search ---------------------------------------------------------
430
+
431
+ # Full-text search over every runbook in the library, or over the one
432
+ # runbook the server was started on.
433
+ get "/search" do
434
+ library&.refresh!
435
+ rs.log.debug("search #{params['q'].to_s.inspect}", tags: ["web"])
436
+ page { Pages.search(rs, library, params["q"].to_s, token: settings.rs_token, nonce: it) }
437
+ end
438
+
439
+ # -- pages ----------------------------------------------------------
440
+
441
+ get "/" do
442
+ page { Pages.landing(rs, nonce: it) }
443
+ end
444
+
445
+ get "/steps/:slug" do
446
+ step = runbook.step(params["slug"]) or fail_with("no step named #{params['slug']}", 404)
447
+ page { Pages.step(rs, step, nonce: it) }
448
+ end
449
+
450
+ get "/files/*" do
451
+ path = Web.resolve_file(content_root, params["splat"].first) or fail_with("no such file", 404)
452
+ headers "Content-Security-Policy" => FILE_CSP, "X-Content-Type-Options" => "nosniff"
453
+ send_file path, disposition: Web.disposition_for(path)
454
+ end
455
+
456
+ # A markdown file the runbook links to. One of the runbook's own files
457
+ # goes to its page, another runbook in the library to its library page,
458
+ # and anything else is rendered as a plain document.
459
+ get "/docs/*" do
460
+ path = Web.resolve_file(content_root, params["splat"].first) or fail_with("no such file", 404)
461
+ fail_with("not a markdown document", 404) unless Renderer.markdown_path?(path)
462
+ if (doc = rs&.runbook&.document_at(path))
463
+ redirect(doc == runbook.landing ? "/" : Pages.step_href(doc))
464
+ end
465
+ entry = library&.entry_at(path) and redirect(Pages::Chooser.href(entry.slug))
466
+ page { Pages.document(rs, path, library:, token: settings.rs_token, nonce: it) }
467
+ end
468
+
469
+ get "/verify" do
470
+ fail_with("this runbook has no verify steps or verify.md", 404) if runbook.verify_documents.empty?
471
+ page { Pages.verify(rs, nonce: it) }
472
+ end
473
+
474
+ get "/run" do
475
+ record = rs.run || rs.history.first or fail_with("no run has been started", 404)
476
+ page { Pages.run(rs, record, nonce: it) }
477
+ end
478
+
479
+ get "/runs/:id" do
480
+ id = params["id"]
481
+ fail_with("bad run id", 404) unless id.match?(RUN_ID)
482
+ dir = File.join(rs.runs_root, runbook.slug, id)
483
+ fail_with("no run #{id}", 404) unless File.file?(File.join(dir, "run.json"))
484
+ page { Pages.run(rs, RunRecord.load(dir), nonce: it) }
485
+ end
486
+
487
+ # -- runs -------------------------------------------------------------
488
+
489
+ # Select a runbook: establish its run with the inputs given, or return
490
+ # to the run it already has, and land on its page. In a library the
491
+ # slug names the runbook; otherwise it is the one runbook served.
492
+ post "/runs" do
493
+ inputs = params["inputs"].is_a?(Hash) ? params["inputs"] : {}
494
+ rs.open(runbook_to_open, inputs:)
495
+ redirect "/"
496
+ end
497
+
498
+ # Change the inputs of the run on screen partway.
499
+ post "/run/inputs" do
500
+ target_run.change_inputs(params["inputs"].is_a?(Hash) ? params["inputs"] : {})
501
+ redirect "/"
502
+ end
503
+
504
+ post "/steps/:slug/mark" do
505
+ run = target_run
506
+ step = run.runbook.step(params["slug"]) or fail_with("no step named #{params['slug']}", 404)
507
+ fail_with("#{step.slug} is not a numbered step", 422) unless step.position
508
+ status = params["status"]
509
+ fail_with("status must be done or skipped", 422) unless %w[done skipped].include?(status)
510
+ run.mark_step(step.slug, status:, note: params["note"])
511
+ rs.show(run.runbook) unless run == rs.current
512
+ redirect after_step_href(step)
513
+ end
514
+
515
+ # -- execution ------------------------------------------------------
516
+
517
+ post "/blocks/:id/execute" do
518
+ execution = target_run.execute(params["id"], confirm: params["confirm"], digest: params["digest"])
519
+ json execution_json(execution), status: 202
520
+ end
521
+
522
+ post "/blocks/:id/acknowledge" do
523
+ ack = target_run.acknowledge(params["id"], note: params["note"], digest: params["digest"])
524
+ json ack.merge(block_id: params["id"]), status: 201
525
+ end
526
+
527
+ get "/executions/:id" do
528
+ execution = rs.execution(params["id"]) or fail_with("no execution #{params['id']}", 404)
529
+ json execution_json(execution)
530
+ end
531
+
532
+ post "/executions/:id/stop" do
533
+ execution = rs.stop(params["id"])
534
+ json execution_json(execution), status: 202
535
+ end
536
+ end
537
+ end