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
@@ -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, init, dump, engineer, why, log_level, quiet). Paths are
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 { File.expand_path(it) }
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, :init, :dump, :engineer, :why, :log_level, :quiet
67
- coerce_types dir: PATH, runs_dir: PATH, bind: TEXT, port: PORT, open: FLAG, check: FLAG, init: FLAG, dump: 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
- data = YAML.safe_load_file(path, aliases: false)
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 and init likewise. why is
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 init why]).to_h { [it.to_s, public_send(it)] }
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
- explicit = @explicit_path
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
@@ -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 = title.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "")
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
@@ -36,7 +36,7 @@ module Runsheets
36
36
  end
37
37
 
38
38
  def title
39
- data["title"] || Renderer.title_of(body) || slug.sub(/\A\d+[-_]?/, "").tr("-_", " ").capitalize
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Runsheets
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.2"
5
5
  end