runsheets 0.0.1 → 0.0.2

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/Archspec.rb +137 -0
  3. data/CHANGELOG.md +35 -1
  4. data/README.md +7 -3
  5. data/archspec_todo.yml +32 -0
  6. data/lib/runsheets/cli.rb +98 -164
  7. data/lib/runsheets/config/defaults.yml +0 -2
  8. data/lib/runsheets/config.rb +55 -16
  9. data/lib/runsheets/prompt.rb +62 -0
  10. data/lib/runsheets/runbook.rb +12 -0
  11. data/lib/runsheets/single_file.rb +34 -1
  12. data/lib/runsheets/slug.rb +19 -0
  13. data/lib/runsheets/step.rb +1 -1
  14. data/lib/runsheets/step_runner.rb +261 -0
  15. data/lib/runsheets/template.rb +94 -0
  16. data/lib/runsheets/version.rb +1 -1
  17. data/lib/runsheets/wizard.rb +260 -0
  18. data/lib/runsheets.rb +9 -4
  19. data/templates/asgard/.loki.erb +9 -0
  20. data/templates/asgard/runbook.md.erb +23 -0
  21. data/templates/asgard/steps/010-preflight.md.erb +13 -0
  22. data/templates/asgard/steps/020-do-the-work.md.erb +16 -0
  23. data/templates/asgard/steps/030-tell-people.md.erb +7 -0
  24. data/templates/asgard/tasks.loki +28 -0
  25. data/templates/asgard/template.yml +2 -0
  26. data/templates/asgard/verify.md.erb +12 -0
  27. data/templates/checks/runbook.md.erb +15 -0
  28. data/templates/checks/steps/010-tools.md.erb +17 -0
  29. data/templates/checks/steps/020-disk-space.md.erb +14 -0
  30. data/templates/checks/steps/030-writable.md.erb +13 -0
  31. data/templates/checks/template.yml +2 -0
  32. data/templates/inputs/runbook.md.erb +40 -0
  33. data/templates/inputs/steps/010-preflight.md.erb +15 -0
  34. data/templates/inputs/steps/020-use-the-inputs.md.erb +13 -0
  35. data/templates/inputs/steps/030-another-language.md.erb +13 -0
  36. data/templates/inputs/template.yml +2 -0
  37. data/templates/inputs/verify.md.erb +12 -0
  38. data/templates/rollback/rollback.md.erb +10 -0
  39. data/templates/rollback/runbook.md.erb +29 -0
  40. data/templates/rollback/steps/010-preflight.md.erb +16 -0
  41. data/templates/rollback/steps/020-keep-a-copy.md.erb +18 -0
  42. data/templates/rollback/steps/030-replace.md.erb +14 -0
  43. data/templates/rollback/steps/040-tell-people.md.erb +7 -0
  44. data/templates/rollback/template.yml +2 -0
  45. data/templates/rollback/verify.md.erb +15 -0
  46. data/templates/tasks/runbook.md.erb +29 -0
  47. data/templates/tasks/steps/010-preflight.md.erb +16 -0
  48. data/templates/tasks/steps/020-do-the-work.md.erb +18 -0
  49. data/templates/tasks/steps/030-tell-people.md.erb +7 -0
  50. data/templates/tasks/template.yml +2 -0
  51. data/templates/tasks/verify.md.erb +15 -0
  52. data/templates/timeouts/runbook.md.erb +30 -0
  53. data/templates/timeouts/steps/010-preflight.md.erb +16 -0
  54. data/templates/timeouts/steps/020-watch-progress.md.erb +13 -0
  55. data/templates/timeouts/steps/030-the-long-step.md.erb +20 -0
  56. data/templates/timeouts/steps/040-look-it-over.md.erb +14 -0
  57. data/templates/timeouts/template.yml +2 -0
  58. data/templates/timeouts/verify.md.erb +13 -0
  59. metadata +48 -1
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Runsheets
6
+ # `runsheets --new [DIR]`: ask what kind of runbook to make, then write it
7
+ # from a Template into DIR, asking for DIR too when it was not given.
8
+ #
9
+ # Every question has a default, so Enter accepts it. Answers are read
10
+ # from +input+ and prompts written to +output+, so a test (or a script
11
+ # piping answers in) drives it like a person would. When input runs out
12
+ # each remaining question takes its default, and the final "Create?" is
13
+ # answered yes.
14
+ class Wizard
15
+ SHAPES = {
16
+ "directory" => "runbook.md, steps/, verify.md and rollback.md",
17
+ "single-file" => "one .md file; each ## heading is a step"
18
+ }.freeze
19
+
20
+ # What Enter gives for the free-text questions.
21
+ DEFAULTS = {
22
+ name: "new-runbook",
23
+ when_to_use: "One or two sentences on the situation this runbook is for.",
24
+ escalation: "Who to call when a step fails"
25
+ }.freeze
26
+
27
+ # How a runbook sits in its parent directory: a directory runbook, a
28
+ # single .md file, or a single file in a folder of its own because its
29
+ # template brings support files (see Template#support_files?). Keyed by
30
+ # [shape, whether the template has support files].
31
+ LAYOUTS = {
32
+ ["directory", false] => :directory, ["directory", true] => :directory,
33
+ ["single-file", false] => :file, ["single-file", true] => :folder
34
+ }.freeze
35
+
36
+ # Where each layout's runbook itself is, relative to the parent. Its
37
+ # first part is what the layout creates there.
38
+ MAINS = { directory: "%<name>s", file: "%<name>s.md", folder: "%<name>s/%<name>s.md" }.freeze
39
+
40
+ Answers = Data.define(:template, :name, :title, :when_to_use, :escalation, :layout) do
41
+ def single_file? = layout != :directory
42
+ def shape = single_file? ? "single-file" : "directory"
43
+ # What is created in the parent directory: a file or a directory.
44
+ def location = Wizard.location(name, layout)
45
+ # The runbook itself, relative to the parent: what --check is given.
46
+ def main = format(MAINS.fetch(layout), name:)
47
+
48
+ def context
49
+ Template::Context.new(name:, title:, when_to_use:, escalation:, single_file: single_file?)
50
+ end
51
+ end
52
+
53
+ # "DB Refresh!" => "db-refresh": the basename a name becomes. A name
54
+ # with nothing usable in it gives "".
55
+ def self.slug(text) = Slug.of(text.to_s.strip.downcase.delete_suffix(".md"))
56
+
57
+ # The layout for a runbook of +shape+ from a template that has, or has
58
+ # not, +support_files+.
59
+ def self.layout(shape, support_files) = LAYOUTS.fetch([shape, support_files])
60
+
61
+ # What a runbook called +name+ creates in its parent directory: a
62
+ # directory or a .md file.
63
+ def self.location(name, layout) = format(MAINS.fetch(layout), name:).split("/").first
64
+
65
+ # "db-refresh" => "Db refresh": the title a name suggests.
66
+ def self.title_for(name) = Slug.title(name)
67
+
68
+ # Why +path+ cannot be created, or nil when it can: an existing file, or
69
+ # a directory with something in it.
70
+ def self.conflict(path)
71
+ return "#{path} already exists" if File.file?(path)
72
+
73
+ "#{path} exists and is not empty" if File.directory?(path) && !Dir.empty?(path)
74
+ end
75
+
76
+ # Why +name+ cannot be a new runbook of +layout+ in +parent+, or nil
77
+ # when it can.
78
+ def self.name_problem(parent, name, layout)
79
+ return "a name needs a letter or digit" if name.empty?
80
+
81
+ conflict(File.join(parent, location(name, layout)))
82
+ end
83
+
84
+ # Why +dir+ cannot be the directory to write into, or nil when it can.
85
+ def self.parent_problem(dir)
86
+ return "a directory is needed" if dir.nil?
87
+
88
+ "#{dir} is a file, not a directory" if File.file?(Config.expand_path(dir))
89
+ rescue ConfigError => e
90
+ e.message
91
+ end
92
+
93
+ # The one of +names+ that +answer+ picks, by its number in the list or
94
+ # by name, or nil.
95
+ def self.pick(names, answer)
96
+ return names.find { it == answer } unless answer.match?(/\A\d+\z/)
97
+
98
+ names[answer.to_i - 1] if answer.to_i.between?(1, names.size)
99
+ end
100
+
101
+ CANCELLED = "Nothing created."
102
+
103
+ attr_reader :parent
104
+
105
+ # +parent+ is the directory to write into; nil asks for it, offering
106
+ # +default_parent+ (nil for no default).
107
+ def initialize(parent, input: $stdin, output: $stdout, templates: Template.all, default_parent: nil)
108
+ raise RunbookError, "no runbook templates to offer" if templates.empty?
109
+
110
+ @parent = parent
111
+ @default_parent = default_parent
112
+ @output = output
113
+ @prompt = Prompt.new(input:, output:)
114
+ @templates = templates
115
+ end
116
+
117
+ # Ask, confirm, write. Returns the exit status.
118
+ def run
119
+ @parent ||= ask_parent(@default_parent)
120
+ @output.puts "New runbook in #{parent}", ""
121
+ answers = ask_all
122
+ return cancelled unless confirm?(summary(answers))
123
+
124
+ write(answers)
125
+ end
126
+
127
+ # Every question, in order, as Answers.
128
+ def ask_all
129
+ shape = choose("Shape", SHAPES)
130
+ template = choose("Template", @templates.to_h { [it.name, it.description] }, default: Template::DEFAULT)
131
+ layout = self.class.layout(shape, template_named(template).support_files?)
132
+ name = ask_name(layout)
133
+ title = ask("Title", default: self.class.title_for(name))
134
+ Answers.new(template:, name:, title:, layout:,
135
+ when_to_use: ask("When to use, in a sentence", default: DEFAULTS[:when_to_use], shown: "edit later"),
136
+ escalation: ask("Who to call when a step fails", default: DEFAULTS[:escalation], shown: "edit later"))
137
+ end
138
+
139
+ # One free-text answer: what was typed, else +default+. +shown+ is what
140
+ # the prompt displays in the brackets in place of a long default.
141
+ # +rejected+ says why the last answer was refused: when input runs out
142
+ # while it is being asked again, the default would quietly replace it,
143
+ # so that is an error instead.
144
+ def ask(question, default:, shown: default, rejected: nil)
145
+ @output.print shown ? "#{question} [#{shown}]: " : "#{question}: "
146
+ line = @prompt.line
147
+ raise RunbookError, rejected if line.nil? && rejected
148
+
149
+ line.nil? || line.empty? ? default : line
150
+ end
151
+
152
+ # Pick one of +options+ (name => description) by number or name.
153
+ # Returns the name; +default+ (the first option unless named) is
154
+ # what Enter picks.
155
+ def choose(question, options, default: options.keys.first)
156
+ names = options.keys
157
+ @output.puts "#{question}:"
158
+ width = names.map(&:size).max
159
+ names.each.with_index(1) { |name, i| @output.puts " #{i}) #{name.ljust(width)} #{options[name]}" }
160
+ ask_until do |rejected|
161
+ answer = ask("Choose", default: ((names.index(default) || 0) + 1).to_s, rejected:)
162
+ pick = self.class.pick(names, answer)
163
+ [pick, ("#{answer.inspect} is not one of 1-#{names.size}" unless pick)]
164
+ end
165
+ end
166
+
167
+ # The runbook's name, slugged, asked again while it is blank or taken.
168
+ def ask_name(layout)
169
+ ask_until do |rejected|
170
+ name = self.class.slug(ask("Name (the #{layout == :file ? 'file' : 'directory'} name)", default: DEFAULTS[:name], rejected:))
171
+ [name, self.class.name_problem(parent, name, layout)]
172
+ end
173
+ end
174
+
175
+ # The directory to write into, expanded so ~ works: what was typed,
176
+ # else +default+. Asked again while it is blank with no default, names
177
+ # a file, or cannot be expanded (~nobody).
178
+ def ask_parent(default)
179
+ dir = ask_until do |rejected|
180
+ answer = ask("Directory to create the runbook in", default:, rejected:)
181
+ [answer, self.class.parent_problem(answer)]
182
+ end
183
+ Config.expand_path(dir)
184
+ end
185
+
186
+ # Ask until an answer has no problem. The block is given why the last
187
+ # answer was refused (nil at first) and returns [answer, problem or
188
+ # nil]. Returns the answer.
189
+ def ask_until
190
+ rejected = nil
191
+ loop do
192
+ answer, problem = yield(rejected)
193
+ return answer unless problem
194
+
195
+ rejected = retry_or_give_up(problem)
196
+ end
197
+ end
198
+
199
+ def summary(answers)
200
+ "Create #{answers.shape} runbook #{File.join(parent, answers.main)} from the #{answers.template} template?"
201
+ end
202
+
203
+ # A yes/no question; Enter, or input that has run out, is yes.
204
+ def confirm?(question)
205
+ @output.puts
206
+ @prompt.yes_unless_no?(question)
207
+ end
208
+
209
+ # The template called +name+ among those offered.
210
+ def template_named(name) = @templates.find { it.name == name } || raise(RunbookError, "no template named #{name}")
211
+
212
+ # The files for +answers+: relative to the parent directory => text. A
213
+ # single file takes the runbook's markdown; support files go beside it.
214
+ def files(answers)
215
+ rendered = template_named(answers.template).render(answers.context)
216
+ return rendered.transform_keys { File.join(answers.location, it) } unless answers.single_file?
217
+
218
+ support = Template.support_files(rendered).transform_keys { File.join(answers.location, it) }
219
+ { answers.main => SingleFile.from_directory(rendered), **support }
220
+ end
221
+
222
+ # Write the runbook and say what to do next. Returns the exit status.
223
+ def write(answers)
224
+ problem = self.class.conflict(File.join(parent, answers.location))
225
+ raise RunbookError, problem if problem
226
+
227
+ files(answers).each do |rel, text|
228
+ full = File.join(parent, rel)
229
+ FileUtils.mkdir_p(File.dirname(full))
230
+ File.write(full, text)
231
+ @output.puts "created #{full}"
232
+ end
233
+ path = File.join(parent, answers.main)
234
+ @output.puts <<~NEXT
235
+
236
+ Next:
237
+ #{CLI::PROGRAM} --check #{path}
238
+ #{CLI::PROGRAM} #{path}
239
+ NEXT
240
+ 0
241
+ end
242
+
243
+ private
244
+
245
+ # Say what was wrong before the question is asked again, and return it
246
+ # for that ask. When input has already run out, asking again would loop
247
+ # forever, so stop instead.
248
+ def retry_or_give_up(problem)
249
+ raise RunbookError, problem if @prompt.exhausted?
250
+
251
+ @output.puts " #{problem}"
252
+ problem
253
+ end
254
+
255
+ def cancelled
256
+ @output.puts CANCELLED
257
+ 1
258
+ end
259
+ end
260
+ end
data/lib/runsheets.rb CHANGED
@@ -25,10 +25,14 @@ module Runsheets
25
25
  class RunbookError < Error; end
