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.
- checksums.yaml +4 -4
- data/Archspec.rb +137 -0
- data/CHANGELOG.md +35 -1
- data/README.md +7 -3
- data/archspec_todo.yml +32 -0
- data/lib/runsheets/cli.rb +98 -164
- data/lib/runsheets/config/defaults.yml +0 -2
- data/lib/runsheets/config.rb +55 -16
- data/lib/runsheets/prompt.rb +62 -0
- data/lib/runsheets/runbook.rb +12 -0
- data/lib/runsheets/single_file.rb +34 -1
- data/lib/runsheets/slug.rb +19 -0
- data/lib/runsheets/step.rb +1 -1
- data/lib/runsheets/step_runner.rb +261 -0
- data/lib/runsheets/template.rb +94 -0
- data/lib/runsheets/version.rb +1 -1
- data/lib/runsheets/wizard.rb +260 -0
- data/lib/runsheets.rb +9 -4
- data/templates/asgard/.loki.erb +9 -0
- data/templates/asgard/runbook.md.erb +23 -0
- data/templates/asgard/steps/010-preflight.md.erb +13 -0
- data/templates/asgard/steps/020-do-the-work.md.erb +16 -0
- data/templates/asgard/steps/030-tell-people.md.erb +7 -0
- data/templates/asgard/tasks.loki +28 -0
- data/templates/asgard/template.yml +2 -0
- data/templates/asgard/verify.md.erb +12 -0
- data/templates/checks/runbook.md.erb +15 -0
- data/templates/checks/steps/010-tools.md.erb +17 -0
- data/templates/checks/steps/020-disk-space.md.erb +14 -0
- data/templates/checks/steps/030-writable.md.erb +13 -0
- data/templates/checks/template.yml +2 -0
- data/templates/inputs/runbook.md.erb +40 -0
- data/templates/inputs/steps/010-preflight.md.erb +15 -0
- data/templates/inputs/steps/020-use-the-inputs.md.erb +13 -0
- data/templates/inputs/steps/030-another-language.md.erb +13 -0
- data/templates/inputs/template.yml +2 -0
- data/templates/inputs/verify.md.erb +12 -0
- data/templates/rollback/rollback.md.erb +10 -0
- data/templates/rollback/runbook.md.erb +29 -0
- data/templates/rollback/steps/010-preflight.md.erb +16 -0
- data/templates/rollback/steps/020-keep-a-copy.md.erb +18 -0
- data/templates/rollback/steps/030-replace.md.erb +14 -0
- data/templates/rollback/steps/040-tell-people.md.erb +7 -0
- data/templates/rollback/template.yml +2 -0
- data/templates/rollback/verify.md.erb +15 -0
- data/templates/tasks/runbook.md.erb +29 -0
- data/templates/tasks/steps/010-preflight.md.erb +16 -0
- data/templates/tasks/steps/020-do-the-work.md.erb +18 -0
- data/templates/tasks/steps/030-tell-people.md.erb +7 -0
- data/templates/tasks/template.yml +2 -0
- data/templates/tasks/verify.md.erb +15 -0
- data/templates/timeouts/runbook.md.erb +30 -0
- data/templates/timeouts/steps/010-preflight.md.erb +16 -0
- data/templates/timeouts/steps/020-watch-progress.md.erb +13 -0
- data/templates/timeouts/steps/030-the-long-step.md.erb +20 -0
- data/templates/timeouts/steps/040-look-it-over.md.erb +14 -0
- data/templates/timeouts/template.yml +2 -0
- data/templates/timeouts/verify.md.erb +13 -0
- 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,
|
|
29
|
-
autoload :CLI,
|
|
30
|
-
autoload :Pages,
|
|
31
|
-
autoload :
|
|
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,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,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,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,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,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,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
|
+
```
|