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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b4caf43ab35c8816f90d794e6dba06e3fd3644cd838c89c9987bbe0f60ab786
4
- data.tar.gz: 39cd9610b55e341bc1b664443a509a2269f7397c190e28f38fcbcc6c9287f2db
3
+ metadata.gz: 92cd3b7a77a52879a46fe002837ab2c7f0df600865eed44ed31f6b2b75491ff3
4
+ data.tar.gz: e70a7b616393d87c1d6fa83e4952e73da0f33da1dd3aab454aa774f03ac197aa
5
5
  SHA512:
6
- metadata.gz: cec8d91aa87096dfae4548d1eb39df3c9b6d57e0d2176d24e12550f9ff7a21c0487e0f850b7fd0c5fd9178f7cd471057e40771c3a2918b897ec7f57fa63b1282
7
- data.tar.gz: 2d960f098334bf2b7900eae5084c1fa722a27b6c115d6da26e4020e4cd9b5c32a546d265ba448a36d4bbb6dfcfdb730ae16d7ab0b3112ae6308d341f54c16ba0
6
+ metadata.gz: 2737dd58316509fb19cf8897b41d3983d538011a64725bd7320fd0e61429e42664063894d0e6f5e70acfd3078df4c222b07fb6552b249b7c6aef7b7978fa224c
7
+ data.tar.gz: aee18d7c0923c297ec38ffa5ffe6b28b59a75a9b19831c3a48c42168ea0b099edd13955fdaadbdd01c1ded1a599e936c1dbddbe19bdf603b04bdb6245a18c8d4
data/Archspec.rb ADDED
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Executable architecture for runsheets, checked by the archspec gem:
4
+ #
5
+ # archspec check
6
+ # archspec explain lib/runsheets/session.rb
7
+ #
8
+ # docs/reference/architecture.md describes the design; this file enforces it.
9
+ # runsheets is a gem: a document model, an execution layer, a record writer,
10
+ # a session over them, and two front ends (a Sinatra app and the terminal)
11
+ # wired together by the CLI. Dependencies point inward only:
12
+ #
13
+ # cli ─┬─> routes ─> pages ─┐
14
+ # └─> console ─────────┴─> session ─┬─> execution ─┐
15
+ # ├─> recording ─┤
16
+ # └─> document ──┴─> foundation
17
+ #
18
+ # Pages and routes may also read the document and recording layers directly
19
+ # (rendering, history); nothing outside session reaches execution.
20
+
21
+ root "."
22
+ source "lib/**/*.rb"
23
+
24
+ # Known violations, burned down rather than allowed: refresh with
25
+ # `archspec check --update-todo` only after fixing one.
26
+ todo "archspec_todo.yml"
27
+
28
+ # Shared building blocks with no runsheets dependencies beyond each other:
29
+ # the Runsheets module itself, settings, parsing helpers, the redactor, and the
30
+ # session log (a plain Logger wrapper that Config borrows LEVELS from).
31
+ component :foundation, in: %w[
32
+ lib/runsheets.rb
33
+ lib/runsheets/version.rb
34
+ lib/runsheets/config.rb
35
+ lib/runsheets/front_matter.rb
36
+ lib/runsheets/fences.rb
37
+ lib/runsheets/slug.rb
38
+ lib/runsheets/block.rb
39
+ lib/runsheets/diff.rb
40
+ lib/runsheets/expectation.rb
41
+ lib/runsheets/redactor.rb
42
+ lib/runsheets/session_log.rb
43
+ ]
44
+
45
+ # The document model: markdown files rendered into steps, runbooks, libraries.
46
+ component :document, in: %w[
47
+ lib/runsheets/renderer.rb
48
+ lib/runsheets/step.rb
49
+ lib/runsheets/single_file.rb
50
+ lib/runsheets/runbook.rb
51
+ lib/runsheets/library.rb
52
+ lib/runsheets/search.rb
53
+ ]
54
+
55
+ # Spawning, pumping, reaping and timing out a block's process.
56
+ component :execution, in: %w[
57
+ lib/runsheets/execution.rb
58
+ lib/runsheets/executor.rb
59
+ ]
60
+
61
+ # The runsheet: run.json, run.md and blocks/ on disk.
62
+ component :recording, in: %w[lib/runsheets/run_record.rb]
63
+
64
+ # The engineering session and each runbook's run inside it.
65
+ component :session, in: %w[
66
+ lib/runsheets/run.rb
67
+ lib/runsheets/session.rb
68
+ ]
69
+
70
+ # Pure functions from a session to HTML, plus the inline CSS and JavaScript.
71
+ component :pages, in: %w[
72
+ lib/runsheets/pages.rb
73
+ lib/runsheets/pages/**/*.rb
74
+ lib/runsheets/assets.rb
75
+ ]
76
+
77
+ # The Sinatra app: routes and the host/token security checks.
78
+ component :routes, in: %w[lib/runsheets/web.rb]
79
+
80
+ # Terminal front end: prompts, templates, --new and --run.
81
+ component :console, in: %w[
82
+ lib/runsheets/prompt.rb
83
+ lib/runsheets/template.rb
84
+ lib/runsheets/wizard.rb
85
+ lib/runsheets/step_runner.rb
86
+ ]
87
+
88
+ # Option parsing and the bin/runsheets entry point; the composition root.
89
+ component :cli, in: %w[lib/runsheets/cli.rb]
90
+
91
+ # ── Layering ────────────────────────────────────────────────────────────────
92
+
93
+ foundation.can_only_use(because: "the foundation is shared by every layer and depends on none")
94
+ document.can_only_use :foundation,
95
+ because: "the document model is tested in isolation and knows nothing of runs or front ends"
96
+ execution.can_only_use :foundation,
97
+ because: "the executor runs code it is handed; it does not know runbooks, records or sessions"
98
+ recording.can_only_use :foundation,
99
+ because: "run records are plain data on disk, readable without a live session"
100
+ session.can_only_use :foundation, :document, :execution, :recording,
101
+ because: "the session sits over the model and knows nothing of HTTP or the terminal"
102
+ pages.can_only_use :foundation, :document, :recording, :session,
103
+ because: "pages render a session; they never touch processes or routes"
104
+ routes.can_only_use :foundation, :document, :recording, :session, :pages,
105
+ because: "routes talk to the session and render through Pages; execution goes through the session"
106
+ console.can_only_use :foundation, :document, :session,
107
+ because: "terminal flows run steps through the session, never the web layer"
108
+ cli.can_only_use :foundation, :document, :session, :pages, :routes, :console,
109
+ because: "the CLI wires the front ends together"
110
+
111
+ no_cycles
112
+
113
+ # ── Third-party boundaries ──────────────────────────────────────────────────
114
+ #
115
+ # Each library stays behind the component that owns it: the web stack behind
116
+ # routes (and Rack::Utils for escaping in pages), kramdown and rouge behind
117
+ # Renderer, myway_config behind Config. The model loads without Sinatra.
118
+
119
+ VENDORS = {
120
+ %w[Sinatra Puma Rackup] => %i[routes],
121
+ %w[Rack] => %i[routes pages],
122
+ %w[Kramdown Rouge] => %i[document],
123
+ %w[MywayConfig Anyway] => %i[foundation]
124
+ }.freeze
125
+
126
+ vendor_reason = "third-party libraries are reached only through the component that owns them"
127
+ %i[foundation document execution recording session pages routes console cli].each do |layer|
128
+ forbidden = VENDORS.reject { |_, owners| owners.include?(layer) }.keys.flatten
129
+ send(layer).cannot_reference_constants(*forbidden, because: vendor_reason)
130
+ end
131
+
132
+ # ── Process control ─────────────────────────────────────────────────────────
133
+
134
+ spawn_reason = "only Executor spawns runbook code, so every execution is recorded and redacted"
135
+ %i[foundation document recording session pages routes console].each do |layer|
136
+ send(layer).cannot_call(:spawn, :exec, :system, :popen, :popen3, :capture2, :capture3, because: spawn_reason)
137
+ end
data/CHANGELOG.md CHANGED
@@ -1,6 +1,40 @@
1
1
  # Changelog