26
26
  class RunError < Error; end
27
27
 
28
- autoload :Assets, File.expand_path("runsheets/assets", __dir__)
29
- autoload :CLI, File.expand_path("runsheets/cli", __dir__)
30
- autoload :Pages, File.expand_path("runsheets/pages", __dir__)
31
- autoload :Web, File.expand_path("runsheets/web", __dir__)
28
+ autoload :Assets, File.expand_path("runsheets/assets", __dir__)
29
+ autoload :CLI, File.expand_path("runsheets/cli", __dir__)
30
+ autoload :Pages, File.expand_path("runsheets/pages", __dir__)
31
+ autoload :Prompt, File.expand_path("runsheets/prompt", __dir__)
32
+ autoload :StepRunner, File.expand_path("runsheets/step_runner", __dir__)
33
+ autoload :Template, File.expand_path("runsheets/template", __dir__)
34
+ autoload :Web, File.expand_path("runsheets/web", __dir__)
35
+ autoload :Wizard, File.expand_path("runsheets/wizard", __dir__)
32
36
 
33
37
  class << self
34
38
  attr_writer :runs_dir
@@ -74,6 +78,7 @@ require_relative "runsheets/front_matter"
74
78
  require_relative "runsheets/fences"
75
79
  require_relative "runsheets/block"
