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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +98 -0
- data/LICENSE.txt +21 -0
- data/README.md +277 -0
- data/bin/runsheets +12 -0
- data/examples/about-the-examples.md +40 -0
- data/examples/directory_based_runbooks/hello/rollback.md +11 -0
- data/examples/directory_based_runbooks/hello/runbook.md +40 -0
- data/examples/directory_based_runbooks/hello/steps/010-say-hello.md +33 -0
- data/examples/directory_based_runbooks/hello/steps/020-inspect-ruby.md +28 -0
- data/examples/directory_based_runbooks/hello/steps/030-take-a-breath.md +11 -0
- data/examples/directory_based_runbooks/hello/steps/035-keep-a-clock-running.md +21 -0
- data/examples/directory_based_runbooks/hello/steps/040-exercise-failure.md +26 -0
- data/examples/directory_based_runbooks/hello/steps/045-check-the-greeting.md +18 -0
- data/examples/directory_based_runbooks/hello/verify.md +18 -0
- data/examples/directory_based_runbooks/staging-teardown/rollback.md +28 -0
- data/examples/directory_based_runbooks/staging-teardown/runbook.md +57 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/010-confirm-nothing-to-preserve.md +18 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/020-snapshot-the-database.md +47 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/030-stop-the-pipeline.md +32 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/040-drain-the-ecs-services.md +35 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/050-record-the-final-row-counts.md +41 -0
- data/examples/directory_based_runbooks/staging-teardown/steps/060-delete-the-stack.md +56 -0
- data/examples/directory_based_runbooks/staging-teardown/verify.md +40 -0
- data/examples/single_md_file_runbooks/db-maintenance.md +163 -0
- data/examples/single_md_file_runbooks/disk-space-triage.md +90 -0
- data/examples/single_md_file_runbooks/disk-usage-glossary.md +48 -0
- data/examples/single_md_file_runbooks/policies/cleanup-policy.md +33 -0
- data/examples/single_md_file_runbooks/policies/images/triage-flow.svg +44 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/.loki +17 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/asgard-tasks.md +135 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/checks.loki +27 -0
- data/examples/single_md_file_runbooks/runbooks_with_scripts/greet.loki +6 -0
- data/lib/runsheets/assets.rb +780 -0
- data/lib/runsheets/block.rb +118 -0
- data/lib/runsheets/cli.rb +424 -0
- data/lib/runsheets/config/defaults.yml +44 -0
- data/lib/runsheets/config.rb +139 -0
- data/lib/runsheets/diff.rb +67 -0
- data/lib/runsheets/execution.rb +144 -0
- data/lib/runsheets/executor.rb +157 -0
- data/lib/runsheets/expectation.rb +101 -0
- data/lib/runsheets/fences.rb +172 -0
- data/lib/runsheets/front_matter.rb +29 -0
- data/lib/runsheets/library.rb +268 -0
- data/lib/runsheets/pages/chooser.rb +408 -0
- data/lib/runsheets/pages/search.rb +119 -0
- data/lib/runsheets/pages/tree.rb +88 -0
- data/lib/runsheets/pages.rb +947 -0
- data/lib/runsheets/redactor.rb +88 -0
- data/lib/runsheets/renderer.rb +229 -0
- data/lib/runsheets/run.rb +330 -0
- data/lib/runsheets/run_record.rb +371 -0
- data/lib/runsheets/runbook.rb +301 -0
- data/lib/runsheets/search.rb +129 -0
- data/lib/runsheets/session.rb +307 -0
- data/lib/runsheets/session_log.rb +114 -0
- data/lib/runsheets/single_file.rb +93 -0
- data/lib/runsheets/step.rb +91 -0
- data/lib/runsheets/version.rb +5 -0
- data/lib/runsheets/web.rb +537 -0
- data/lib/runsheets.rb +90 -0
- data/sig/runsheets.rbs +4 -0
- 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
|