2
2
 
3
- ## [Unreleased]
3
+ ## [0.0.2] - Unreleased
4
+
5
+ ### Added
6
+
7
+ - `runsheets --new [DIR]` asks what kind of runbook to make (a directory or a
8
+ single file, a template, a name, a title, when to use it and who to call)
9
+ and writes it into `DIR`. Enter accepts each default, and answers can be
10
+ piped in. Without `DIR` it asks for one, offering the `dir:` a config file
11
+ sets, else `RUNSHEETS_DIR`.
12
+ - Runbook templates in `templates/`, a ladder of structure: `checks` (checks
13
+ only), `tasks` (checks and tasks, the default), `rollback`, `timeouts`,
14
+ `inputs` and `asgard` (tasks in `.loki` files beside the runbook). Each is
15
+ written once as a directory runbook and produces either shape; a single
16
+ file from a template with support files gets a folder of its own.
17
+ - `runsheets --run [STEP] RUNBOOK` runs one step at the terminal (listing the
18
+ steps to choose from when `STEP` is left out), recorded in a session like
19
+ a run from the page, and exits non-zero if the step fails.
20
+ `--engineer` and `--why` are required. It asks for the runbook's inputs
21
+ first, Enter keeping each one's value. A destructive block still needs
22
+ its confirmation code typed, a terminal block or manual step asks to be
23
+ confirmed, and a step with a background block is refused. Name a step by
24
+ its name or slug, or `verify`, `rollback` or `runbook`; `--run verify` is
25
+ a health check. `STEP` is never a number: numbers are only places in the
26
+ list shown when `STEP` is left out. Exits 0 on success, 1 on failure and
27
+ 130 on Ctrl-C.
28
+ - A relative `RUNBOOK` not found from the current directory is looked for
29
+ under the `dir:` a config file sets, else `RUNSHEETS_DIR`.
30
+ - `--check` warns about a numbered step named like `verify.md`,
31
+ `rollback.md` or the landing page (`030-verify`), since `--run verify`
32
+ runs `verify.md`.
33
+
34
+ ### Removed (breaking)
35
+
36
+ - `--init`, `--no-init`, `RUNSHEETS_INIT` and the `init` setting. Use
37
+ `--new`; the `tasks` template is the nearest to what `--init` wrote.
4
38
 
