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,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: {}