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