76
80
  require_relative "runsheets/renderer"
81
+ require_relative "runsheets/slug"
77
82
  require_relative "runsheets/step"
78
83
  require_relative "runsheets/single_file"
79
84
  require_relative "runsheets/runbook"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+ # asgard's entry point for the <%= title %> runbook beside it. Blocks in the
3
+ # runbook run in this directory, so `asgard` finds this file first.
4
+
5
+ import "*.loki"
6
+
7
+ class Tasks
8
+ header <%= ruby_string("#{name}: tasks for the #{title} runbook") %>
9
+ end
@@ -0,0 +1,23 @@
1
+ ---
2
+ title: <%= yaml title %>
3
+ when_to_use: <%= yaml when_to_use %>
4
+ prerequisites:
5
+ - The asgard command on the PATH (gem install asgard)
6
+ tags: [asgard]
7
+ inputs:
8
+ - name: WORK_DIR
9
+ prompt: Where this runbook writes its files
10
+ default: /tmp/<%= name %>
11
+ escalation: <%= yaml escalation %>
12
+ ---
13
+
14
+ # <%= title %>
15
+
16
+ Checks and tasks whose code lives in [asgard](https://github.com/MadBomber/asgard)
17
+ tasks, so each block stays one line. The tasks are in `tasks.loki`; `.loki`
18
+ is asgard's entry point and imports it. Blocks run in this runbook's
19
+ directory, and asgard uses the nearest `.loki`, so these are the tasks they
20
+ get. Inputs reach the tasks as environment variables: `env(:work_dir)`.
21
+
22
+ Run `asgard --help` in this directory to list the tasks.
23
+
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: Preflight
3
+ kind: verify
4
+ timeout: 30
5
+ ---
6
+
7
+ ```bash run
8
+ asgard preflight
9
+ ```
10
+
11
+ ```text expect
12
+ preflight: WORK_DIR is {{any}}
13
+ ```
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: Do the work
3
+ kind: automated
4
+ timeout: 300
5
+ ---
6
+
7
+ The runbook says when and why; the task says how. Change `work` in
8
+ `tasks.loki`, not this block.
9
+
10
+ ```bash run
11
+ asgard work
12
+ ```
13
+
14
+ ```text expect
15
+ work: wrote {{any}}/last-run
16
+ ```
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: Tell people
3
+ kind: manual
4
+ ---
5
+
6
+ A `manual` step: something the operator does by hand, then acknowledges.
7
+ Say who needs to know that this ran, and where to tell them.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ # The runbook's work. WORK_DIR is a runbook input; env(:work_dir) reads it.
6
+ class Tasks
7
+ desc "preflight", "Fail unless WORK_DIR is set"
8
+ def preflight
9
+ dir = env(:work_dir, "")
10
+ abort "preflight: WORK_DIR is empty" if dir.empty?
11
+ puts "preflight: WORK_DIR is #{dir}"
12
+ end
13
+
14
+ desc "work", "Write the time to WORK_DIR/last-run"
15
+ def work
16
+ dir = env(:work_dir, "")
17
+ FileUtils.mkdir_p(dir)
18
+ File.write(File.join(dir, "last-run"), "#{Time.now.utc}\n")
19
+ puts "work: wrote #{dir}/last-run"
20
+ end
21
+
22
+ desc "check", "Fail unless WORK_DIR/last-run has something in it"
23
+ def check
24
+ path = File.join(env(:work_dir, ""), "last-run")
25
+ abort "check: #{path} is missing or empty" unless File.size?(path)
26
+ puts "check: last run #{File.read(path).strip}"
27
+ end
28
+ end
@@ -0,0 +1,2 @@
1
+ order: 6
2
+ description: "Checks and tasks as asgard tasks in .loki files beside the runbook"
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: Verify
3
+ kind: verify
4
+ ---
5
+
6
+ ```bash run
7
+ asgard check
8
+ ```
9
+
10
+ ```text expect
11
+ check: last run {{any}}
12
+ ```
@@ -0,0 +1,15 @@
1
+ ---
2
+ title: <%= yaml title %>
3
+ when_to_use: <%= yaml when_to_use %>
4
+ prerequisites:
5
+ - Nothing; every step only reads
6
+ tags: [checks]
7
+ escalation: <%= yaml escalation %>
8
+ ---
9
+
10
+ # <%= title %>
11
+
12
+ A runbook of checks only. Every step is `kind: verify`: it runs commands
13
+ that read, never change, and compares what they print with what is
14
+ expected. Run the steps in order, or all at once from the Checks page.
15
+
@@ -0,0 +1,17 @@
1
+ ---
2
+ title: The tools are installed
3
+ kind: verify
4
+ timeout: 30
5
+ ---
6
+
7
+ Each `bash run` block gets a Run button. The `text expect` block under it
8
+ is compared with its output; `{{any}}` matches whatever changes from
9
+ machine to machine.
10
+
11
+ ```bash run
12
+ git --version
13
+ ```
14
+
15
+ ```text expect
16
+ git version {{any}}
17
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: There is disk space
3
+ kind: verify
4
+ timeout: 30
5
+ ---
6
+
7
+ A check fails when its block exits non-zero. This one fails when the file
8
+ system holding the runbook is 90% full or more.
9
+
10
+ ```bash run
11
+ used=$(df -P . | awk 'NR == 2 { sub("%", "", $5); print $5 }')
12
+ echo "disk use: ${used}%"
13
+ test "$used" -lt 90
14
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: The directory is writable
3
+ kind: verify
4
+ timeout: 30
5
+ ---
6
+
7
+ ```bash run
8
+ test -w . && echo "can write here"
9
+ ```
10
+
11
+ ```text expect
12
+ can write here
13
+ ```
@@ -0,0 +1,2 @@
1
+ order: 1
2
+ description: "Checks only: verify steps that read and compare, never change"
@@ -0,0 +1,40 @@
1
+ ---
2
+ title: <%= yaml title %>
3
+ when_to_use: <%= yaml when_to_use %>
4
+ prerequisites:
5
+ - python3 on the PATH, for the step in another language
6
+ tags: []
7
+ inputs:
8
+ - name: GREETING
9
+ prompt: A value with a default
10
+ default: hello
11
+ - name: TARGET
12
+ prompt: A value with no default; the operator types it
13
+ - name: API_TOKEN
14
+ prompt: A secret, masked in the form and kept out of the record
15
+ secret: true
16
+ # Map a language to the command that runs its blocks. The block is written
17
+ # to a file and the file's path is appended to the command. For a database:
18
+ # sql: psql -X -v ON_ERROR_STOP=1 -f
19
+ interpreters:
20
+ python: python3
21
+ escalation: <%= yaml escalation %>
22
+ ---
23
+
24
+ # <%= title %>
25
+
26
+ Checks and tasks driven by inputs. The start-run form asks for each
27
+ input; every block gets them as environment variables. A blank field
28
+ falls back to the same variable in the environment runsheets started in,
29
+ then to the default. A `secret` is masked in the form, never written to
30
+ the run record, and redacted wherever a block prints it.
31
+
32
+ <% if single_file -%>
33
+ Everything above the first `##` heading is the preamble. Each `##` heading
34
+ below is a step, and its attributes (`kind`, `timeout`, ...) go in the HTML
35
+ comment right after the heading.
36
+ <% else -%>
37
+ Everything above the first step is the preamble. The steps are the files in
38
+ `steps/`, run in the order of their numbers, and each one's attributes
39
+ (`kind`, `timeout`, ...) are in its front matter.
40
+ <% end -%>
@@ -0,0 +1,15 @@
1
+ ---
2
+ title: Preflight
3
+ kind: verify
4
+ timeout: 30
5
+ ---
6
+
7
+ An input with no default may arrive empty. Check for it first.
8
+
9
+ ```bash run
10
+ test -n "$TARGET" && echo "TARGET is $TARGET"
11
+ ```
12
+
13
+ ```text expect
14
+ TARGET is {{any}}
15
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: Use the inputs
3
+ kind: automated
4
+ timeout: 60
5
+ ---
6
+
7
+ This block prints the secret on purpose: the output and the record show
8
+ it redacted.
9
+
10
+ ```bash run
11
+ echo "$GREETING, $TARGET"
12
+ echo "token: $API_TOKEN"
13
+ ```
@@ -0,0 +1,13 @@
1
+ ---
2
+ title: Another language
3
+ kind: automated
4
+ timeout: 60
5
+ ---
6
+
7
+ `python` blocks run because the front matter maps `python` to `python3`
8
+ under `interpreters`. `bash`, `sh`, `zsh` and `ruby` need no mapping.
9
+
10
+ ```python run
11
+ import os
12
+ print(os.environ["GREETING"], "from python")
13
+ ```
@@ -0,0 +1,2 @@
1
+ order: 5
2
+ description: "Checks, tasks and inputs: defaults, a secret, another language"
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: Verify
3
+ kind: verify
4
+ ---
5
+
6
+ ```bash run
7
+ test -n "$API_TOKEN" && echo "API_TOKEN is set"
8
+ ```
9
+
10
+ ```text expect
11
+ API_TOKEN is set
12
+ ```
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: Rollback
3
+ ---
4
+
5
+ When a step fails partway, put the copy back, then run Verify.
6
+
7
+ ```bash destructive
8
+ cp "$WORK_DIR/last-run.bak" "$WORK_DIR/last-run"
9
+ echo "restored $WORK_DIR/last-run"
10
+ ```
@@ -0,0 +1,29 @@
1
+ ---
2
+ title: <%= yaml title %>
3
+ when_to_use: <%= yaml when_to_use %>
4
+ prerequisites:
5
+ - What must be true or installed before starting
6
+ tags: []
7
+ inputs:
8
+ - name: WORK_DIR
9
+ prompt: Where this runbook writes its files
10
+ default: /tmp/<%= name %>
11
+ escalation: <%= yaml escalation %>
12
+ ---
13
+
14
+ # <%= title %>
15
+
16
+ Checks and tasks, plus a way back. The step that destroys something is a
17
+ `bash destructive` block: its Run button asks for a typed confirmation.
18
+ The step before it keeps a copy, and Rollback, which sits beside every
19
+ step, puts the copy back.
20
+
21
+ <% if single_file -%>
22
+ Everything above the first `##` heading is the preamble. Each `##` heading
23
+ below is a step, and its attributes (`kind`, `timeout`, ...) go in the HTML
24
+ comment right after the heading.
25
+ <% else -%>
26
+ Everything above the first step is the preamble. The steps are the files in
27
+ `steps/`, run in the order of their numbers, and each one's attributes
28
+ (`kind`, `timeout`, ...) are in its front matter.
29
+ <% end -%>
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: Preflight
3
+ kind: verify
4
+ timeout: 30
5
+ ---
6
+
7
+ Check what the later steps rely on before anything changes. A `verify`
8
+ step also runs on its own from the Checks page.
9
+
10
+ ```bash run
11
+ test -n "$WORK_DIR" && echo "WORK_DIR is $WORK_DIR"
12
+ ```
13
+
14
+ ```text expect
15
+ WORK_DIR is {{any}}
16
+ ```
@@ -0,0 +1,18 @@
1
+ ---
2
+ title: Keep a copy
3
+ kind: automated
4
+ timeout: 120
5
+ ---
6
+
7
+ Save what the next step replaces, so Rollback has something to restore.
8
+
9
+ ```bash run
10
+ mkdir -p "$WORK_DIR"
11
+ touch "$WORK_DIR/last-run"
12
+ cp "$WORK_DIR/last-run" "$WORK_DIR/last-run.bak"
13
+ echo "saved $WORK_DIR/last-run.bak"
14
+ ```
15
+
16
+ ```text expect
17
+ saved {{any}}/last-run.bak
18
+ ```
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: Replace
3
+ kind: automated
4
+ timeout: 300
5
+ ---
6
+
7
+ The change that cannot be taken back without the copy. A `destructive`
8
+ block runs only after the operator types the confirmation code, and the
9
+ step shows a warning banner.
10
+
11
+ ```bash destructive
12
+ date -u > "$WORK_DIR/last-run"
13
+ echo "replaced $WORK_DIR/last-run"
14
+ ```