runsheets 0.0.1

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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +98 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +277 -0
  5. data/bin/runsheets +12 -0
  6. data/examples/about-the-examples.md +40 -0
  7. data/examples/directory_based_runbooks/hello/rollback.md +11 -0
  8. data/examples/directory_based_runbooks/hello/runbook.md +40 -0
  9. data/examples/directory_based_runbooks/hello/steps/010-say-hello.md +33 -0
  10. data/examples/directory_based_runbooks/hello/steps/020-inspect-ruby.md +28 -0
  11. data/examples/directory_based_runbooks/hello/steps/030-take-a-breath.md +11 -0
  12. data/examples/directory_based_runbooks/hello/steps/035-keep-a-clock-running.md +21 -0
  13. data/examples/directory_based_runbooks/hello/steps/040-exercise-failure.md +26 -0
  14. data/examples/directory_based_runbooks/hello/steps/045-check-the-greeting.md +18 -0
  15. data/examples/directory_based_runbooks/hello/verify.md +18 -0
  16. data/examples/directory_based_runbooks/staging-teardown/rollback.md +28 -0
  17. data/examples/directory_based_runbooks/staging-teardown/runbook.md +57 -0
  18. data/examples/directory_based_runbooks/staging-teardown/steps/010-confirm-nothing-to-preserve.md +18 -0
  19. data/examples/directory_based_runbooks/staging-teardown/steps/020-snapshot-the-database.md +47 -0
  20. data/examples/directory_based_runbooks/staging-teardown/steps/030-stop-the-pipeline.md +32 -0
  21. data/examples/directory_based_runbooks/staging-teardown/steps/040-drain-the-ecs-services.md +35 -0
  22. data/examples/directory_based_runbooks/staging-teardown/steps/050-record-the-final-row-counts.md +41 -0
  23. data/examples/directory_based_runbooks/staging-teardown/steps/060-delete-the-stack.md +56 -0
  24. data/examples/directory_based_runbooks/staging-teardown/verify.md +40 -0
  25. data/examples/single_md_file_runbooks/db-maintenance.md +163 -0
  26. data/examples/single_md_file_runbooks/disk-space-triage.md +90 -0
  27. data/examples/single_md_file_runbooks/disk-usage-glossary.md +48 -0
  28. data/examples/single_md_file_runbooks/policies/cleanup-policy.md +33 -0
  29. data/examples/single_md_file_runbooks/policies/images/triage-flow.svg +44 -0
  30. data/examples/single_md_file_runbooks/runbooks_with_scripts/.loki +17 -0
  31. data/examples/single_md_file_runbooks/runbooks_with_scripts/asgard-tasks.md +135 -0
  32. data/examples/single_md_file_runbooks/runbooks_with_scripts/checks.loki +27 -0
  33. data/examples/single_md_file_runbooks/runbooks_with_scripts/greet.loki +6 -0
  34. data/lib/runsheets/assets.rb +780 -0
  35. data/lib/runsheets/block.rb +118 -0
  36. data/lib/runsheets/cli.rb +424 -0
  37. data/lib/runsheets/config/defaults.yml +44 -0
  38. data/lib/runsheets/config.rb +139 -0
  39. data/lib/runsheets/diff.rb +67 -0
  40. data/lib/runsheets/execution.rb +144 -0
  41. data/lib/runsheets/executor.rb +157 -0
  42. data/lib/runsheets/expectation.rb +101 -0
  43. data/lib/runsheets/fences.rb +172 -0
  44. data/lib/runsheets/front_matter.rb +29 -0
  45. data/lib/runsheets/library.rb +268 -0
  46. data/lib/runsheets/pages/chooser.rb +408 -0
  47. data/lib/runsheets/pages/search.rb +119 -0
  48. data/lib/runsheets/pages/tree.rb +88 -0
  49. data/lib/runsheets/pages.rb +947 -0
  50. data/lib/runsheets/redactor.rb +88 -0
  51. data/lib/runsheets/renderer.rb +229 -0
  52. data/lib/runsheets/run.rb +330 -0
  53. data/lib/runsheets/run_record.rb +371 -0
  54. data/lib/runsheets/runbook.rb +301 -0
  55. data/lib/runsheets/search.rb +129 -0
  56. data/lib/runsheets/session.rb +307 -0
  57. data/lib/runsheets/session_log.rb +114 -0
  58. data/lib/runsheets/single_file.rb +93 -0
  59. data/lib/runsheets/step.rb +91 -0
  60. data/lib/runsheets/version.rb +5 -0
  61. data/lib/runsheets/web.rb +537 -0
  62. data/lib/runsheets.rb +90 -0
  63. data/sig/runsheets.rbs +4 -0
  64. metadata +226 -0
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "myway_config"
5
+
6
+ module Runsheets
7
+ class ConfigError < Error; end
8
+
9
+ # Settings for the `runsheets` command, layered lowest to highest:
10
+ #
11
+ # 1. lib/runsheets/config/defaults.yml bundled with the gem (the schema; see that file)
12
+ # 2. the XDG user config, ~/.config/runsheets/runsheets.yml (myway_config)
13
+ # 3. the project config, ./config/runsheets.yml, or the file named by
14
+ # --config FILE or RUNSHEETS_CONFIG in its place
15
+ # 4. RUNSHEETS_<KEY> environment variables
16
+ # 5. the overrides hash, which the CLI fills from the command line
17
+ #
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
20
+ # expanded; flags accept 1/true/yes/on.
21
+ class Config < MywayConfig::Base
22
+ config_name :runsheets
23
+ env_prefix :runsheets
24
+ defaults_path File.expand_path("config/defaults.yml", __dir__)
25
+
26
+ TRUE_WORDS = %w[1 true yes on].freeze
27
+
28
+ class << self
29
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
30
+
31
+ # A coercion that hands the stripped text to the block, or gives nil for
32
+ # a blank so the bundled default fills it (see fill_blanks_from_defaults).
33
+ def unless_blank = ->(v) { blank?(v) ? nil : yield(v.to_s.strip) }
34
+
35
+ def whole_number(text)
36
+ port = Integer(text, 10)
37
+ raise ConfigError, "port must be between 1 and 65535, got #{port}" unless (1..65_535).cover?(port)
38
+
39
+ port
40
+ rescue ArgumentError
41
+ raise ConfigError, "port must be a whole number, got #{text.inspect}"
42
+ end
43
+
44
+ # The XDG user config: the file myway_config found, else where it would be.
45
+ def xdg_path
46
+ MywayConfig::Loaders::XdgConfigLoader.find_config_file(config_name) ||
47
+ File.join(Dir.home, ".config", config_name.to_s, "#{config_name}.yml")
48
+ end
49
+
50
+ # The project config read when neither --config nor RUNSHEETS_CONFIG names a file.
51
+ def project_path = File.expand_path(Anyway::Settings.default_config_path.call(config_name))
52
+
53
+ # The bundled defaults, coerced, keyed by attribute. What the help text shows.
54
+ def bundled_defaults = schema.to_h { |key, value| [key, type_caster.coerce(key, value)] }
55
+
56
+ # Any RACK_ENV is fine; the environment sections of defaults.yml are optional.
57
+ def validate_environment! = nil
58
+ end
59
+
60
+ # Coercions: the raw YAML/env/CLI value becomes the type the attribute needs.
61
+ TEXT = unless_blank(&:itself)
62
+ PATH = unless_blank { File.expand_path(it) }
63
+ PORT = unless_blank { whole_number(it) }
64
+ FLAG = ->(v) { v == true || TRUE_WORDS.include?(v.to_s.strip.downcase) }
65
+
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,
68
+ engineer: TEXT, why: TEXT, log_level: unless_blank { SessionLog.level_name(it) }, quiet: FLAG
69
+
70
+ on_load :fill_blanks_from_defaults
71
+
72
+ # The config file read in place of the project config, set while loading.
73
+ attr_reader :path
74
+
75
+ # overrides: a Hash of values that beat every other layer (the CLI's).
76
+ # path: the config file to read instead of RUNSHEETS_CONFIG / ./config/runsheets.yml.
77
+ def initialize(overrides = nil, path: nil)
78
+ @explicit_path = path
79
+ @override_keys = overrides.to_h.keys.map(&:to_s)
80
+ super(overrides&.transform_keys(&:to_s))
81
+ end
82
+
83
+ # The config files that exist and were read, lowest layer first.
84
+ def files = [self.class.xdg_path, path].select { File.file?(it) }
85
+
86
+ # The bind address in force when it came from the project config found
87
+ # in the current directory, else nil. That file arrives with whatever
88
+ # repository was cloned, so the CLI will not let it alone open the
89
+ # server to the network (see CLI.check_bind_source).
90
+ def project_bind
91
+ return nil if @explicit_path || !self.class.blank?(ENV["RUNSHEETS_CONFIG"])
92
+ return nil if @override_keys.include?("bind") || !self.class.blank?(ENV["RUNSHEETS_BIND"])
93
+ return nil unless File.file?(path.to_s)
94
+
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
99
+ end
100
+
101
+ # The settings in force as the flat hash a config file holds. dump is
102
+ # 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
104
+ # left out too: it belongs to one session, and a saved one would start
105
+ # 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)] }
107
+
108
+ # The settings in force as the text of a config file: a comment header,
109
+ # then one key per setting. `runsheets --dump` prints this.
110
+ def to_config_yaml
111
+ <<~YAML + settings.to_yaml.delete_prefix("---\n")
112
+ # runsheets settings, written by `runsheets --dump` on #{Time.now.strftime('%Y-%m-%d %H:%M')}.
113
+ # One key per setting; RUNSHEETS_* variables and command-line options
114
+ # override these. See `runsheets --help` for the layers.
115
+ YAML
116
+ end
117
+
118
+ private
119
+
120
+ # Where anyway_config's yml loader reads from. A file named explicitly
121
+ # (option or variable) must exist; the project config may be absent.
122
+ 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
129
+ end
130
+
131
+ # A blank in any layer (RUNSHEETS_PORT="" say) coerces to nil; put the
132
+ # bundled default back so an attribute is never accidentally unset.
133
+ def fill_blanks_from_defaults
134
+ self.class.bundled_defaults.each do |key, value|
135
+ public_send(:"#{key}=", value) if public_send(key).nil?
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # A small line diff, enough to show how a block's code changed between a
5
+ # recorded run and the runbook as it is now, or how a block's output
6
+ # differs from its expect block. Standard library only.
7
+ module Diff
8
+ Line = Data.define(:tag, :text) do
9
+ def unchanged? = tag == " "
10
+ def removed? = tag == "-"
11
+ def added? = tag == "+"
12
+ def to_s = "#{tag}#{text}"
13
+ end
14
+
15
+ SAME = ->(a, b) { a == b }
16
+
17
+ # Lines of +before+ and +after+ tagged " " (same), "-" (only in before)
18
+ # or "+" (only in after), in order. Longest-common-subsequence based.
19
+ def self.lines(before, after)
20
+ ops(before.to_s.lines(chomp: true), after.to_s.lines(chomp: true)).map do |tag, old, new|
21
+ Line.new(tag, tag == "+" ? new : old)
22
+ end
23
+ end
24
+
25
+ # The edit script between two arrays of lines, one [tag, before_line,
26
+ # after_line] per row: " " when +same+ says the pair is the same, "-"
27
+ # with a nil after_line, "+" with a nil before_line.
28
+ def self.ops(before, after, same: SAME)
29
+ table = lcs_table(before, after, same)
30
+ walk(before, after, table, same)
31
+ end
32
+
33
+ # Unified-style text, one tagged line per row.
34
+ def self.unified(before, after) = lines(before, after).join("\n")
35
+
36
+ def self.changed?(before, after) = before.to_s != after.to_s
37
+
38
+ def self.lcs_table(before, after, same)
39
+ table = Array.new(before.size + 1) { Array.new(after.size + 1, 0) }
40
+ before.each_index.reverse_each do |i|
41
+ after.each_index.reverse_each do |j|
42
+ table[i][j] = same.call(before[i], after[j]) ? table[i + 1][j + 1] + 1 : [table[i + 1][j], table[i][j + 1]].max
43
+ end
44
+ end
45
+ table
46
+ end
47
+ private_class_method :lcs_table
48
+
49
+ def self.walk(before, after, table, same)
50
+ out = []
51
+ i = j = 0
52
+ while i < before.size && j < after.size
53
+ tag, di, dj = same.call(before[i], after[j]) ? [" ", 1, 1] : choose(table, i, j)
54
+ out << [tag, (before[i] unless tag == "+"), (after[j] unless tag == "-")]
55
+ i += di
56
+ j += dj
57
+ end
58
+ out.concat(before[i..].map { ["-", it, nil] }).concat(after[j..].map { ["+", nil, it] })
59
+ end
60
+ private_class_method :walk
61
+
62
+ # Which row to emit at (i, j) when the lines there differ: [tag,
63
+ # advance before, advance after].
64
+ def self.choose(table, i, j) = table[i + 1][j] >= table[i][j + 1] ? ["-", 1, 0] : ["+", 0, 1]
65
+ private_class_method :choose
66
+ end
67
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # One execution of a block: what ran, where its output went, how it ended.
5
+ # Executor drives the state transitions; everything else only reads.
6
+ class Execution
7
+ STATES = %i[pending running finished timed_out stopped failed].freeze
8
+
9
+ # tee is where the output also goes as it arrives: the session log's
10
+ # stream for this execution (anything with #write and #close), or nil.
11
+ attr_reader :id, :block_id, :step_slug, :command, :cmd_path, :log_path, :timeout, :tee,
12
+ :state, :pid, :started_at, :finished_at, :exit_status, :signal, :error
13
+
14
+ def initialize(id:, block_id:, step_slug:, command:, cmd_path:, log_path:, timeout: nil, background: false, tee: nil)
15
+ @id = id
16
+ @block_id = block_id
17
+ @step_slug = step_slug
18
+ @command = Array(command).map(&:to_s)
19
+ @cmd_path = cmd_path
20
+ @log_path = log_path
21
+ @timeout = timeout
22
+ @background = background
23
+ @tee = tee
24
+ @state = :pending
25
+ @stop = false
26
+ @mutex = Mutex.new
27
+ @thread = nil
28
+ @on_end = []
29
+ end
30
+
31
+ def background? = @background
32
+ def pending? = state == :pending
33
+ def running? = state == :running
34
+ def finished? = %i[finished timed_out stopped failed].include?(state)
35
+ def timed_out? = state == :timed_out
36
+ def stopped? = state == :stopped
37
+ def success? = state == :finished && exit_status == 0
38
+ def failure? = finished? && !success? && !stopped?
39
+
40
+ def stop_requested? = @stop
41
+
42
+ # Call +block+ with this execution once it has ended, however it ended.
43
+ # Listeners run on the executor's reaper thread.
44
+ def on_end(&block)
45
+ @on_end << block
46
+ self
47
+ end
48
+
49
+ def duration = started_at && finished_at ? finished_at - started_at : nil
50
+
51
+ # Block until the process has been reaped (or +limit+ seconds pass).
52
+ def wait(limit = nil)
53
+ @thread&.join(limit)
54
+ self
55
+ rescue StandardError # the reaper's own failure is already recorded as the state
56
+ self
57
+ end
58
+
59
+ # Captured stdout+stderr. With +tail+, only the last that many bytes.
60
+ def output(tail: nil)
61
+ return "" unless log_path && File.file?(log_path)
62
+
63
+ size = File.size(log_path)
64
+ text = if tail && size > tail
65
+ File.binread(log_path, tail, size - tail)
66
+ else
67
+ File.binread(log_path)
68
+ end
69
+ text.force_encoding("UTF-8").scrub
70
+ end
71
+
72
+ def output_size = (log_path && File.size?(log_path)) || 0
73
+
74
+ def to_h
75
+ {
76
+ id:, block_id:, step: step_slug, command:, background: background?, state: state.to_s, pid:,
77
+ started_at: started_at&.iso8601(3), finished_at: finished_at&.iso8601(3),
78
+ duration: duration&.round(3), exit_status:, signal:, error:,
79
+ cmd: cmd_path && File.basename(cmd_path), log: log_path && File.basename(log_path)
80
+ }
81
+ end
82
+
83
+ # --- transitions, called by Executor -----------------------------------
84
+
85
+ def started!(pid, at:)
86
+ @mutex.synchronize do
87
+ @pid = pid
88
+ @started_at = at
89
+ @state = :running
90
+ end
91
+ self
92
+ end
93
+
94
+ def attach(thread)
95
+ @thread = thread
96
+ self
97
+ end
98
+
99
+ # Ask the executor's reaper to end the process group. Returns true if
100
+ # there was something to stop.
101
+ def request_stop!
102
+ @mutex.synchronize do
103
+ return false unless running?
104
+
105
+ @stop = true
106
+ end
107
+ end
108
+
109
+ def finished!(status, at:, timed_out: false, stopped: false)
110
+ @mutex.synchronize do
111
+ @finished_at = at
112
+ @exit_status = status&.exitstatus || (status&.termsig && (128 + status.termsig))
113
+ @signal = status&.termsig
114
+ @state = if timed_out then :timed_out
115
+ elsif stopped then :stopped
116
+ else :finished
117
+ end
118
+ end
119
+ ended!
120
+ end
121
+
122
+ def failed!(message, at:)
123
+ @mutex.synchronize do
124
+ @finished_at = at
125
+ @error = message
126
+ @state = :failed
127
+ end
128
+ ended!
129
+ end
130
+
131
+ private
132
+
133
+ # Each listener runs whatever the others do; one that raises is
134
+ # reported on stderr and skipped.
135
+ def ended!
136
+ @on_end.each do |listener|
137
+ listener.call(self)
138
+ rescue StandardError => e
139
+ warn "runsheets: execution #{id}: #{e.class}: #{e.message}"
140
+ end
141
+ self
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # Spawns block code as a child process group, pumps its stdout and stderr
5
+ # through the run's Redactor into the execution's log file, and reaps it
6
+ # in a background thread.
7
+ #
8
+ # Every execution is a fresh process: nothing carries over between blocks
9
+ # except the environment the session passes in.
10
+ class Executor
11
+ GRACE = 2.0 # seconds between TERM and KILL on timeout or stop
12
+ DRAIN = 1.0 # seconds to wait for the output pump after the process exits
13
+ POLL = 0.05
14
+ CHUNK = 64 * 1024
15
+
16
+ attr_reader :clock
17
+
18
+ def initialize(clock: Time)
19
+ @clock = clock
20
+ end
21
+
22
+ # Write +code+ to the execution's cmd file and start it. Returns the
23
+ # execution, now running (or failed if it could not start). The
24
+ # execution's tee, when it has one, also receives the redacted output as
25
+ # it arrives and is closed at the end of the stream.
26
+ def start(execution, code:, env: {}, cwd: nil, redactor: nil)
27
+ File.write(execution.cmd_path, code)
28
+ log = File.open(execution.log_path, "wb") # rubocop:disable Style/FileOpen -- closed by the pump thread
29
+ reader, writer = IO.pipe
30
+ pid = Process.spawn(
31
+ env.transform_keys(&:to_s).transform_values(&:to_s),
32
+ *execution.command, execution.cmd_path,
33
+ chdir: cwd || Dir.pwd, pgroup: true,
34
+ in: File::NULL, out: writer, err: writer
35
+ )
36
+ writer.close
37
+ pump = Thread.new { pump(reader, log, redactor || Redactor.new({}), execution.tee) }
38
+ execution.started!(pid, at: clock.now)
39
+ execution.attach(Thread.new { reap(execution, pid, pump) })
40
+ rescue SystemCallError, ArgumentError => e # ArgumentError: a NUL in the environment or command
41
+ writer&.close
42
+ reader&.close
43
+ log&.close
44
+ execution.tee&.close
45
+ execution.failed!("#{e.class}: #{e.message}", at: clock.now)
46
+ end
47
+
48
+ # Start and wait. Handy for scripts and tests.
49
+ def run(execution, **) = start(execution, **).wait
50
+
51
+ # Ask a running execution to end: TERM to its process group, then KILL.
52
+ # The reaper does the signalling; the execution ends up :stopped.
53
+ def stop(execution)
54
+ execution.request_stop!
55
+ execution
56
+ end
57
+
58
+ # Copy the child's output into the log, redacting as it goes. Runs until
59
+ # every holder of the pipe's write end has closed it.
60
+ def pump(io, log, redactor, tee = nil)
61
+ while (chunk = io.readpartial(CHUNK))
62
+ safe = redactor.feed(chunk)
63
+ log.write(safe)
64
+ log.flush
65
+ tee&.write(safe)
66
+ end
67
+ rescue IOError
68
+ nil
69
+ ensure
70
+ rest = redactor.flush
71
+ log.write(rest)
72
+ log.close
73
+ io.close
74
+ tee&.write(rest)
75
+ tee&.close
76
+ end
77
+
78
+ private
79
+
80
+ def reap(execution, pid, pump)
81
+ status, how = outcome(execution, pid)
82
+ rescue SystemCallError => e
83
+ execution.failed!("#{e.class}: #{e.message}", at: clock.now)
84
+ else
85
+ sweep_group(pid)
86
+ finish(execution, pump, status, **how)
87
+ end
88
+
89
+ # Wait for the process to end on its own, be stopped, or time out.
90
+ # Returns [status, how it ended].
91
+ def outcome(execution, pid)
92
+ deadline = execution.timeout && (monotonic + execution.timeout)
93
+ loop do
94
+ _, status = Process.wait2(pid, Process::WNOHANG)
95
+ return [status, {}] if status
96
+ return [end_group(pid), { stopped: true }] if execution.stop_requested?
97
+ return [end_group(pid), { timed_out: true }] if deadline && monotonic > deadline
98
+
99
+ sleep POLL
100
+ end
101
+ end
102
+
103
+ # Let the pump drain, then record how the process ended.
104
+ def finish(execution, pump, status, **how)
105
+ pump.join(DRAIN)
106
+ execution.finished!(status, at: clock.now, **how)
107
+ end
108
+
109
+ # Kill the group and return the child's status however it was reaped.
110
+ def end_group(pid) = kill_group(pid) || Process.wait2(pid).last
111
+
112
+ # TERM the whole process group, then KILL whatever is still there.
113
+ # Returns the child's status if it was reaped here, else nil.
114
+ def kill_group(pid)
115
+ signal(pid, "TERM")
116
+ stop = monotonic + GRACE
117
+ while monotonic < stop
118
+ reaped = Process.wait2(pid, Process::WNOHANG)
119
+ return reaped.last if reaped
120
+
121
+ sleep POLL
122
+ end
123
+ signal(pid, "KILL")
124
+ nil
125
+ end
126
+
127
+ # The block's own process has been reaped; end anything it left behind
128
+ # in its group (`cmd &`, `nohup cmd &`) the same way: TERM, then KILL
129
+ # after GRACE. A block that started nothing returns at once.
130
+ def sweep_group(pid)
131
+ %w[TERM KILL].each do |name|
132
+ break unless group_alive?(pid)
133
+
134
+ signal(pid, name)
135
+ stop = monotonic + GRACE
136
+ sleep POLL while group_alive?(pid) && monotonic < stop
137
+ end
138
+ end
139
+
140
+ def group_alive?(pid)
141
+ Process.kill(0, -pid)
142
+ true
143
+ rescue Errno::ESRCH
144
+ false
145
+ rescue Errno::EPERM
146
+ true
147
+ end
148
+
149
+ def signal(pid, name)
150
+ Process.kill(name, -pid)
151
+ rescue Errno::ESRCH, Errno::EPERM
152
+ nil
153
+ end
154
+
155
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
+ end
157
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Runsheets
4
+ # Compares a block's output with the text of its expect block. Lines are
5
+ # matched in order with trailing whitespace ignored, and `{{any}}` in the
6
+ # expected text matches any characters within one line. The result lines
7
+ # the two up row by row for the page, with the characters that differ
8
+ # marked, so line N of the output sits beside line N of what was expected.
9
+ module Expectation
10
+ ANY = "{{any}}"
11
+ DIFF = "diff"
12
+ WILD = "any"
13
+
14
+ # One row of the side-by-side view. +tag+ is :same, :changed, :expected
15
+ # (a line only the expect block has) or :actual (only the output has).
16
+ # Each side is a list of [text, mark] segments, mark nil, "diff" or
17
+ # "any"; a side is nil when the row has no line there.
18
+ Row = Data.define(:tag, :expected, :actual) do
19
+ def to_h = { tag: tag.to_s, expected:, actual: }
20
+ end
21
+
22
+ Result = Data.define(:rows) do
23
+ def match? = rows.all? { it.tag == :same }
24
+ def to_h = { match: match?, rows: rows.map(&:to_h) }
25
+ end
26
+
27
+ def self.compare(expected, actual)
28
+ patterns = Hash.new { |cache, line| cache[line] = pattern(line) }
29
+ Result.new(rows(ops(lines(expected), lines(actual), ->(want, got) { patterns[want].match?(got) })))
30
+ end
31
+
32
+ # The edit script, with the lines that match at the start and at the
33
+ # end taken out before the diff, whose cost grows with the product of
34
+ # the two line counts. Output that mostly matches leaves it little to do.
35
+ def self.ops(wants, gots, same)
36
+ head = matching_run(wants, gots, same)
37
+ tail = matching_run(wants.drop(head).reverse, gots.drop(head).reverse, same)
38
+ middle = Diff.ops(wants[head...(wants.size - tail)], gots[head...(gots.size - tail)], same:)
39
+ paired(wants.first(head), gots.first(head)) + middle + paired(wants.last(tail), gots.last(tail))
40
+ end
41
+
42
+ # How many lines from the start match, pair by pair.
43
+ def self.matching_run(wants, gots, same) = wants.zip(gots).take_while { |want, got| got && same.call(want, got) }.size
44
+
45
+ def self.paired(wants, gots) = wants.zip(gots).map { |want, got| [" ", want, got] }
46
+
47
+ # The comparison as a page wants it, or nil when there is nothing to
48
+ # compare: no expect block, or an execution still running or that never
49
+ # started.
50
+ def self.for_page(expected, output, state)
51
+ return nil if expected.nil? || %w[running failed].include?(state.to_s)
52
+
53
+ compare(expected, output).to_h
54
+ end
55
+
56
+ def self.lines(text) = text.to_s.gsub(/[ \t]+$/, "").rstrip.lines(chomp: true)
57
+
58
+ # A line of expected text as a whole-line pattern.
59
+ def self.pattern(line) = /\A#{line.split(ANY, -1).map { Regexp.escape(it) }.join('.*')}\z/
60
+
61
+ # Rows from the edit script: unchanged lines as they are, and each run of
62
+ # changed lines paired off expected against actual.
63
+ def self.rows(ops)
64
+ ops.chunk_while { |a, b| (a.first == " ") == (b.first == " ") }.flat_map do |chunk|
65
+ chunk.first.first == " " ? same_rows(chunk) : changed_rows(chunk.filter_map { it[1] }, chunk.filter_map { it[2] })
66
+ end
67
+ end
68
+
69
+ def self.same_rows(chunk) = chunk.map { |_, want, got| Row.new(:same, wildcards(want), plain(got)) }
70
+
71
+ def self.changed_rows(wants, gots)
72
+ Array.new([wants.size, gots.size].max) { row(wants[it], gots[it]) }
73
+ end
74
+
75
+ def self.row(want, got)
76
+ return Row.new(:expected, wildcards(want, DIFF), nil) if got.nil?
77
+ return Row.new(:actual, nil, plain(got, DIFF)) if want.nil?
78
+ return Row.new(:changed, wildcards(want, DIFF), plain(got, DIFF)) if want.include?(ANY)
79
+
80
+ prefix = common_prefix(want, got)
81
+ suffix = common_prefix(want[prefix..].reverse, got[prefix..].reverse)
82
+ Row.new(:changed, around(want, prefix, suffix), around(got, prefix, suffix))
83
+ end
84
+
85
+ # +text+ in segments, with each {{any}} marked as a wildcard.
86
+ def self.wildcards(text, mark = nil)
87
+ text.split(/(#{Regexp.escape(ANY)})/).reject(&:empty?).map { [it, it == ANY ? WILD : mark] }
88
+ end
89
+
90
+ def self.plain(text, mark = nil) = text.empty? ? [] : [[text, mark]]
91
+
92
+ # +text+ with the part between its first +prefix+ and last +suffix+
93
+ # characters marked as the difference.
94
+ def self.around(text, prefix, suffix)
95
+ [[text[0, prefix], nil], [text[prefix...(text.size - suffix)], DIFF], [text[(text.size - suffix)..], nil]]
96
+ .reject { it.first.empty? }
97
+ end
98
+
99
+ def self.common_prefix(one, other) = one.each_char.zip(other.each_char).take_while { |mine, theirs| mine == theirs }.size
100
+ end
101
+ end