5
39
  ## [0.0.1] - 2026-10-11
6
40
 
data/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/MadBomber/runsheets/main/docs/assets/images/logo.png" alt="runsheets logo: a smiling runsheet sprinting beside the runsheets wordmark" width="620">
3
+ </p>
4
+
1
5
  # runsheets
2
6
 
3
7
  > **Note:** This is a proof-of-concept and a work in progress. Features,
@@ -47,7 +51,7 @@ runsheets path/to/runbook.md # serve a single all-in-one markdown file
47
51
  runsheets path/to/runbooks # a directory of runbooks: choose one in the browser
48
52
  runsheets --open path/to/runbook # and open the browser
49
53
  runsheets --check path/to/runbook # load it, print authoring warnings, exit
50
- runsheets --init path/to/new # scaffold a runbook (a .md path makes a single file)
54
+ runsheets --new path/to/runbooks # answer a few questions, get a runbook from a template
51
55
  ```
52
56
 
53
57
  The argument is one of three things: a runbook directory, a single
@@ -76,11 +80,11 @@ runsheets --open examples/single_md_file_runbooks/disk-space-triage.md # links t
76
80
 
77
81
  Options: `--port`, `--bind` (default loopback), `--runs-dir` (where run
78
82
  records go; default `~/.local/share/runsheets/runs`), `--open`, `--check`,
79
- `--init`, `--config FILE`, `--dump` to print the settings in force as a
83
+ `--new [DIR]`, `--run [STEP]` to run one step at the terminal (by its name or slug), `--config FILE`, `--dump` to print the settings in force as a
80
84
  config file (redirect it to save them), and for the session `--engineer`,
81
85
  `--why`, `--log-level`, `--verbose` and `--quiet`. Settings are layered: the command line beats
82
86
  `RUNSHEETS_*` environment variables (`RUNSHEETS_PORT`, `RUNSHEETS_DIR` for
83
- the runbook, ...), which beat `./config/runsheets.yml` (or the file `--config` or
87
+ the runbook, under which a relative `RUNBOOK` is also looked for, ...), which beat `./config/runsheets.yml` (or the file `--config` or
84
88
  `RUNSHEETS_CONFIG` names), which beats `~/.config/runsheets/runsheets.yml`,
85
89
  which beats the defaults bundled in `lib/runsheets/config/defaults.yml`. See
86
90
  [docs/running/cli.md](docs/running/cli.md#settings).
data/archspec_todo.yml ADDED
@@ -0,0 +1,32 @@
1
+ ---
2
+ violations:
3
+ - id: bd6f748ea23f791211b2342d
4
+ rule: dependencies.no_cycles
5
+ path: lib/runsheets/cli.rb
6
+ line: 322
7
+ message: 'component dependency cycle: cli -> console -> cli'
8
+ - id: 4af0bc5913dc3dfbdd2512bc
9
+ rule: dependencies.allow
10
+ path: lib/runsheets/pages.rb
11
+ line: 648
12
+ message: pages may not depend on routes
13
+ - id: 542009c67c4ebffeb4d329f7
14
+ rule: dependencies.no_cycles
15
+ path: lib/runsheets/pages.rb
16
+ line: 648
17
+ message: 'component dependency cycle: pages -> routes -> pages'
18
+ - id: 4af0bc5913dc3dfbdd2512bc
19
+ rule: dependencies.allow
20
+ path: lib/runsheets/pages.rb
21
+ line: 651
22
+ message: pages may not depend on routes
23
+ - id: 5521b0d9be73ab02461a4abe
24
+ rule: dependencies.allow
25
+ path: lib/runsheets/wizard.rb
26
+ line: 237
27
+ message: console may not depend on cli
28
+ - id: 5521b0d9be73ab02461a4abe
29
+ rule: dependencies.allow
30
+ path: lib/runsheets/wizard.rb
31
+ line: 238
32
+ message: console may not depend on cli
data/lib/runsheets/cli.rb CHANGED
@@ -6,7 +6,7 @@ require "socket"
6
6
 
7
7
  module Runsheets
8
8
  # The `runsheets` command: load a runbook (a directory or a single markdown
9
- # file) and serve it, check it, or scaffold a new one.
9
+ # file) and serve it or check it; or ask some questions and write a new one.
10
10
  #
11
11
  # Settings come from Config: bundled defaults, then the config file, then
12
12
  # RUNSHEETS_* variables, then whatever is given on the command line.
@@ -22,7 +22,7 @@ module Runsheets
22
22
  # names for the options, :config for the config file. Raises
23
23
  # OptionParser::ParseError on bad input; returns nil after printing help
24
24
  # or the version.
25
- def self.parse(argv, out: $stdout)
25
+ def self.parse(argv, out: $stdout, cwd: Dir.pwd)
26
26
  options = {}
27
27
  defaults = Config.bundled_defaults
28
28
  parser = OptionParser.new do |opts|
@@ -30,6 +30,8 @@ module Runsheets
30
30
  #{PROGRAM} #{VERSION} - executable runbooks in your browser
31
31
 
32
32
  Usage: #{PROGRAM} [options] [RUNBOOK]
33
+ #{PROGRAM} --new [DIR]
34
+ #{PROGRAM} --run [STEP] --engineer NAME --why TEXT [RUNBOOK]
33
35
 
34
36
  RUNBOOK is a directory holding runbook.md and a steps/ directory of
35
37
  markdown files, a single all-in-one markdown file whose ## headings
@@ -44,8 +46,8 @@ module Runsheets
44
46
  then ./config/runsheets.yml (or the file --config names), then the
45
47
  environment variable named beside each option, then the command line,
46
48
  which wins. RUNBOOK itself can be set as `dir:` in a config file or
47
- with RUNSHEETS_DIR. Flags take 1, true, yes or on; --no-open,
48
- --no-check and --no-init switch a flag off that a lower layer turned on.
49
+ with RUNSHEETS_DIR. Flags take 1, true, yes or on; --no-open and
50
+ --no-check switch a flag off that a lower layer turned on.
49
51
 
50
52
  Options:
51
53
  BANNER
@@ -64,11 +66,28 @@ module Runsheets
64
66
 
65
67
  rest = parser.parse(argv)
66
68
  raise OptionParser::ParseError, "too many arguments: #{rest.join(' ')}" if rest.size > 1
69
+ raise OptionParser::ParseError, "--new takes the directory to create the runbook in; give no RUNBOOK with it" if options.key?(:new) && rest.any?
70
+ raise OptionParser::ParseError, "--run and --new cannot be used together" if options.key?(:new) && options.key?(:run)
67
71
 
72
+ rest = runbook_taken_by_run(options, rest, cwd:)
68
73
  options[:dir] = rest.first if rest.first
69
74
  options
70
75
  end
71
76
 
77
+ # --run's step is optional, so in `--run db-refresh` it takes the
78
+ # runbook as the step. A value with a / or ending in .md, or that names
79
+ # something in +cwd+, is no step name: when nothing else was given as
80
+ # RUNBOOK it is moved there, and the step is asked for. Returns the
81
+ # arguments left over.
82
+ def self.runbook_taken_by_run(options, rest, cwd: Dir.pwd)
83
+ value = options[:run]
84
+ return rest unless rest.empty? && value
85
+ return rest unless value.include?("/") || value.end_with?(".md") || File.exist?(File.join(cwd, value))
86
+
87
+ options[:run] = nil
88
+ [value]
89
+ end
90
+
72
91
  # The options about serving: config file, port, bind, records, and the
73
92
  # one-shot actions.
74
93
  def self.server_options(opts, options, defaults)
@@ -78,7 +97,9 @@ module Runsheets
78
97
  opts.on("--runs-dir DIR", "Where run records are written (default: #{defaults[:runs_dir]}) [RUNSHEETS_RUNS_DIR]") { options[:runs_dir] = it }
79
98
  opts.on("-o", "--[no-]open", "Open the browser once the server is up [RUNSHEETS_OPEN]") { options[:open] = it }
80
99
  opts.on("--[no-]check", "Load the runbook, report authoring warnings, and exit [RUNSHEETS_CHECK]") { options[:check] = it }
81
- opts.on("--[no-]init", "Create a new runbook skeleton at RUNBOOK (a directory, or a .md file) and exit [RUNSHEETS_INIT]") { options[:init] = it }
100
+ opts.on("--new [DIR]", "Ask what kind of runbook to make, write it from a template into DIR (asked for when left out), and exit") { options[:new] = it }
101
+ opts.on("--run [STEP]", "Run one step of RUNBOOK at the terminal, named by its slug or name (or verify, rollback, runbook); " \
102
+ "left out, choose it by number from a list. Needs -e and -w") { options[:run] = it }
82
103
  opts.on("--[no-]dump", "Print the settings in force as a config file to stdout and exit [RUNSHEETS_DUMP]") { options[:dump] = it }
83
104
  end
84
105
 
@@ -117,27 +138,37 @@ module Runsheets
117
138
  0
118
139
  end
119
140
 
120
- # The runbook a configuration points at: its dir, else the bundled example.
121
- # --init insists on an explicit one.
122
- def self.runbook_path(config)
123
- if config.init && config.dir.nil?
124
- raise OptionParser::ParseError, "--init needs the path of the runbook to create (RUNBOOK, dir: in the config file, or RUNSHEETS_DIR)"
125
- end
141
+ # The RUNBOOK argument as a path. A relative one is found from the
142
+ # current directory, else under +base+: the dir RUNSHEETS_DIR or a
143
+ # config file sets (or the directory of a single-file runbook it
144
+ # names). Raises RunbookError naming both places when neither has it.
145
+ def self.resolve_runbook(arg, base:, cwd: Dir.pwd)
146
+ here = Config.expand_path(arg, cwd)
147
+ return here if File.exist?(here) || base.nil? || Pathname.new(arg).absolute? || arg.start_with?("~")
126
148
 
127
- config.dir || DEFAULT_RUNBOOK
149
+ base = File.dirname(base) if File.file?(base)
150
+ under = Config.expand_path(arg, base)
151
+ return under if File.exist?(under)
152
+
153
+ raise RunbookError, "no such runbook: #{here}, nor #{under} under the runbook directory #{base}"
128
154
  end
129
155
 
156
+ # The runbook a configuration points at: its dir, else the bundled example.
157
+ def self.runbook_path(config) = config.dir || DEFAULT_RUNBOOK
158
+
130
159
  # Entry point for the executable. Returns the process exit status.
131
- def self.run(argv, out: $stdout, err: $stderr)
160
+ # --new and --run read their answers from +input+.
161
+ def self.run(argv, out: $stdout, err: $stderr, input: $stdin)
132
162
  options = parse(argv, out:)
133
163
  return 0 unless options
164
+ return new_runbook(options[:new], default: configured_runbook_dir(offline_config(options)), input:, out:) if options.key?(:new)
134
165
 
135
- config = configure(options)
166
+ config = options.key?(:run) ? offline_config(options) : configure(options)
167
+ config.dir = resolve_runbook(options[:dir], base: configured_runbook_dir(config)) if options[:dir]
168
+ return run_step(options[:run], config, input:, out:) if options.key?(:run)
136
169
  return dump(config, out:) if config.dump
137
170
 
138
- path = runbook_path(config)
139
- return init(path, out:) if config.init
140
-
171
+ path = runbook_path(config)
141
172
  target = Library.library?(path) ? Library.load(path) : Runbook.load(path)
142
173
  return check(target, out:, err:) if config.check
143
174
 
@@ -146,7 +177,7 @@ module Runsheets
146
177
  rescue Errno::EADDRINUSE
147
178
  err.puts "#{PROGRAM}: port #{config.port} on #{config.bind} is already in use; pick another with --port"
148
179
  1
149
- rescue OptionParser::ParseError, RunbookError, ConfigError,
180
+ rescue OptionParser::ParseError, RunbookError, RunError, ConfigError,
150
181
  Psych::Exception, SocketError, SystemCallError => e # also a bad config file, an unknown host, an unreadable directory
151
182
  err.puts "#{PROGRAM}: #{e.message}"
152
183
  1
@@ -225,7 +256,9 @@ module Runsheets
225
256
 
226
257
  # Who the start page suggests: the configured engineer, else git's
227
258
  # user.name, else the login name.
228
- def self.default_engineer(config) = [config.engineer, git_user_name, ENV["USER"]].find { !it.to_s.strip.empty? }
259
+ def self.default_engineer(config, env: ENV, git_name: git_user_name)
260
+ [config.engineer, git_name, env["USER"]].find { !it.to_s.strip.empty? }
261
+ end
229
262
 
230
263
  def self.git_user_name
231
264
  IO.popen(%w[git config user.name], err: File::NULL, &:read).strip
@@ -267,158 +300,59 @@ module Runsheets
267
300
  nil
268
301
  end
269
302
 
270
- # --- scaffolding -------------------------------------------------------
271
-
272
- # Write a starter runbook. A path ending in .md gets the single-file
273
- # shape; anything else becomes a directory. Refuses to touch an existing
274
- # file or a non-empty directory. Returns the exit status.
275
- def self.init(path, out: $stdout)
276
- files = scaffold(path)
277
- raise RunbookError, "#{path} already exists" if File.file?(path)
278
- raise RunbookError, "#{path} exists and is not empty" if File.directory?(path) && !Dir.empty?(path)
279
-
280
- files.each do |rel, text|
281
- full = rel.start_with?("/") ? rel : File.join(path, rel)
282
- FileUtils.mkdir_p(File.dirname(full))
283
- File.write(full, text)
284
- out.puts "created #{full}"
285
- end
286
- out.puts "Next: #{PROGRAM} --check #{path}"
287
- 0
303
+ # The settings for --new and --run. Nothing is served, so a project
304
+ # config that binds to the network is no concern.
305
+ def self.offline_config(options) = Runsheets.configure(options.except(:config, :new, :run), path: options[:config])
306
+
307
+ # The runbook directory the settings name, apart from the command line:
308
+ # the dir a config file sets, else $RUNSHEETS_DIR, else nil. A dir
309
+ # naming a single-file runbook gives the directory it is in. --new
310
+ # offers it when given no directory, and a relative RUNBOOK not found
311
+ # from the current directory is looked for under it.
312
+ def self.configured_runbook_dir(config, env: ENV)
313
+ dir = config.file_dir
314
+ dir ||= Config.expand_path(env["RUNSHEETS_DIR"].strip) unless Config.blank?(env["RUNSHEETS_DIR"])
315
+ dir && File.file?(dir) ? File.dirname(dir) : dir
288
316
  end
289
317
 
290
- # The files a new runbook starts with, keyed by path. For a single-file
291
- # runbook the one key is the absolute path itself.
292
- def self.scaffold(path)
293
- name = File.basename(path, ".*")
294
- title = name.tr("-_", " ").capitalize
295
- if path.end_with?(".md")
296
- { path => single_file_template(title) }
297
- else
298
- {
299
- "runbook.md" => runbook_template(title),
300
- "steps/010-first-step.md" => <<~MD,
301
- ---
302
- title: First step
303
- kind: automated
304
- timeout: 300
305
- ---
306
-
307
- Say what this step does and why it is here. Then the command:
308
-
309
- ```bash run
310
- echo "hello from #{name}"
311
- ```
312
-
313
- How to tell it worked:
314
-
315
- ```text expect
316
- hello from #{name}
317
- ```
318
- MD
319
- "steps/020-a-manual-step.md" => <<~MD,
320
- ---
321
- title: A manual step
322
- kind: manual
323
- ---
324
-
325
- An instruction the operator carries out and then acknowledges.
326
- MD
327
- "verify.md" => <<~MD,
328
- ---
329
- title: Verify
330
- kind: verify
331
- ---
332
-
333
- Whole-procedure checks. These should pass after every run.
334
-
335
- ```bash run
336
- test -n "$EXAMPLE_INPUT" && echo "EXAMPLE_INPUT is set"
337
- ```
338
- MD
339
- "rollback.md" => <<~MD
340
- ---
341
- title: Rollback
342
- ---
343
-
344
- What to do when it fails partway. This panel sits in the sidebar of
345
- every step.
346
- MD
347
- }
348
- end
318
+ # --new: ask what kind of runbook to make and write it into +dir+; with
319
+ # no dir, ask for that too, offering +default+. Ctrl-C at a question
320
+ # creates nothing. Returns the exit status.
321
+ def self.new_runbook(dir, default: nil, input: $stdin, out: $stdout)
322
+ Wizard.new(dir, input:, output: out, default_parent: default).run
323
+ rescue Interrupt
324
+ out.puts "", Wizard::CANCELLED
325
+ 130
349
326
  end
350
327
 
351
- def self.runbook_template(title)
352
- <<~MD
353
- ---
354
- title: #{title.to_json}
355
- when_to_use: >
356
- One or two sentences on the situation this runbook is for.
357
- prerequisites:
358
- - What must be true or installed before starting
359
- escalation: Who to call when a step fails
360
- tags: []
361
- inputs:
362
- - name: EXAMPLE_INPUT
363
- prompt: An example input, exported as $EXAMPLE_INPUT
364
- default: example
365
- ---
366
-
367
- # #{title}
368
-
369
- Everything above the first step is the preamble: what this procedure does,
370
- what it touches, and what the operator should know before starting.
371
- MD
328
+ # --run: run one step of the configured runbook at the terminal, in a
329
+ # session of its own that ends when the step does. The engineer and why
330
+ # are required, since nobody is at a start page to give them. Ctrl-C
331
+ # stops what is running. Returns the exit status.
332
+ def self.run_step(name, config, input: $stdin, out: $stdout)
333
+ raise ConfigError, "--run needs --engineer and --why: the run is recorded with who ran it and why" unless config.engineer && config.why
334
+
335
+ runbook = run_step_runbook(runbook_path(config))
336
+ step = name ? StepRunner.find_step(runbook, name) : StepRunner.choose_step(runbook, input:, output: out)
337
+ runner = StepRunner.new(runbook:, step:, input:, output: out)
338
+ session = Session.new(engineer: config.engineer, why: config.why, runbook:, **session_options(config, out:))
339
+ status = runner.run(session)
340
+ ending = status.zero? ? "succeeded" : "failed"
341
+ status
342
+ rescue Interrupt
343
+ ending = "was interrupted"
344
+ out.puts "", "Interrupted."
345
+ 130
346
+ ensure
347
+ session&.end!("runsheets --run #{step.slug} #{ending || 'stopped on an error'}")
372
348
  end
373
349
 
374
- def self.single_file_template(title)
375
- <<~MD
376
- ---
377
- title: #{title.to_json}
378
- when_to_use: >
379
- One or two sentences on the situation this runbook is for.
380
- prerequisites:
381
- - What must be true or installed before starting
382
- escalation: Who to call when a step fails
383
- inputs:
384
- - name: EXAMPLE_INPUT
385
- prompt: An example input, exported as $EXAMPLE_INPUT
386
- default: example
387
- ---
388
-
389
- # #{title}
390
-
391
- Everything above the first `##` heading is the preamble. Each `##` heading
392
- below is a step. Step attributes go in an HTML comment right after the
393
- heading.
394
-
395
- ## First step
396
- <!-- kind: automated, timeout: 300 -->
397
-
398
- Say what this step does and why it is here. Then the command:
399
-
400
- ```bash run
401
- echo "hello from $EXAMPLE_INPUT"
402
- ```
403
-
404
- ## A manual step
405
- <!-- kind: manual -->
406
-
407
- An instruction the operator carries out and then acknowledges.
408
-
409
- ## Verify
410
-
411
- Whole-procedure checks. A section headed Verify plays the part of verify.md.
412
-
413
- ```bash run
414
- test -n "$EXAMPLE_INPUT" && echo "EXAMPLE_INPUT is set"
415
- ```
416
-
417
- ## Rollback
350
+ # The runbook at +path+ for --run, which runs a step of one runbook and
351
+ # so cannot be given a library.
352
+ def self.run_step_runbook(path)
353
+ raise RunbookError, "--run needs a runbook, not a library of them: #{path}" if Library.library?(path)
418
354
 
419
- What to do when it fails partway. A section headed Rollback sits in the
420
- sidebar of every step.
421
- MD
355
+ Runbook.load(path)
422
356
  end
423
357
  end
424
358
  end
@@ -21,8 +21,6 @@ defaults:
21
21
  open: false
22
22
  # Load the runbook, report authoring warnings, and exit. (--check, RUNSHEETS_CHECK)
23
23
  check: false
24
- # Create a runbook skeleton at dir and exit. (--init, RUNSHEETS_INIT)
25
- init: false
26
24
  # Print the settings in force as a config file to stdout and exit. (--dump, RUNSHEETS_DUMP)
27
25
  dump: false
28
26
  # Who is starting the session. Empty means ask, prefilled from