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
data/lib/runsheets/config.rb
CHANGED
|
@@ -16,7 +16,7 @@ module Runsheets
|
|
|
16
16
|
# 5. the overrides hash, which the CLI fills from the command line
|
|
17
17
|
#
|
|
18
18
|
# Every key in defaults.yml is an attribute here (dir, port, bind, runs_dir,
|
|
19
|
-
# open, check,
|
|
19
|
+
# open, check, dump, engineer, why, log_level, quiet). Paths are
|
|
20
20
|
# expanded; flags accept 1/true/yes/on.
|
|
21
21
|
class Config < MywayConfig::Base
|
|
22
22
|
config_name :runsheets
|
|
@@ -28,6 +28,14 @@ module Runsheets
|
|
|
28
28
|
class << self
|
|
29
29
|
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
30
30
|
|
|
31
|
+
# File.expand_path, but a ~user that names nobody is a ConfigError
|
|
32
|
+
# rather than an ArgumentError, so the CLI reports it in one line.
|
|
33
|
+
def expand_path(path, base = nil)
|
|
34
|
+
File.expand_path(path, base)
|
|
35
|
+
rescue ArgumentError => e
|
|
36
|
+
raise ConfigError, "cannot expand #{path}: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
31
39
|
# A coercion that hands the stripped text to the block, or gives nil for
|
|
32
40
|
# a blank so the bundled default fills it (see fill_blanks_from_defaults).
|
|
33
41
|
def unless_blank = ->(v) { blank?(v) ? nil : yield(v.to_s.strip) }
|
|
@@ -47,9 +55,38 @@ module Runsheets
|
|
|
47
55
|
File.join(Dir.home, ".config", config_name.to_s, "#{config_name}.yml")
|
|
48
56
|
end
|
|
49
57
|
|
|
58
|
+
# The config file named in place of the project config: +given+ (the
|
|
59
|
+
# --config option), else RUNSHEETS_CONFIG, expanded; nil when neither
|
|
60
|
+
# names one. Raises ConfigError when the file named does not exist.
|
|
61
|
+
def explicit_path(given, env: ENV)
|
|
62
|
+
name = [given, env["RUNSHEETS_CONFIG"]].find { !blank?(it) } or return nil
|
|
63
|
+
path = expand_path(name)
|
|
64
|
+
raise ConfigError, "config file not found: #{path}" unless File.file?(path)
|
|
65
|
+
|
|
66
|
+
path
|
|
67
|
+
end
|
|
68
|
+
|
|
50
69
|
# The project config read when neither --config nor RUNSHEETS_CONFIG names a file.
|
|
51
70
|
def project_path = File.expand_path(Anyway::Settings.default_config_path.call(config_name))
|
|
52
71
|
|
|
72
|
+
# The settings one config file holds, as a string-keyed Hash, read as
|
|
73
|
+
# myway_config reads it: the section for +environment+ when one is
|
|
74
|
+
# set and the file has it, else the whole file. {} when it is
|
|
75
|
+
# missing, unreadable or not a mapping.
|
|
76
|
+
def file_settings(path, environment: current_environment)
|
|
77
|
+
data = YAML.safe_load_file(path, aliases: false)
|
|
78
|
+
return {} unless data.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
section = data[environment] if environment
|
|
81
|
+
section.is_a?(Hash) ? section : data
|
|
82
|
+
rescue Psych::Exception, SystemCallError
|
|
83
|
+
{}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The environment whose section a config file is read from, or nil
|
|
87
|
+
# (the usual case for a command) to read the whole file.
|
|
88
|
+
def current_environment = Anyway::Settings.current_environment&.to_s
|
|
89
|
+
|
|
53
90
|
# The bundled defaults, coerced, keyed by attribute. What the help text shows.
|
|
54
91
|
def bundled_defaults = schema.to_h { |key, value| [key, type_caster.coerce(key, value)] }
|
|
55
92
|
|
|
@@ -59,12 +96,12 @@ module Runsheets
|
|
|
59
96
|
|
|
60
97
|
# Coercions: the raw YAML/env/CLI value becomes the type the attribute needs.
|
|
61
98
|
TEXT = unless_blank(&:itself)
|
|
62
|
-
PATH = unless_blank {
|
|
99
|
+
PATH = unless_blank { expand_path(it) }
|
|
63
100
|
PORT = unless_blank { whole_number(it) }
|
|
64
101
|
FLAG = ->(v) { v == true || TRUE_WORDS.include?(v.to_s.strip.downcase) }
|
|
65
102
|
|
|
66
|
-
attr_config :dir, :port, :bind, :runs_dir, :open, :check, :
|
|
67
|
-
coerce_types dir: PATH, runs_dir: PATH, bind: TEXT, port: PORT, open: FLAG, check: FLAG,
|
|
103
|
+
attr_config :dir, :port, :bind, :runs_dir, :open, :check, :dump, :engineer, :why, :log_level, :quiet
|
|
104
|
+
coerce_types dir: PATH, runs_dir: PATH, bind: TEXT, port: PORT, open: FLAG, check: FLAG, dump: FLAG,
|
|
68
105
|
engineer: TEXT, why: TEXT, log_level: unless_blank { SessionLog.level_name(it) }, quiet: FLAG
|
|
69
106
|
|
|
70
107
|
on_load :fill_blanks_from_defaults
|
|
@@ -83,6 +120,16 @@ module Runsheets
|
|
|
83
120
|
# The config files that exist and were read, lowest layer first.
|
|
84
121
|
def files = [self.class.xdg_path, path].select { File.file?(it) }
|
|
85
122
|
|
|
123
|
+
# The dir a config file sets, whatever RUNSHEETS_DIR or the command line
|
|
124
|
+
# say: from the highest file that has one, expanded, else nil.
|
|
125
|
+
def file_dir
|
|
126
|
+
files.reverse_each do |file|
|
|
127
|
+
value = self.class.file_settings(file)["dir"]
|
|
128
|
+
return self.class.expand_path(value.to_s) unless self.class.blank?(value)
|
|
129
|
+
end
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
86
133
|
# The bind address in force when it came from the project config found
|
|
87
134
|
# in the current directory, else nil. That file arrives with whatever
|
|
88
135
|
# repository was cloned, so the CLI will not let it alone open the
|
|
@@ -92,18 +139,15 @@ module Runsheets
|
|
|
92
139
|
return nil if @override_keys.include?("bind") || !self.class.blank?(ENV["RUNSHEETS_BIND"])
|
|
93
140
|
return nil unless File.file?(path.to_s)
|
|
94
141
|
|
|
95
|
-
|
|
96
|
-
data.is_a?(Hash) && data.key?("bind") ? bind : nil
|
|
97
|
-
rescue Psych::Exception
|
|
98
|
-
nil
|
|
142
|
+
self.class.file_settings(path).key?("bind") ? bind : nil
|
|
99
143
|
end
|
|
100
144
|
|
|
101
145
|
# The settings in force as the flat hash a config file holds. dump is
|
|
102
146
|
# left out: it is an action for this run, and a saved `dump: true` would
|
|
103
|
-
# make every later run print and exit; check
|
|
147
|
+
# make every later run print and exit; check likewise. why is
|
|
104
148
|
# left out too: it belongs to one session, and a saved one would start
|
|
105
149
|
# every later session with it.
|
|
106
|
-
def settings = (self.class.config_attributes - %i[dump check
|
|
150
|
+
def settings = (self.class.config_attributes - %i[dump check why]).to_h { [it.to_s, public_send(it)] }
|
|
107
151
|
|
|
108
152
|
# The settings in force as the text of a config file: a comment header,
|
|
109
153
|
# then one key per setting. `runsheets --dump` prints this.
|
|
@@ -120,12 +164,7 @@ module Runsheets
|
|
|
120
164
|
# Where anyway_config's yml loader reads from. A file named explicitly
|
|
121
165
|
# (option or variable) must exist; the project config may be absent.
|
|
122
166
|
def resolve_config_path(_name, _env_prefix)
|
|
123
|
-
|
|
124
|
-
explicit = ENV["RUNSHEETS_CONFIG"] if self.class.blank?(explicit)
|
|
125
|
-
explicit = nil if self.class.blank?(explicit)
|
|
126
|
-
raise ConfigError, "config file not found: #{explicit}" if explicit && !File.file?(File.expand_path(explicit))
|
|
127
|
-
|
|
128
|
-
@path = explicit ? File.expand_path(explicit) : self.class.project_path
|
|
167
|
+
@path = self.class.explicit_path(@explicit_path) || self.class.project_path
|
|
129
168
|
end
|
|
130
169
|
|
|
131
170
|
# A blank in any layer (RUNSHEETS_PORT="" say) coerces to nil; put the
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Runsheets
|
|
6
|
+
# Questions at the terminal, for `--new` and `--run`: answers are read
|
|
7
|
+
# from +input+ and prompts written to +output+, so a test or a script
|
|
8
|
+
# piping answers in drives it as a person would.
|
|
9
|
+
#
|
|
10
|
+
# Once input runs out (Ctrl-D, a pipe that ends, stdin from /dev/null)
|
|
11
|
+
# every read gives nil and #exhausted? is true, so a caller can stop
|
|
12
|
+
# asking instead of waiting or looping.
|
|
13
|
+
class Prompt
|
|
14
|
+
def initialize(input: $stdin, output: $stdout)
|
|
15
|
+
@input = input
|
|
16
|
+
@output = output
|
|
17
|
+
@exhausted = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exhausted? = @exhausted
|
|
21
|
+
|
|
22
|
+
# A line of input, stripped, or nil when input has run out.
|
|
23
|
+
def line = finish(@input.gets)&.strip
|
|
24
|
+
|
|
25
|
+
# A secret: read without echo at a terminal, and only its newline
|
|
26
|
+
# removed, since spaces can be part of a password. nil when input has
|
|
27
|
+
# run out.
|
|
28
|
+
def secret
|
|
29
|
+
return finish(@input.gets)&.chomp unless @input.tty?
|
|
30
|
+
|
|
31
|
+
text = @input.noecho(&:gets)
|
|
32
|
+
@exhausted = true if text.nil?
|
|
33
|
+
@output.puts # the Enter that noecho did not show
|
|
34
|
+
text&.chomp
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A yes/no question where no is the default: yes only for an answer
|
|
38
|
+
# starting with y.
|
|
39
|
+
def yes?(question) = answered_yes?(question, "y/N", false)
|
|
40
|
+
|
|
41
|
+
# A yes/no question where yes is the default: Enter, or no more input,
|
|
42
|
+
# is yes, and so is an answer starting with y.
|
|
43
|
+
def yes_unless_no?(question) = answered_yes?(question, "Y/n", true)
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def answered_yes?(question, hint, default)
|
|
48
|
+
@output.print "#{question} [#{hint}]: "
|
|
49
|
+
answer = line.to_s.downcase
|
|
50
|
+
answer.empty? ? default : answer.start_with?("y")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# +text+, as read; nil marks input as run out and ends the prompt's line.
|
|
54
|
+
def finish(text)
|
|
55
|
+
return text unless text.nil?
|
|
56
|
+
|
|
57
|
+
@exhausted = true
|
|
58
|
+
@output.puts
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/runsheets/runbook.rb
CHANGED
|
@@ -291,11 +291,23 @@ module Runsheets
|
|
|
291
291
|
dupes = steps.map(&:slug).tally.select { |_, n| n > 1 }.keys
|
|
292
292
|
warnings << "duplicate step slugs: #{dupes.join(', ')}" if dupes.any?
|
|
293
293
|
steps.map(&:slug).intersection(%w[runbook verify rollback]).each { warnings << "step #{it} has the name of a special document; give it a number" }
|
|
294
|
+
warnings.concat(shadow_warnings)
|
|
294
295
|
warnings.concat(@section_warnings)
|
|
295
296
|
documents.each_value do |step|
|
|
296
297
|
step.warnings.each { warnings << "#{step.slug}: #{it}" }
|
|
297
298
|
end
|
|
298
299
|
warnings
|
|
299
300
|
end
|
|
301
|
+
|
|
302
|
+
# A warning for each numbered step whose name without the number is the
|
|
303
|
+
# slug of another document (030-verify beside verify.md), which that
|
|
304
|
+
# name finds first.
|
|
305
|
+
def shadow_warnings
|
|
306
|
+
steps.filter_map do |step|
|
|
307
|
+
slug = step.slug
|
|
308
|
+
name = Slug.bare(slug)
|
|
309
|
+
"step #{slug} is named like #{name}.md, so `--run #{name}` runs that; use `--run #{slug}`" if name != slug && documents.key?(name)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
300
312
|
end
|
|
301
313
|
end
|
|
@@ -85,9 +85,42 @@ module Runsheets
|
|
|
85
85
|
# A step slug for a section: position-numbered like a directory step,
|
|
86
86
|
# so block ids and record files look the same either way.
|
|
87
87
|
def self.slug_for(title, position)
|
|
88
|
-
words =
|
|
88
|
+
words = Slug.of(title)
|
|
89
89
|
words = "step" if words.empty?
|
|
90
90
|
format("%<number>03d-%<words>s", number: position * 10, words:)
|
|
91
91
|
end
|
|
92
|
+
|
|
93
|
+
# The reverse of split: one single-file runbook made from a directory
|
|
94
|
+
# runbook's files (relative path => text), as `runsheets --new` writes
|
|
95
|
+
# one. runbook.md gives the front matter and preamble, then comes a ##
|
|
96
|
+
# section per step in order, then Verify and Rollback. Each file's front
|
|
97
|
+
# matter, less its title, becomes the attribute comment under its heading.
|
|
98
|
+
def self.from_directory(files)
|
|
99
|
+
steps = files.keys.grep(%r{\A#{Runbook::STEPS_DIR}/[^/]+\.md\z}).sort_by { [File.basename(it)[/\A\d+/].to_i, it] }
|
|
100
|
+
extras = Runbook::EXTRAS.map { "#{it}.md" }.select { files.key?(it) }
|
|
101
|
+
sections = (steps + extras).map { section(files[it], fallback: File.basename(it, ".md")) }
|
|
102
|
+
[files.fetch("runbook.md").rstrip, *sections].join("\n\n") << "\n"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A ## section from one step file's text; +fallback+ is its basename,
|
|
106
|
+
# whose title stands in when the file has none.
|
|
107
|
+
def self.section(text, fallback:)
|
|
108
|
+
parsed = FrontMatter.parse(text)
|
|
109
|
+
title = parsed.data["title"] || Slug.title(Slug.bare(fallback))
|
|
110
|
+
attrs = parsed.data.except("title")
|
|
111
|
+
heading = "## #{title}"
|
|
112
|
+
heading += "\n<!-- #{format_attributes(attrs)} -->" unless attrs.empty?
|
|
113
|
+
"#{heading}\n\n#{parsed.body.strip}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The reverse of parse_attributes:
|
|
117
|
+
# { "kind" => "automated", "timeout" => 300 } => "kind: automated, timeout: 300".
|
|
118
|
+
# A string YAML would read as something else (yes, 010, a: b) is quoted.
|
|
119
|
+
def self.format_attributes(data)
|
|
120
|
+
data.map do |key, value|
|
|
121
|
+
plain = value.is_a?(String) && value.match?(%r{\A[A-Za-z][\w./-]*\z}) && YAML.safe_load(value) == value
|
|
122
|
+
"#{key}: #{plain || !value.is_a?(String) ? value : value.to_json}"
|
|
123
|
+
end.join(", ")
|
|
124
|
+
end
|
|
92
125
|
end
|
|
93
126
|
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runsheets
|
|
4
|
+
# The text rules shared by steps, single-file sections and the --new
|
|
5
|
+
# wizard, so each kind of name is made the same way everywhere.
|
|
6
|
+
module Slug
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# "DB Refresh!" => "db-refresh": lowercase letters and digits joined by
|
|
10
|
+
# single hyphens. Text with nothing usable in it gives "".
|
|
11
|
+
def of(text) = text.to_s.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
12
|
+
|
|
13
|
+
# "020-take-a-backup" => "take-a-backup": a step slug without its number.
|
|
14
|
+
def bare(slug) = slug.to_s.sub(/\A\d+[-_]?/, "")
|
|
15
|
+
|
|
16
|
+
# "db-refresh" => "Db refresh": the title a slug or name suggests.
|
|
17
|
+
def title(text) = text.to_s.tr("-_", " ").capitalize
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/runsheets/step.rb
CHANGED
|
@@ -36,7 +36,7 @@ module Runsheets
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def title
|
|
39
|
-
data["title"] || Renderer.title_of(body) ||
|
|
39
|
+
data["title"] || Renderer.title_of(body) || Slug.title(Slug.bare(slug))
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def kind = (data["kind"] || (blocks.any?(&:executable?) ? "automated" : "manual")).to_s
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Runsheets
|
|
4
|
+
# `runsheets --run STEP RUNBOOK`: run one step of a runbook at the
|
|
5
|
+
# terminal. It is recorded like a run from the page: a session with an
|
|
6
|
+
# engineer and a why, a run record, and the session log, whose echo shows
|
|
7
|
+
# each block's output as it comes.
|
|
8
|
+
#
|
|
9
|
+
# Each block does at the terminal what it does on the page:
|
|
10
|
+
#
|
|
11
|
+
# run runs, and fails the step unless it exits 0
|
|
12
|
+
# expect is compared with the output of the block above it
|
|
13
|
+
# destructive runs only once its confirmation code is typed
|
|
14
|
+
# terminal is shown, and the operator says they ran it
|
|
15
|
+
# background cannot run here: nothing would be left to stop it
|
|
16
|
+
#
|
|
17
|
+
# A step with nothing to run or confirm is shown, and the operator says it
|
|
18
|
+
# is done. Answers are read from +input+, so when input runs out (a cron
|
|
19
|
+
# job, stdin from /dev/null) anything that needs a person fails the step
|
|
20
|
+
# rather than waiting.
|
|
21
|
+
class StepRunner
|
|
22
|
+
attr_reader :session, :runbook, :step
|
|
23
|
+
|
|
24
|
+
# The document of +runbook+ that +name+ names: its slug ("020-greet"),
|
|
25
|
+
# else its slug without the number ("greet"), which covers verify,
|
|
26
|
+
# rollback and runbook too. A number names nothing here; numbers are
|
|
27
|
+
# only places in the list choose_step shows. Raises RunbookError saying
|
|
28
|
+
# what can be named when nothing, or more than one step, matches.
|
|
29
|
+
def self.find_step(runbook, name)
|
|
30
|
+
name = name.to_s.strip
|
|
31
|
+
if name.match?(/\A\d+\z/)
|
|
32
|
+
raise RunbookError, "#{name.inspect} is a number; name the step by its slug or name, or leave STEP out to choose from a numbered list"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
found = runbook.step(name) and return found
|
|
36
|
+
|
|
37
|
+
matches = runbook.steps.select { Slug.bare(it.slug) == name }
|
|
38
|
+
return matches.first if matches.one?
|
|
39
|
+
|
|
40
|
+
what = matches.empty? ? "no step #{name.inspect}" : "#{name.inspect} matches #{matches.map(&:slug).join(', ')}"
|
|
41
|
+
raise RunbookError, "#{what} in #{runbook.title} (#{runbook.main_path}); steps: #{runbook.documents.keys.join(', ')}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The documents --run offers to choose from: the numbered steps, then
|
|
45
|
+
# verify and rollback, after the landing page when it has blocks to run
|
|
46
|
+
# or confirm.
|
|
47
|
+
def self.choices(runbook)
|
|
48
|
+
landing = runbook.landing if runbook.landing.blocks.any? { it.executable? || it.terminal? }
|
|
49
|
+
[landing, *runbook.steps, *runbook.extras.values].compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Ask which step to run, listing the choices. The answer is a number in
|
|
53
|
+
# the list or anything find_step takes; a wrong one is asked again.
|
|
54
|
+
# Raises RunError when input runs out with nothing chosen.
|
|
55
|
+
def self.choose_step(runbook, input:, output:)
|
|
56
|
+
choices = choices(runbook)
|
|
57
|
+
raise RunbookError, "#{runbook.title} has no steps" if choices.empty?
|
|
58
|
+
|
|
59
|
+
prompt = Prompt.new(input:, output:)
|
|
60
|
+
output.puts "Steps in #{runbook.title}:", *choice_lines(choices)
|
|
61
|
+
loop do
|
|
62
|
+
output.print "Which step? (its number in the list, or its name): "
|
|
63
|
+
answer = prompt.line or raise RunError, "no step chosen"
|
|
64
|
+
|
|
65
|
+
pick = pick_step(runbook, choices, answer) and return pick
|
|
66
|
+
output.puts " #{answer.inspect} is not one of the steps"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The list choose_step shows: number, slug and title, with the steps
|
|
71
|
+
# that cannot run at a terminal marked.
|
|
72
|
+
def self.choice_lines(choices)
|
|
73
|
+
width = choices.map { it.slug.size }.max
|
|
74
|
+
choices.map.with_index(1) do |step, i|
|
|
75
|
+
format(" %<i>2d) %<slug>-#{width}s %<title>s%<note>s", i:, slug: step.slug, title: step.title, note: refusal(step) ? " (page only)" : "")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The choice +answer+ names, or nil. A number is a place in the list,
|
|
80
|
+
# and only that; anything else is looked up as find_step does.
|
|
81
|
+
def self.pick_step(runbook, choices, answer)
|
|
82
|
+
return if answer.empty?
|
|
83
|
+
return choices[answer.to_i - 1] if answer.match?(/\A\d+\z/) && answer.to_i.between?(1, choices.size)
|
|
84
|
+
|
|
85
|
+
find_step(runbook, answer)
|
|
86
|
+
rescue RunbookError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Why +step+ cannot be run at a terminal, or nil.
|
|
91
|
+
def self.refusal(step)
|
|
92
|
+
background = step.blocks.select(&:background?)
|
|
93
|
+
return if background.empty?
|
|
94
|
+
|
|
95
|
+
"#{step.slug} has a background block (#{background.map(&:id).join(', ')}), which nothing would be left to stop; " \
|
|
96
|
+
"start it from the page"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Declared inputs that +step+'s blocks refer to and +values+ leaves blank.
|
|
100
|
+
def self.blank_inputs(runbook, step, values)
|
|
101
|
+
referenced = step.blocks.select { it.executable? || it.terminal? }.flat_map(&:referenced_variables)
|
|
102
|
+
runbook.inputs.select { referenced.include?(it.name) && values[it.name].to_s.empty? }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# What a prompt shows of an input's current value: " [value]", " [set]"
|
|
106
|
+
# for a secret with one, or "" when it has none.
|
|
107
|
+
def self.shown(input, current)
|
|
108
|
+
return "" if current.to_s.empty?
|
|
109
|
+
|
|
110
|
+
" [#{input.secret? ? 'set' : current}]"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Expected and actual lines that differ, marked - and +, for a failed
|
|
114
|
+
# Expectation comparison.
|
|
115
|
+
def self.mismatch_lines(result)
|
|
116
|
+
result.rows.reject { it.tag == :same }.flat_map do |row|
|
|
117
|
+
[("- #{row.expected.map(&:first).join}" if row.expected), ("+ #{row.actual.map(&:first).join}" if row.actual)].compact
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# "A has no value; the step needs it", or for several names
|
|
122
|
+
# "A, B have no value; the step needs them".
|
|
123
|
+
def self.blank_message(names)
|
|
124
|
+
many = names.size > 1
|
|
125
|
+
"#{names.join(', ')} #{many ? 'have' : 'has'} no value; the step needs #{many ? 'them' : 'it'}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Raises RunError when +step+ cannot be run at a terminal, so a step
|
|
129
|
+
# that would be refused is refused before any session starts.
|
|
130
|
+
def initialize(runbook:, step:, input: $stdin, output: $stdout)
|
|
131
|
+
refusal = self.class.refusal(step)
|
|
132
|
+
raise RunError, refusal if refusal
|
|
133
|
+
|
|
134
|
+
@runbook = runbook
|
|
135
|
+
@step = step
|
|
136
|
+
@output = output
|
|
137
|
+
@prompt = Prompt.new(input:, output:)
|
|
138
|
+
@session = nil
|
|
139
|
+
@run = nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Run the step in +session+. Returns the exit status: 0 when every
|
|
143
|
+
# block ran, matched and was confirmed, 1 otherwise. A numbered step
|
|
144
|
+
# that succeeds is marked done.
|
|
145
|
+
def run(session)
|
|
146
|
+
@session = session
|
|
147
|
+
inputs = ask_inputs
|
|
148
|
+
@run = session.open(runbook, inputs:)
|
|
149
|
+
@output.puts "#{step.title} [#{step.slug}]"
|
|
150
|
+
return 1 unless performed?
|
|
151
|
+
|
|
152
|
+
@run.mark_step(step.slug, status: "done") if step.position
|
|
153
|
+
0
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Do the step: each block in turn, stopping at the first that fails, or
|
|
157
|
+
# for a step with nothing to run, ask whether it is done.
|
|
158
|
+
def performed? = needs_person? ? done? : step.blocks.all? { perform(it) }
|
|
159
|
+
|
|
160
|
+
# Whether the step has nothing to run or confirm, so a person says when
|
|
161
|
+
# it is done.
|
|
162
|
+
def needs_person? = step.blocks.none? { it.executable? || it.terminal? }
|
|
163
|
+
|
|
164
|
+
# Every input the runbook declares, asked for in order as the page's
|
|
165
|
+
# start form does, each offering the value it has now (from the
|
|
166
|
+
# environment or its default) for Enter to keep. Once input runs out
|
|
167
|
+
# the rest keep theirs unasked. Raises RunError when an input the step
|
|
168
|
+
# uses is still blank.
|
|
169
|
+
def ask_inputs
|
|
170
|
+
values = Run.resolve_inputs(runbook, {})
|
|
171
|
+
values = runbook.inputs.to_h { [it.name, ask_input(it, values[it.name])] }
|
|
172
|
+
blank = self.class.blank_inputs(runbook, step, values).map(&:name)
|
|
173
|
+
raise RunError, self.class.blank_message(blank) if blank.any?
|
|
174
|
+
|
|
175
|
+
values
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# One input's value: what was typed, else +current+. A secret is read
|
|
179
|
+
# without echo, and its value is never shown.
|
|
180
|
+
def ask_input(input, current)
|
|
181
|
+
return current if @prompt.exhausted?
|
|
182
|
+
|
|
183
|
+
@output.print "#{input.prompt || input.name} (#{input.name})#{self.class.shown(input, current)}: "
|
|
184
|
+
answer = input.secret? ? @prompt.secret : @prompt.line
|
|
185
|
+
answer.to_s.empty? ? current : answer
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Do what +block+ asks for. Returns whether the step can carry on.
|
|
189
|
+
def perform(block)
|
|
190
|
+
case block.kind
|
|
191
|
+
when :run, :destructive then execute(block)
|
|
192
|
+
when :terminal then confirm_terminal(block)
|
|
193
|
+
else true
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Run a block, then check its exit status and its expect block.
|
|
198
|
+
def execute(block)
|
|
199
|
+
execution = start(block) or return false
|
|
200
|
+
execution.wait
|
|
201
|
+
return failed(block.id, Run.ending(execution).last) unless execution.success?
|
|
202
|
+
|
|
203
|
+
expected = step.expected_output(block.id)
|
|
204
|
+
expected.nil? || matches?(block, expected, execution.output)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Start a block, asking for its confirmation code when it is
|
|
208
|
+
# destructive. Returns the Execution, or nil when it was not confirmed.
|
|
209
|
+
def start(block)
|
|
210
|
+
id = block.id
|
|
211
|
+
begin
|
|
212
|
+
@run.execute(id)
|
|
213
|
+
rescue Run::ConfirmationRequired => e
|
|
214
|
+
@output.print "#{id} is destructive. Type #{e.challenge} to run it: "
|
|
215
|
+
start_confirmed(id)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Run block +id+ with the code that is typed; nil when it is not the code.
|
|
220
|
+
def start_confirmed(id)
|
|
221
|
+
@run.execute(id, confirm: @prompt.line.to_s)
|
|
222
|
+
rescue Run::ConfirmationRequired
|
|
223
|
+
failed(id, "not confirmed")
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Whether +output+ is what the expect block says; prints the lines that
|
|
228
|
+
# differ when it is not.
|
|
229
|
+
def matches?(block, expected, output)
|
|
230
|
+
result = Expectation.compare(expected, output)
|
|
231
|
+
return true if result.match?
|
|
232
|
+
|
|
233
|
+
@output.puts "#{block.id}: the output is not what the expect block says:", *self.class.mismatch_lines(result).map { " #{it}" }
|
|
234
|
+
failed(block.id, "output did not match")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Show a terminal block and ask whether the operator ran it.
|
|
238
|
+
def confirm_terminal(block)
|
|
239
|
+
@output.puts "Run this yourself:", *block.code.lines(chomp: true).map { " #{it}" }
|
|
240
|
+
return failed(block.id, "not confirmed") unless @prompt.yes?("Did you run it?")
|
|
241
|
+
|
|
242
|
+
@output.print "Note (optional): "
|
|
243
|
+
@run.acknowledge(block.id, note: @prompt.line)
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Show a step with nothing to run and ask whether it is done.
|
|
248
|
+
def done?
|
|
249
|
+
@output.puts "", step.body.strip, ""
|
|
250
|
+
@prompt.yes?("Done?") || failed(step.slug, "not done")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
# Say why +label+ (a block id or step slug) failed; false, for the step.
|
|
256
|
+
def failed(label, why)
|
|
257
|
+
@output.puts "#{label}: #{why}"
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Runsheets
|
|
6
|
+
# A starter runbook that `runsheets --new` fills in: a directory under
|
|
7
|
+
# templates/ at the gem root holding a directory runbook. Files ending in
|
|
8
|
+
# .erb are rendered (the suffix dropped); any other file is copied as is.
|
|
9
|
+
#
|
|
10
|
+
# templates/asgard/
|
|
11
|
+
# template.yml order: and description: for the --new menu
|
|
12
|
+
# runbook.md.erb
|
|
13
|
+
# steps/010-preflight.md.erb
|
|
14
|
+
# verify.md.erb (optional)
|
|
15
|
+
# rollback.md.erb (optional)
|
|
16
|
+
# .loki support files: anything else, copied beside
|
|
17
|
+
#
|
|
18
|
+
# Each template is written once, in the directory shape. A single-file
|
|
19
|
+
# runbook is made from the rendered files (SingleFile.from_directory), so the two
|
|
20
|
+
# shapes cannot drift apart. Step bodies must therefore use ### and
|
|
21
|
+
# deeper headings: a ## heading would start a new step in a single file.
|
|
22
|
+
class Template
|
|
23
|
+
DIR = File.expand_path("../../templates", __dir__)
|
|
24
|
+
DEFAULT = "tasks"
|
|
25
|
+
META = "template.yml"
|
|
26
|
+
|
|
27
|
+
# What the ERB sees. +yaml+ quotes a value for front matter, so a title
|
|
28
|
+
# such as "null" or "yes: no" stays a string. +ruby_string+ makes a
|
|
29
|
+
# value a Ruby string literal for a support file such as asgard's .loki,
|
|
30
|
+
# so a quote or #{} in a title is text, not code.
|
|
31
|
+
Context = Data.define(:name, :title, :when_to_use, :escalation, :single_file) do
|
|
32
|
+
def yaml(value) = value.to_s.to_json
|
|
33
|
+
def ruby_string(value) = value.to_s.dump
|
|
34
|
+
def binding_for = binding
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A template without an order in template.yml sorts after those with one.
|
|
38
|
+
UNORDERED = 1_000
|
|
39
|
+
|
|
40
|
+
attr_reader :name, :dir, :description, :order
|
|
41
|
+
|
|
42
|
+
# Every template under +root+, by the order in its template.yml, then
|
|
43
|
+
# by name. Raises RunbookError when there are none.
|
|
44
|
+
def self.all(root: DIR)
|
|
45
|
+
templates = Dir.glob("*/", base: root).map { new(File.join(root, it.chomp("/"))) }.sort_by { [it.order, it.name] }
|
|
46
|
+
raise RunbookError, "no runbook templates found in #{root}" if templates.empty?
|
|
47
|
+
|
|
48
|
+
templates
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Whether +rel+, a rendered path, is part of the runbook itself rather
|
|
52
|
+
# than a support file: runbook.md, a step, verify.md or rollback.md.
|
|
53
|
+
def self.runbook_file?(rel)
|
|
54
|
+
rel == "runbook.md" || rel.match?(%r{\A#{Runbook::STEPS_DIR}/[^/]+\.md\z}) || Runbook::EXTRAS.any? { rel == "#{it}.md" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The rendered files that are not the runbook's own markdown.
|
|
58
|
+
def self.support_files(files) = files.reject { |rel, _| runbook_file?(rel) }
|
|
59
|
+
|
|
60
|
+
def initialize(dir)
|
|
61
|
+
@dir = dir
|
|
62
|
+
@name = File.basename(dir)
|
|
63
|
+
meta = File.file?(meta_path) ? YAML.safe_load_file(meta_path, aliases: false) : {}
|
|
64
|
+
meta = {} unless meta.is_a?(Hash)
|
|
65
|
+
@description = meta["description"].to_s
|
|
66
|
+
@order = Integer(meta["order"], exception: false) || UNORDERED
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The rendered directory runbook: relative path => text. An .erb file
|
|
70
|
+
# is rendered with +context+ and loses the suffix; others are copied.
|
|
71
|
+
def render(context)
|
|
72
|
+
sources.to_h do |rel|
|
|
73
|
+
text = File.read(File.join(dir, rel))
|
|
74
|
+
next [rel, text] unless rel.end_with?(".erb")
|
|
75
|
+
|
|
76
|
+
[rel.delete_suffix(".erb"), ERB.new(text, trim_mode: "-").result(context.binding_for)]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The template's files, relative to its directory, sorted: everything,
|
|
81
|
+
# dotfiles included, but template.yml.
|
|
82
|
+
def sources
|
|
83
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).select { File.file?(File.join(dir, it)) }.reject { it == META }.sort
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Whether the template brings files besides the runbook's markdown, such
|
|
87
|
+
# as asgard's .loki files. A single-file runbook from it gets a folder.
|
|
88
|
+
def support_files? = sources.any? { !self.class.runbook_file?(it.delete_suffix(".erb")) }
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def meta_path = File.join(dir, META)
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/runsheets/version.rb
CHANGED