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,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runsheets
|
|
4
|
+
# A fenced code block and the behaviour its info string asks for.
|
|
5
|
+
#
|
|
6
|
+
# ```bash display only
|
|
7
|
+
# ```bash run Run button
|
|
8
|
+
# ```bash destructive Run button behind a typed confirmation (implies run)
|
|
9
|
+
# ```bash background Start and Stop buttons; output streams until stopped
|
|
10
|
+
# ```bash terminal operator runs it in their own terminal and confirms
|
|
11
|
+
# ```text expect expected output of the preceding executable block
|
|
12
|
+
class Block
|
|
13
|
+
KINDS = %i[display run background destructive terminal expect].freeze
|
|
14
|
+
|
|
15
|
+
FLAG_KINDS = {
|
|
16
|
+
"run" => :run,
|
|
17
|
+
"background" => :background,
|
|
18
|
+
"destructive" => :destructive,
|
|
19
|
+
"terminal" => :terminal,
|
|
20
|
+
"expect" => :expect
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Kinds the tool spawns a process for.
|
|
24
|
+
EXECUTABLE_KINDS = %i[run destructive background].freeze
|
|
25
|
+
|
|
26
|
+
# Languages that can execute and the command that runs a file of them.
|
|
27
|
+
# A runbook can add or override entries in its front matter.
|
|
28
|
+
INTERPRETERS = {
|
|
29
|
+
"bash" => %w[bash],
|
|
30
|
+
"sh" => %w[sh],
|
|
31
|
+
"zsh" => %w[zsh],
|
|
32
|
+
"ruby" => %w[ruby]
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :id, :index, :lang, :flags, :code, :line, :kind, :warnings, :interpreters
|
|
36
|
+
|
|
37
|
+
# For an :expect block, the id of the executable block it illustrates
|
|
38
|
+
# (the nearest one above it). Set by Renderer once every block is known.
|
|
39
|
+
attr_accessor :expect_for
|
|
40
|
+
|
|
41
|
+
# +interpreters+ is the language => command map in force for the
|
|
42
|
+
# runbook (the defaults plus its front matter); a language outside it
|
|
43
|
+
# cannot execute whatever its flags say.
|
|
44
|
+
def initialize(id:, index:, info:, code:, line: nil, interpreters: INTERPRETERS)
|
|
45
|
+
@id = id
|
|
46
|
+
@index = index
|
|
47
|
+
@code = code
|
|
48
|
+
@line = line
|
|
49
|
+
@interpreters = interpreters
|
|
50
|
+
@lang, *@flags = info.to_s.split
|
|
51
|
+
@lang = @lang.to_s
|
|
52
|
+
@flags.freeze
|
|
53
|
+
@kind, @warnings = Block.classify(@flags, @lang, interpreters)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The kind a set of flags asks for, plus any authoring warnings.
|
|
57
|
+
def self.classify(flags, lang, interpreters = INTERPRETERS)
|
|
58
|
+
warnings = []
|
|
59
|
+
unknown = flags.reject { FLAG_KINDS.key?(it) }
|
|
60
|
+
warnings << "unknown flag#{'s' if unknown.size > 1}: #{unknown.join(', ')}" if unknown.any?
|
|
61
|
+
|
|
62
|
+
kinds = flags.filter_map { FLAG_KINDS[it] }.uniq
|
|
63
|
+
kind = kind_for(kinds)
|
|
64
|
+
warnings << "conflicting flags: #{kinds.join(', ')}; using #{kind}" if conflicting?(kinds, kind)
|
|
65
|
+
|
|
66
|
+
if EXECUTABLE_KINDS.include?(kind) && !interpreters.key?(lang)
|
|
67
|
+
warnings << if lang.empty?
|
|
68
|
+
"a block without a language cannot execute"
|
|
69
|
+
else
|
|
70
|
+
"#{lang} blocks cannot execute; displayed only (map it under interpreters in the runbook front matter)"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
[kind, warnings.freeze]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The kind a list of flag kinds asks for, whatever their order:
|
|
78
|
+
# destructive wins, then the first kind other than run, then run.
|
|
79
|
+
def self.kind_for(kinds)
|
|
80
|
+
others = kinds - [:run]
|
|
81
|
+
if others.include?(:destructive) then :destructive
|
|
82
|
+
elsif others.any? then others.first
|
|
83
|
+
elsif kinds.any? then :run
|
|
84
|
+
else :display
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# True when a flag asks for something +kind+ does not do. run beside
|
|
89
|
+
# destructive or background is redundant (both run); run beside
|
|
90
|
+
# terminal or expect is a contradiction.
|
|
91
|
+
def self.conflicting?(kinds, kind)
|
|
92
|
+
(kinds - [:run, kind]).any? || (kinds.include?(:run) && !EXECUTABLE_KINDS.include?(kind))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def executable? = EXECUTABLE_KINDS.include?(kind) && interpreters.key?(lang)
|
|
96
|
+
def destructive? = kind == :destructive
|
|
97
|
+
def background? = kind == :background
|
|
98
|
+
def terminal? = kind == :terminal
|
|
99
|
+
def expect? = kind == :expect
|
|
100
|
+
def display? = kind == :display
|
|
101
|
+
def label = flags.empty? ? lang : "#{lang} #{flags.join(' ')}"
|
|
102
|
+
|
|
103
|
+
# A short fingerprint of what the block says and does. The page sends
|
|
104
|
+
# it back with Run and I ran this, so an edit made after the page loaded
|
|
105
|
+
# cannot turn a click into a different block.
|
|
106
|
+
def digest = Digest::SHA256.hexdigest("#{label}\0#{code}")[0, 16]
|
|
107
|
+
|
|
108
|
+
# Terminal blocks are not executed; the operator confirms they ran them.
|
|
109
|
+
def acknowledgeable? = terminal?
|
|
110
|
+
|
|
111
|
+
# Names of $VARIABLES the code refers to.
|
|
112
|
+
def referenced_variables = code.scan(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/).flatten.uniq
|
|
113
|
+
|
|
114
|
+
def to_h
|
|
115
|
+
{ id:, index:, lang:, flags:, kind:, line:, executable: executable?, expect_for:, warnings:, code: }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module Runsheets
|
|
8
|
+
# The `runsheets` command: load a runbook (a directory or a single markdown
|
|
9
|
+
# file) and serve it, check it, or scaffold a new one.
|
|
10
|
+
#
|
|
11
|
+
# Settings come from Config: bundled defaults, then the config file, then
|
|
12
|
+
# RUNSHEETS_* variables, then whatever is given on the command line.
|
|
13
|
+
module CLI
|
|
14
|
+
PROGRAM = "runsheets"
|
|
15
|
+
|
|
16
|
+
# The runbook served when none is named: the bundled hello example,
|
|
17
|
+
# found relative to this file so it works from a checkout and from an
|
|
18
|
+
# installed gem alike.
|
|
19
|
+
DEFAULT_RUNBOOK = File.expand_path("../../examples/directory_based_runbooks/hello", __dir__)
|
|
20
|
+
|
|
21
|
+
# Parse argv into a hash holding only what was given: Config attribute
|
|
22
|
+
# names for the options, :config for the config file. Raises
|
|
23
|
+
# OptionParser::ParseError on bad input; returns nil after printing help
|
|
24
|
+
# or the version.
|
|
25
|
+
def self.parse(argv, out: $stdout)
|
|
26
|
+
options = {}
|
|
27
|
+
defaults = Config.bundled_defaults
|
|
28
|
+
parser = OptionParser.new do |opts|
|
|
29
|
+
opts.banner = <<~BANNER
|
|
30
|
+
#{PROGRAM} #{VERSION} - executable runbooks in your browser
|
|
31
|
+
|
|
32
|
+
Usage: #{PROGRAM} [options] [RUNBOOK]
|
|
33
|
+
|
|
34
|
+
RUNBOOK is a directory holding runbook.md and a steps/ directory of
|
|
35
|
+
markdown files, a single all-in-one markdown file whose ## headings
|
|
36
|
+
are the steps, or a directory of such runbooks to choose from in the
|
|
37
|
+
browser. Without one, the bundled example is served:
|
|
38
|
+
#{DEFAULT_RUNBOOK}
|
|
39
|
+
Fenced blocks marked `bash run`, `ruby run`, `bash destructive`
|
|
40
|
+
or `bash background` get buttons. Everything from start to Ctrl-C is
|
|
41
|
+
one session, recorded with its log under #{defaults[:runs_dir]}
|
|
42
|
+
|
|
43
|
+
Settings are layered: the user config #{Config.xdg_path},
|
|
44
|
+
then ./config/runsheets.yml (or the file --config names), then the
|
|
45
|
+
environment variable named beside each option, then the command line,
|
|
46
|
+
which wins. RUNBOOK itself can be set as `dir:` in a config file or
|
|
47
|
+
with RUNSHEETS_DIR. Flags take 1, true, yes or on; --no-open,
|
|
48
|
+
--no-check and --no-init switch a flag off that a lower layer turned on.
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
BANNER
|
|
52
|
+
|
|
53
|
+
server_options(opts, options, defaults)
|
|
54
|
+
session_flags(opts, options, defaults)
|
|
55
|
+
opts.on("-v", "--version", "Print the version and exit") do
|
|
56
|
+
out.puts "#{PROGRAM} #{VERSION}"
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
opts.on("-h", "--help", "Show this help and exit") do
|
|
60
|
+
out.puts opts
|
|
61
|
+
return nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
rest = parser.parse(argv)
|
|
66
|
+
raise OptionParser::ParseError, "too many arguments: #{rest.join(' ')}" if rest.size > 1
|
|
67
|
+
|
|
68
|
+
options[:dir] = rest.first if rest.first
|
|
69
|
+
options
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The options about serving: config file, port, bind, records, and the
|
|
73
|
+
# one-shot actions.
|
|
74
|
+
def self.server_options(opts, options, defaults)
|
|
75
|
+
opts.on("-c", "--config FILE", "Config file to read in place of ./config/runsheets.yml [RUNSHEETS_CONFIG]") { options[:config] = it }
|
|
76
|
+
opts.on("-p", "--port PORT", Integer, "Port to listen on (default: #{defaults[:port]}) [RUNSHEETS_PORT]") { options[:port] = it }
|
|
77
|
+
opts.on("-b", "--bind HOST", "Address to bind to (default: #{defaults[:bind]}) [RUNSHEETS_BIND]") { options[:bind] = it }
|
|
78
|
+
opts.on("--runs-dir DIR", "Where run records are written (default: #{defaults[:runs_dir]}) [RUNSHEETS_RUNS_DIR]") { options[:runs_dir] = it }
|
|
79
|
+
opts.on("-o", "--[no-]open", "Open the browser once the server is up [RUNSHEETS_OPEN]") { options[:open] = it }
|
|
80
|
+
opts.on("--[no-]check", "Load the runbook, report authoring warnings, and exit [RUNSHEETS_CHECK]") { options[:check] = it }
|
|
81
|
+
opts.on("--[no-]init", "Create a new runbook skeleton at RUNBOOK (a directory, or a .md file) and exit [RUNSHEETS_INIT]") { options[:init] = it }
|
|
82
|
+
opts.on("--[no-]dump", "Print the settings in force as a config file to stdout and exit [RUNSHEETS_DUMP]") { options[:dump] = it }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The options about the session: who, why, and its log.
|
|
86
|
+
def self.session_flags(opts, options, defaults)
|
|
87
|
+
opts.on("-e", "--engineer NAME", "Who is starting the session (default: ask) [RUNSHEETS_ENGINEER]") { options[:engineer] = it }
|
|
88
|
+
opts.on("-w", "--why TEXT", "Why: the session's first note; with --engineer, skips the start page [RUNSHEETS_WHY]") { options[:why] = it }
|
|
89
|
+
opts.on("--log-level LEVEL", "Session log level: #{SessionLog::LEVELS.join(', ')} (default: #{defaults[:log_level]}) [RUNSHEETS_LOG_LEVEL]") { options[:log_level] = it }
|
|
90
|
+
opts.on("--verbose", "Log page views, searches and polling too (--log-level debug)") { options[:log_level] = "debug" }
|
|
91
|
+
opts.on("-q", "--[no-]quiet", "Do not echo the session log to the terminal [RUNSHEETS_QUIET]") { options[:quiet] = it }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build the effective settings from parsed options and install them as
|
|
95
|
+
# Runsheets.config. The command line beats the environment, which beats
|
|
96
|
+
# the config file, which beats the bundled defaults.
|
|
97
|
+
def self.configure(options)
|
|
98
|
+
Runsheets.configure(options.except(:config), path: options[:config]).tap { check_bind_source(it) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Refuse a non-loopback bind that only the project config in the
|
|
102
|
+
# current directory asks for: a cloned repository could otherwise open
|
|
103
|
+
# runsheets to the network. --bind, RUNSHEETS_BIND, --config and the
|
|
104
|
+
# user config can still choose one.
|
|
105
|
+
def self.check_bind_source(config)
|
|
106
|
+
bind = config.project_bind
|
|
107
|
+
return if bind.nil? || Web.loopback?(bind)
|
|
108
|
+
|
|
109
|
+
raise ConfigError, "#{config.path} sets bind: #{bind}, which is not a loopback address. A project config cannot open " \
|
|
110
|
+
"runsheets to the network; pass --bind #{bind} (or set RUNSHEETS_BIND) if that is what you want"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --dump: print the settings in force as a config file. Redirect it to
|
|
114
|
+
# save them: `runsheets --dump -p 4580 > ~/.config/runsheets/runsheets.yml`.
|
|
115
|
+
def self.dump(config, out: $stdout)
|
|
116
|
+
out.print config.to_config_yaml
|
|
117
|
+
0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The runbook a configuration points at: its dir, else the bundled example.
|
|
121
|
+
# --init insists on an explicit one.
|
|
122
|
+
def self.runbook_path(config)
|
|
123
|
+
if config.init && config.dir.nil?
|
|
124
|
+
raise OptionParser::ParseError, "--init needs the path of the runbook to create (RUNBOOK, dir: in the config file, or RUNSHEETS_DIR)"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
config.dir || DEFAULT_RUNBOOK
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Entry point for the executable. Returns the process exit status.
|
|
131
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
132
|
+
options = parse(argv, out:)
|
|
133
|
+
return 0 unless options
|
|
134
|
+
|
|
135
|
+
config = configure(options)
|
|
136
|
+
return dump(config, out:) if config.dump
|
|
137
|
+
|
|
138
|
+
path = runbook_path(config)
|
|
139
|
+
return init(path, out:) if config.init
|
|
140
|
+
|
|
141
|
+
target = Library.library?(path) ? Library.load(path) : Runbook.load(path)
|
|
142
|
+
return check(target, out:, err:) if config.check
|
|
143
|
+
|
|
144
|
+
serve(target, config, out:, err:)
|
|
145
|
+
0
|
|
146
|
+
rescue Errno::EADDRINUSE
|
|
147
|
+
err.puts "#{PROGRAM}: port #{config.port} on #{config.bind} is already in use; pick another with --port"
|
|
148
|
+
1
|
|
149
|
+
rescue OptionParser::ParseError, RunbookError, ConfigError,
|
|
150
|
+
Psych::Exception, SocketError, SystemCallError => e # also a bad config file, an unknown host, an unreadable directory
|
|
151
|
+
err.puts "#{PROGRAM}: #{e.message}"
|
|
152
|
+
1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --check: report authoring warnings for a runbook, or for every runbook
|
|
156
|
+
# in a library, one line each. Exit 1 when anything warned or failed.
|
|
157
|
+
def self.check(target, out: $stdout, err: $stderr)
|
|
158
|
+
library = target.is_a?(Library)
|
|
159
|
+
runbooks = library ? target.entries.map { check_entry(it, err:) } : [target]
|
|
160
|
+
runbooks.compact.each do |runbook|
|
|
161
|
+
runbook.warnings.each { err.puts "#{PROGRAM}: warning: #{"#{runbook.slug}: " if library}#{it}" }
|
|
162
|
+
out.puts "#{runbook.title}#{" [#{runbook.slug}]" if library}: #{runbook.steps.size} steps, #{runbook.warnings.size} warnings"
|
|
163
|
+
end
|
|
164
|
+
clean = runbooks.all? { it && it.warnings.empty? }
|
|
165
|
+
clean ? 0 : 1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# One library entry's runbook (as the library loaded it, with its slug),
|
|
169
|
+
# or nil and a message when it does not load.
|
|
170
|
+
def self.check_entry(entry, err: $stderr)
|
|
171
|
+
return entry.runbook if entry.ok?
|
|
172
|
+
|
|
173
|
+
err.puts "#{PROGRAM}: error: #{entry.slug}: #{entry.error}"
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Serve a runbook, or a library of them (the operator chooses in the
|
|
178
|
+
# browser). Everything until the server stops is one session; it starts
|
|
179
|
+
# now when the engineer and why are known, else on the browser's start
|
|
180
|
+
# page.
|
|
181
|
+
def self.serve(target, config, out: $stdout, err: $stderr)
|
|
182
|
+
bind = config.bind
|
|
183
|
+
port = config.port
|
|
184
|
+
library = target.is_a?(Library) ? target : nil
|
|
185
|
+
runbook = library ? nil : target
|
|
186
|
+
options = session_options(config, out:)
|
|
187
|
+
session = early_session(config, library:, runbook:, options:)
|
|
188
|
+
Web.configure_for(session, bind:, port:, library:, runbook:).prepare_start(session_options: options, engineer: default_engineer(config))
|
|
189
|
+
url = SessionKey.url(bind, port, Web.settings.rs_token)
|
|
190
|
+
err.puts bind_warning(bind) unless Web.loopback?(bind)
|
|
191
|
+
runbook&.warnings&.each { err.puts "#{PROGRAM}: warning: #{it}" }
|
|
192
|
+
|
|
193
|
+
out.puts <<~INFO
|
|
194
|
+
#{PROGRAM} #{VERSION}
|
|
195
|
+
#{target_line(target)}
|
|
196
|
+
Runs: #{Runsheets.runs_dir}
|
|
197
|
+
Config: #{config.files.empty? ? 'none (defaults)' : config.files.join(', ')}
|
|
198
|
+
Session: #{session ? "#{session.id}, log #{session.log.path}" : 'starts in the browser (who and why)'}
|
|
199
|
+
Open #{url} in your browser
|
|
200
|
+
Press Ctrl-C to end the session and stop
|
|
201
|
+
INFO
|
|
202
|
+
|
|
203
|
+
Web.run! do
|
|
204
|
+
open_browser(url) if config.open
|
|
205
|
+
end
|
|
206
|
+
ensure
|
|
207
|
+
shutdown(Web.session) if Web.session
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Raise Errno::EADDRINUSE now when +port+ is taken, before a session and
|
|
211
|
+
# its record exist. The server binds the port again a moment later.
|
|
212
|
+
def self.ensure_port_free(bind, port) = TCPServer.new(bind, port).close
|
|
213
|
+
|
|
214
|
+
# The session, started now when --engineer and --why say who and why;
|
|
215
|
+
# nil leaves it to the start page. The port is checked first, so a
|
|
216
|
+
# start that cannot serve records no session.
|
|
217
|
+
def self.early_session(config, library:, runbook:, options:)
|
|
218
|
+
ensure_port_free(config.bind, config.port)
|
|
219
|
+
Session.new(engineer: config.engineer, why: config.why, library:, runbook:, **options) if config.engineer && config.why
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# What every session this process starts is built with: where records
|
|
223
|
+
# go, the log level, and the terminal echo (none with --quiet).
|
|
224
|
+
def self.session_options(config, out: $stdout) = { runs_root: Runsheets.runs_dir, log_level: config.log_level, echo: config.quiet ? nil : out }
|
|
225
|
+
|
|
226
|
+
# Who the start page suggests: the configured engineer, else git's
|
|
227
|
+
# user.name, else the login name.
|
|
228
|
+
def self.default_engineer(config) = [config.engineer, git_user_name, ENV["USER"]].find { !it.to_s.strip.empty? }
|
|
229
|
+
|
|
230
|
+
def self.git_user_name
|
|
231
|
+
IO.popen(%w[git config user.name], err: File::NULL, &:read).strip
|
|
232
|
+
rescue SystemCallError
|
|
233
|
+
nil
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def self.target_line(target)
|
|
237
|
+
if target.is_a?(Library)
|
|
238
|
+
folders = target.folders.size
|
|
239
|
+
where = folders.positive? ? " in #{folders} folder#{'s' unless folders == 1}" : ""
|
|
240
|
+
"Library: #{target.size} runbook#{'s' unless target.size == 1}#{where} under #{target.dir}"
|
|
241
|
+
else
|
|
242
|
+
"Runbook: #{target.title} (#{target.single_file? ? target.main_path : target.dir})"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# When the server stops (Ctrl-C, End session, or an error), end the
|
|
247
|
+
# session: every run is closed with the status its work earns and
|
|
248
|
+
# nothing it started is left running. Ending twice does nothing.
|
|
249
|
+
def self.shutdown(session) = session.end!("runsheets stopped")
|
|
250
|
+
|
|
251
|
+
def self.bind_warning(bind)
|
|
252
|
+
<<~WARN.chomp
|
|
253
|
+
#{PROGRAM}: warning: binding to #{bind}, which is not a loopback address.
|
|
254
|
+
Anyone who can reach this address and guess the session token can run blocks as you.
|
|
255
|
+
#{Web.wildcard?(bind) ? 'The Host header check is off for a wildcard bind.' : 'Only requests with this address in the Host header are accepted.'}
|
|
256
|
+
WARN
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def self.open_browser(url)
|
|
260
|
+
command = case RUBY_PLATFORM
|
|
261
|
+
when /darwin/ then ["open", url]
|
|
262
|
+
when /mswin|mingw/ then ["cmd", "/c", "start", url]
|
|
263
|
+
else ["xdg-open", url]
|
|
264
|
+
end
|
|
265
|
+
Process.spawn(*command, %i[out err] => File::NULL)
|
|
266
|
+
rescue SystemCallError
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- scaffolding -------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
# Write a starter runbook. A path ending in .md gets the single-file
|
|
273
|
+
# shape; anything else becomes a directory. Refuses to touch an existing
|
|
274
|
+
# file or a non-empty directory. Returns the exit status.
|
|
275
|
+
def self.init(path, out: $stdout)
|
|
276
|
+
files = scaffold(path)
|
|
277
|
+
raise RunbookError, "#{path} already exists" if File.file?(path)
|
|
278
|
+
raise RunbookError, "#{path} exists and is not empty" if File.directory?(path) && !Dir.empty?(path)
|
|
279
|
+
|
|
280
|
+
files.each do |rel, text|
|
|
281
|
+
full = rel.start_with?("/") ? rel : File.join(path, rel)
|
|
282
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
283
|
+
File.write(full, text)
|
|
284
|
+
out.puts "created #{full}"
|
|
285
|
+
end
|
|
286
|
+
out.puts "Next: #{PROGRAM} --check #{path}"
|
|
287
|
+
0
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The files a new runbook starts with, keyed by path. For a single-file
|
|
291
|
+
# runbook the one key is the absolute path itself.
|
|
292
|
+
def self.scaffold(path)
|
|
293
|
+
name = File.basename(path, ".*")
|
|
294
|
+
title = name.tr("-_", " ").capitalize
|
|
295
|
+
if path.end_with?(".md")
|
|
296
|
+
{ path => single_file_template(title) }
|
|
297
|
+
else
|
|
298
|
+
{
|
|
299
|
+
"runbook.md" => runbook_template(title),
|
|
300
|
+
"steps/010-first-step.md" => <<~MD,
|
|
301
|
+
---
|
|
302
|
+
title: First step
|
|
303
|
+
kind: automated
|
|
304
|
+
timeout: 300
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
Say what this step does and why it is here. Then the command:
|
|
308
|
+
|
|
309
|
+
```bash run
|
|
310
|
+
echo "hello from #{name}"
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
How to tell it worked:
|
|
314
|
+
|
|
315
|
+
```text expect
|
|
316
|
+
hello from #{name}
|
|
317
|
+
```
|
|
318
|
+
MD
|
|
319
|
+
"steps/020-a-manual-step.md" => <<~MD,
|
|
320
|
+
---
|
|
321
|
+
title: A manual step
|
|
322
|
+
kind: manual
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
An instruction the operator carries out and then acknowledges.
|
|
326
|
+
MD
|
|
327
|
+
"verify.md" => <<~MD,
|
|
328
|
+
---
|
|
329
|
+
title: Verify
|
|
330
|
+
kind: verify
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
Whole-procedure checks. These should pass after every run.
|
|
334
|
+
|
|
335
|
+
```bash run
|
|
336
|
+
test -n "$EXAMPLE_INPUT" && echo "EXAMPLE_INPUT is set"
|
|
337
|
+
```
|
|
338
|
+
MD
|
|
339
|
+
"rollback.md" => <<~MD
|
|
340
|
+
---
|
|
341
|
+
title: Rollback
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
What to do when it fails partway. This panel sits in the sidebar of
|
|
345
|
+
every step.
|
|
346
|
+
MD
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def self.runbook_template(title)
|
|
352
|
+
<<~MD
|
|
353
|
+
---
|
|
354
|
+
title: #{title.to_json}
|
|
355
|
+
when_to_use: >
|
|
356
|
+
One or two sentences on the situation this runbook is for.
|
|
357
|
+
prerequisites:
|
|
358
|
+
- What must be true or installed before starting
|
|
359
|
+
escalation: Who to call when a step fails
|
|
360
|
+
tags: []
|
|
361
|
+
inputs:
|
|
362
|
+
- name: EXAMPLE_INPUT
|
|
363
|
+
prompt: An example input, exported as $EXAMPLE_INPUT
|
|
364
|
+
default: example
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
# #{title}
|
|
368
|
+
|
|
369
|
+
Everything above the first step is the preamble: what this procedure does,
|
|
370
|
+
what it touches, and what the operator should know before starting.
|
|
371
|
+
MD
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def self.single_file_template(title)
|
|
375
|
+
<<~MD
|
|
376
|
+
---
|
|
377
|
+
title: #{title.to_json}
|
|
378
|
+
when_to_use: >
|
|
379
|
+
One or two sentences on the situation this runbook is for.
|
|
380
|
+
prerequisites:
|
|
381
|
+
- What must be true or installed before starting
|
|
382
|
+
escalation: Who to call when a step fails
|
|
383
|
+
inputs:
|
|
384
|
+
- name: EXAMPLE_INPUT
|
|
385
|
+
prompt: An example input, exported as $EXAMPLE_INPUT
|
|
386
|
+
default: example
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
# #{title}
|
|
390
|
+
|
|
391
|
+
Everything above the first `##` heading is the preamble. Each `##` heading
|
|
392
|
+
below is a step. Step attributes go in an HTML comment right after the
|
|
393
|
+
heading.
|
|
394
|
+
|
|
395
|
+
## First step
|
|
396
|
+
<!-- kind: automated, timeout: 300 -->
|
|
397
|
+
|
|
398
|
+
Say what this step does and why it is here. Then the command:
|
|
399
|
+
|
|
400
|
+
```bash run
|
|
401
|
+
echo "hello from $EXAMPLE_INPUT"
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## A manual step
|
|
405
|
+
<!-- kind: manual -->
|
|
406
|
+
|
|
407
|
+
An instruction the operator carries out and then acknowledges.
|
|
408
|
+
|
|
409
|
+
## Verify
|
|
410
|
+
|
|
411
|
+
Whole-procedure checks. A section headed Verify plays the part of verify.md.
|
|
412
|
+
|
|
413
|
+
```bash run
|
|
414
|
+
test -n "$EXAMPLE_INPUT" && echo "EXAMPLE_INPUT is set"
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
## Rollback
|
|
418
|
+
|
|
419
|
+
What to do when it fails partway. A section headed Rollback sits in the
|
|
420
|
+
sidebar of every step.
|
|
421
|
+
MD
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Bundled defaults for the runsheets command. This file is the lowest layer;
|
|
2
|
+
# every key here is a setting. Higher layers override it, lowest to highest:
|
|
3
|
+
#
|
|
4
|
+
# ~/.config/runsheets/runsheets.yml XDG user config (myway_config)
|
|
5
|
+
# ./config/runsheets.yml project config, or the file --config / RUNSHEETS_CONFIG names
|
|
6
|
+
# RUNSHEETS_<KEY> environment variables
|
|
7
|
+
# command-line options -p, --bind, --runs-dir, ...
|
|
8
|
+
#
|
|
9
|
+
# Paths may start with ~. Flags accept true/false, yes/no, on/off or 1/0.
|
|
10
|
+
defaults:
|
|
11
|
+
# The runbook to serve: a directory holding runbook.md, or a single .md
|
|
12
|
+
# file. Empty means the bundled examples/directory_based_runbooks/hello. (RUNBOOK argument, RUNSHEETS_DIR)
|
|
13
|
+
dir:
|
|
14
|
+
# Port to listen on. (--port, RUNSHEETS_PORT)
|
|
15
|
+
port: 4567
|
|
16
|
+
# Address to bind to. Anything but loopback exposes shell execution. (--bind, RUNSHEETS_BIND)
|
|
17
|
+
bind: 127.0.0.1
|
|
18
|
+
# Where run records are written. (--runs-dir, RUNSHEETS_RUNS_DIR)
|
|
19
|
+
runs_dir: ~/.local/share/runsheets/runs
|
|
20
|
+
# Open the browser once the server is up. (--open, RUNSHEETS_OPEN)
|
|
21
|
+
open: false
|
|
22
|
+
# Load the runbook, report authoring warnings, and exit. (--check, RUNSHEETS_CHECK)
|
|
23
|
+
check: false
|
|
24
|
+
# Create a runbook skeleton at dir and exit. (--init, RUNSHEETS_INIT)
|
|
25
|
+
init: false
|
|
26
|
+
# Print the settings in force as a config file to stdout and exit. (--dump, RUNSHEETS_DUMP)
|
|
27
|
+
dump: false
|
|
28
|
+
# Who is starting the session. Empty means ask, prefilled from
|
|
29
|
+
# `git config user.name`, then $USER. (--engineer, RUNSHEETS_ENGINEER)
|
|
30
|
+
engineer:
|
|
31
|
+
# Why the session is being started: its first note. With engineer set too,
|
|
32
|
+
# the start page is skipped. Never saved by --dump. (--why, RUNSHEETS_WHY)
|
|
33
|
+
why:
|
|
34
|
+
# The session log's level: debug, info, warn, error or fatal.
|
|
35
|
+
# (--log-level, --verbose for debug, RUNSHEETS_LOG_LEVEL)
|
|
36
|
+
log_level: info
|
|
37
|
+
# Do not echo the session log to the terminal. (--quiet, RUNSHEETS_QUIET)
|
|
38
|
+
quiet: false
|
|
39
|
+
|
|
40
|
+
# Per-environment overrides (RACK_ENV). None are needed; the sections exist
|
|
41
|
+
# so myway_config recognises the usual environment names.
|
|
42
|
+
development: {}
|
|
43
|
+
test: {}
|
|
44
|
+
production: {}
|