asgard 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -99
- data/README.md +141 -46
- data/bin/asgard +10 -0
- data/lib/asgard/base/dispatch.rb +257 -31
- data/lib/asgard/base/registry.rb +7 -3
- data/lib/asgard/base.rb +140 -9
- data/lib/asgard/declarations.rb +21 -0
- data/lib/asgard/doctor/task_sections.rb +11 -3
- data/lib/asgard/doctor.rb +12 -8
- data/lib/asgard/jobs/commands.rb +152 -0
- data/lib/asgard/jobs/declaration.rb +149 -0
- data/lib/asgard/jobs/launcher.rb +109 -0
- data/lib/asgard/jobs/state.rb +190 -0
- data/lib/asgard/jobs/stopper.rb +141 -0
- data/lib/asgard/jobs.rb +25 -0
- data/lib/asgard/kernel_methods.rb +19 -14
- data/lib/asgard/leading_flag.rb +10 -0
- data/lib/asgard/pid.rb +130 -0
- data/lib/asgard/schedule/commands.rb +39 -11
- data/lib/asgard/schedule/declaration.rb +139 -12
- data/lib/asgard/schedule/job_files.rb +24 -0
- data/lib/asgard/schedule/launchd.rb +55 -18
- data/lib/asgard/schedule/systemd.rb +69 -27
- data/lib/asgard/schedule/table.rb +34 -16
- data/lib/asgard/schedule.rb +49 -9
- data/lib/asgard/shell.rb +372 -44
- data/lib/asgard/state_dir.rb +84 -0
- data/lib/asgard/tasks.rb +8 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +26 -1
- data/lib/asgard/watch/commands.rb +52 -12
- data/lib/asgard/watch/daemon.rb +334 -43
- data/lib/asgard/watch/declaration.rb +135 -40
- data/lib/asgard/watch/dispatcher.rb +61 -10
- data/lib/asgard/watch/mailbox.rb +50 -0
- data/lib/asgard/watch/reporter.rb +50 -34
- data/lib/asgard/watch/spawn.rb +41 -16
- data/lib/asgard/watch/state.rb +93 -27
- data/lib/asgard/watch/worker.rb +44 -9
- data/lib/asgard/watch.rb +4 -11
- data/lib/asgard.rb +80 -14
- metadata +167 -186
- data/.envrc +0 -6
- data/.github/workflows/deploy-github-pages.yml +0 -52
- data/.loki +0 -39
- data/.reek.yml +0 -166
- data/.rubocop.yml +0 -163
- data/Archspec.rb +0 -24
- data/CLAUDE.md +0 -139
- data/COMMITS.md +0 -196
- data/bin/console +0 -11
- data/bin/setup +0 -8
- data/doc_tasks.loki +0 -21
- data/docs/api.md +0 -368
- data/docs/assets/css/custom.css +0 -93
- data/docs/assets/images/asgard.jpg +0 -0
- data/docs/changelog.md +0 -1
- data/docs/dependencies.md +0 -313
- data/docs/environment.md +0 -129
- data/docs/examples.md +0 -194
- data/docs/getting-started.md +0 -181
- data/docs/helpers.md +0 -262
- data/docs/index.md +0 -87
- data/docs/options.md +0 -224
- data/docs/schedule.md +0 -106
- data/docs/shell.md +0 -237
- data/docs/subcommands.md +0 -181
- data/docs/task-files.md +0 -407
- data/docs/tasks.md +0 -326
- data/docs/variables.md +0 -338
- data/docs/watch.md +0 -123
- data/examples/.env +0 -4
- data/examples/.envrc +0 -3
- data/examples/.loki +0 -24
- data/examples/bad.loki +0 -63
- data/examples/concurrent.loki +0 -58
- data/examples/confirm.loki +0 -48
- data/examples/db_subcommands.loki +0 -74
- data/examples/depends_on_block/bad/.loki +0 -51
- data/examples/depends_on_block/good/.loki +0 -83
- data/examples/do.loki +0 -45
- data/examples/env_usage.loki +0 -27
- data/examples/kitchen_sink.loki +0 -210
- data/examples/server_subcommands.loki +0 -65
- data/examples/subdir/.loki +0 -12
- data/examples/subdir/import_demo.loki +0 -14
- data/examples/subdir/import_up_demo.loki +0 -18
- data/examples/watcher_examples/.envrc +0 -3
- data/examples/watcher_examples/.loki +0 -54
- data/examples/watcher_examples/README.md +0 -112
- data/examples/watcher_examples/bin/hello +0 -3
- data/examples/watcher_examples/data/records.json +0 -1
- data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
- data/examples/watcher_examples/docs/index.md +0 -4
- data/examples/watcher_examples/lib/greeter.rb +0 -7
- data/gem_tasks.loki +0 -51
- data/git.loki +0 -13
- data/mkdocs.yml +0 -166
- data/quality.loki +0 -266
- data/quality_rails.loki +0 -47
- data/xyzzy.loki +0 -12
|
@@ -8,10 +8,11 @@ module Asgard
|
|
|
8
8
|
# give up width, so the short columns always stay whole.
|
|
9
9
|
class Table
|
|
10
10
|
HEADERS = ["NAME", "COMMAND", "SCHEDULE", "STATE", "LAST EXIT"].freeze
|
|
11
|
+
KEYS = %i[name command schedule state last_exit].freeze
|
|
11
12
|
FLEXIBLE = [1, 2].freeze
|
|
12
13
|
MIN_WIDTH = 14
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
PADDING = 1 # spaces either side of a cell
|
|
15
|
+
EXTRA = PADDING * 2 # what a column adds to its content
|
|
15
16
|
|
|
16
17
|
# Columns available for output: the terminal's when io is one, else
|
|
17
18
|
# $COLUMNS, else a roomy default (piped output shouldn't wrap at 80).
|
|
@@ -23,18 +24,22 @@ module Asgard
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# rows rendered for the terminal, under title when given: colored when io
|
|
26
|
-
# is a tty and NO_COLOR is unset. headers: relabels the
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# is a tty and NO_COLOR is unset. headers: relabels the columns; keys:
|
|
28
|
+
# picks them from each row, and flexible: says which give up width
|
|
29
|
+
# first when the table is wider than the screen.
|
|
30
|
+
def self.draw(rows, title: nil, io: $stdout, headers: HEADERS, keys: KEYS, flexible: FLEXIBLE)
|
|
31
|
+
table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io), headers:, keys:, flexible:).render
|
|
29
32
|
[title, table].compact.join("\n")
|
|
30
33
|
end
|
|
31
34
|
|
|
32
35
|
# color: styles the header, state and last exit with ANSI codes.
|
|
33
|
-
def initialize(rows, color:, width:, headers: HEADERS)
|
|
36
|
+
def initialize(rows, color:, width:, headers: HEADERS, keys: KEYS, flexible: FLEXIBLE)
|
|
34
37
|
require "pastel"
|
|
35
38
|
|
|
36
|
-
@headers
|
|
37
|
-
@
|
|
39
|
+
@headers = headers
|
|
40
|
+
@flexible = flexible
|
|
41
|
+
@styled = { state: keys.index(:state), last_exit: keys.index(:last_exit) }.compact
|
|
42
|
+
@cells = rows.map { |row| row.values_at(*keys).map(&:to_s) }
|
|
38
43
|
@pastel = Pastel.new(enabled: color)
|
|
39
44
|
@width = width
|
|
40
45
|
@column_widths = natural_widths
|
|
@@ -45,7 +50,12 @@ module Asgard
|
|
|
45
50
|
require "tty-table"
|
|
46
51
|
|
|
47
52
|
grid = TTY::Table.new(header: @headers.map { |text| @pastel.bold(text) }, rows: @cells.map { |cells| styled(cells) })
|
|
48
|
-
|
|
53
|
+
# tty-table gets the content widths and no resize: with resize it
|
|
54
|
+
# re-spreads the columns by its own arithmetic (and squeezes a
|
|
55
|
+
# one-row table), while this way the widths computed here are the
|
|
56
|
+
# widths it draws, wrapping any cell that is longer.
|
|
57
|
+
grid.render(:unicode, multiline: true, resize: false, width: total_width, column_widths: content_widths,
|
|
58
|
+
padding: [0, PADDING]).to_s
|
|
49
59
|
end
|
|
50
60
|
|
|
51
61
|
# Width of each column, padding included. Every column starts at its
|
|
@@ -53,27 +63,35 @@ module Asgard
|
|
|
53
63
|
# flexible columns give up a character at a time until it fits.
|
|
54
64
|
attr_reader :column_widths
|
|
55
65
|
|
|
56
|
-
# Rendered width: the columns plus their borders
|
|
57
|
-
|
|
66
|
+
# Rendered width: the columns plus their borders, one before each
|
|
67
|
+
# column and one after the last.
|
|
68
|
+
def total_width = column_widths.sum + @headers.size + 1
|
|
58
69
|
|
|
59
70
|
private
|
|
60
71
|
|
|
72
|
+
# The widths without their padding, as tty-table counts them.
|
|
73
|
+
def content_widths = column_widths.map { |width| width - EXTRA }
|
|
74
|
+
|
|
61
75
|
def natural_widths = [@headers, *@cells].transpose.map { |column| column.max_by(&:length).length + EXTRA }
|
|
62
76
|
|
|
63
77
|
def overflowing? = total_width > @width
|
|
64
78
|
|
|
65
|
-
def widest_flexible =
|
|
79
|
+
def widest_flexible = @flexible.max_by { |index| @column_widths[index] }
|
|
66
80
|
|
|
67
81
|
def shrinkable? = @column_widths[widest_flexible] > MIN_WIDTH
|
|
68
82
|
|
|
69
83
|
def shrink_widest = @column_widths[widest_flexible] -= 1
|
|
70
84
|
|
|
85
|
+
# The state and last-exit cells colored, wherever keys: put them.
|
|
71
86
|
def styled(cells)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
[*lead, @pastel.decorate(state, state_color), @pastel.decorate(last_exit, exit_color)]
|
|
87
|
+
cells = cells.dup
|
|
88
|
+
@styled.each { |column, index| cells[index] = @pastel.decorate(cells[index], send(:"#{column}_color", cells[index])) }
|
|
89
|
+
cells
|
|
76
90
|
end
|
|
91
|
+
|
|
92
|
+
def state_color(state) = { "active" => :green, "running" => :green, "stopped" => :yellow }.fetch(state.split.first, :red)
|
|
93
|
+
|
|
94
|
+
def last_exit_color(last_exit) = last_exit.match?(/\A(?:[1-9]\d*\b|signal )/) ? :red : :dim
|
|
77
95
|
end
|
|
78
96
|
end
|
|
79
97
|
end
|
data/lib/asgard/schedule.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "declarations"
|
|
3
4
|
require_relative "schedule/declaration"
|
|
4
5
|
require_relative "schedule/launchd"
|
|
5
6
|
require_relative "schedule/systemd"
|
|
@@ -24,16 +25,55 @@ module Asgard
|
|
|
24
25
|
@declarations ||= {}
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
# Records one declaration
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
def declare(task, **
|
|
31
|
-
spec = normalize(task, **settings)
|
|
32
|
-
name = spec[:name]
|
|
33
|
-
taken = declarations[name]
|
|
34
|
-
raise ArgumentError, "schedule: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
|
|
28
|
+
# Records one declaration (see Declarations.record). Two different
|
|
29
|
+
# commands whose names derive alike both take hashed names first
|
|
30
|
+
# (see rival_commands?).
|
|
31
|
+
def declare(task, **) = Declarations.record(declarations, place(normalize(task, **)), "schedule")
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
# The name a command derives (a normalize result's task and args).
|
|
34
|
+
def plain_name(spec) = command_slug(spec[:task], spec[:args])
|
|
35
|
+
|
|
36
|
+
# Whether +spec+ has the plain name its command derives, rather than
|
|
37
|
+
# an as: name or a hashed one.
|
|
38
|
+
def derived_name?(spec) = !spec[:args].empty? && spec[:name] == plain_name(spec)
|
|
39
|
+
|
|
40
|
+
# Two declarations of different commands that derive the same plain
|
|
41
|
+
# name: neither may keep it.
|
|
42
|
+
def rival_commands?(one, other)
|
|
43
|
+
derived_name?(one) && derived_name?(other) && one.values_at(:task, :args) != other.values_at(:task, :args)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def with_hashed_name(spec) = spec.merge(name: hashed_slug(spec[:task], spec[:args]))
|
|
47
|
+
|
|
48
|
+
# Whether +spec+'s plain name was already given up by a rival in
|
|
49
|
+
# +declarations+, one now under its hashed name, so +spec+ takes its
|
|
50
|
+
# hashed name too. Whatever order the .loki declares them in, every
|
|
51
|
+
# rival ends up hashed.
|
|
52
|
+
def contested?(spec, declarations)
|
|
53
|
+
derived_name?(spec) && declarations.each_value.any? do |other|
|
|
54
|
+
other == with_hashed_name(other) && rival_commands?(other.merge(name: plain_name(other)), spec)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# +spec+ under the name it is declared by: hashed when a rival gave
|
|
61
|
+
# up its plain name earlier, or meets it now (the rival then moves to
|
|
62
|
+
# its hashed name too).
|
|
63
|
+
def place(spec)
|
|
64
|
+
return with_hashed_name(spec) if contested?(spec, declarations)
|
|
65
|
+
|
|
66
|
+
taken = declarations[spec[:name]]
|
|
67
|
+
return spec unless taken && rival_commands?(taken, spec)
|
|
68
|
+
|
|
69
|
+
rename_to_hashed(taken)
|
|
70
|
+
with_hashed_name(spec)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def rename_to_hashed(spec)
|
|
74
|
+
declarations.delete(spec[:name])
|
|
75
|
+
renamed = with_hashed_name(spec)
|
|
76
|
+
declarations[renamed[:name]] = renamed
|
|
37
77
|
end
|
|
38
78
|
end
|
|
39
79
|
|
data/lib/asgard/shell.rb
CHANGED
|
@@ -1,59 +1,387 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "English"
|
|
4
|
+
require "shellwords"
|
|
5
|
+
require_relative "pid"
|
|
4
6
|
require "tempfile"
|
|
7
|
+
require_relative "schedule/declaration"
|
|
5
8
|
|
|
6
9
|
module Asgard
|
|
10
|
+
# `shebang` is the one way a task runs something that isn't Ruby. The script
|
|
11
|
+
# is written to a tempfile and the file is handed to an interpreter from the
|
|
12
|
+
# `shells` registry, so one line and fifty lines take the same path:
|
|
13
|
+
#
|
|
14
|
+
# shebang :bash, "rake build"
|
|
15
|
+
# shebang :python3, <<~PY
|
|
16
|
+
# print("hi")
|
|
17
|
+
# PY
|
|
18
|
+
#
|
|
19
|
+
# The name may be left out, and `sh script` is the same call: both run the
|
|
20
|
+
# `default_shell`, which starts as :bash. The registry maps a name to the
|
|
21
|
+
# executable, the arguments placed before the script, and the tempfile
|
|
22
|
+
# extension:
|
|
23
|
+
#
|
|
24
|
+
# Tasks.shells[:bash] # => { path: "bash", args: [], ext: ".sh" }
|
|
25
|
+
#
|
|
26
|
+
# `shell` at class level adds or replaces an entry. A name that isn't
|
|
27
|
+
# registered runs as itself, found on PATH, with a `.tmp` extension.
|
|
7
28
|
module Shell
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
|
|
29
|
+
# The extension a declaration gets when `ext:` is omitted, keyed by the
|
|
30
|
+
# basename of its executable. Node needs `.js` to load ES modules.
|
|
31
|
+
DEFAULT_EXTENSIONS = {
|
|
32
|
+
"bash" => ".sh", "sh" => ".sh", "zsh" => ".sh", "fish" => ".fish", "csh" => ".csh", "tcsh" => ".csh",
|
|
33
|
+
"python3" => ".py", "python" => ".py",
|
|
34
|
+
"node" => ".js", "ruby" => ".rb", "perl" => ".pl"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
SEEDED_SHELLS = %w[bash sh python3 python node ruby perl].freeze
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
# The registry: name => { path:, args:, ext: }. One table for every
|
|
41
|
+
# Tasks subclass, seeded with the interpreters above.
|
|
42
|
+
def shells
|
|
43
|
+
@shells ||= SEEDED_SHELLS.to_h { |name| [name.to_sym, entry(name)] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The shell `sh` and a nameless `shebang` run; :bash until a .loki says
|
|
47
|
+
# otherwise.
|
|
48
|
+
def default_shell(name = nil)
|
|
49
|
+
@default_shell = name.to_sym if name
|
|
50
|
+
@default_shell ||= :bash
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Restore the seeded registry and default (tests).
|
|
54
|
+
def reset_shells!
|
|
55
|
+
@shells = nil
|
|
56
|
+
@default_shell = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build a normalized entry. A String +path+ is split with Shellwords:
|
|
60
|
+
# the first word is the executable, the rest are arguments and come
|
|
61
|
+
# before +args+, which may be a String (also Shellwords-split) or an
|
|
62
|
+
# Array. A Pathname +path+ is the executable as is, spaces and all.
|
|
63
|
+
# +ext+ is derived from the executable's basename when omitted.
|
|
64
|
+
def entry(path, args: nil, ext: nil)
|
|
65
|
+
words = path_words(path)
|
|
66
|
+
raise ArgumentError, "shell path is empty" if words.empty?
|
|
67
|
+
|
|
68
|
+
executable = words.shift
|
|
69
|
+
{
|
|
70
|
+
path: executable,
|
|
71
|
+
args: (words + split_args(args)).freeze,
|
|
72
|
+
ext: ext || DEFAULT_EXTENSIONS.fetch(File.basename(executable), ".tmp")
|
|
73
|
+
}.freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def split_args(args)
|
|
77
|
+
case args
|
|
78
|
+
when nil then []
|
|
79
|
+
when String then Shellwords.split(args)
|
|
80
|
+
else Array(args).map(&:to_s)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The executable and any leading arguments a path gives: a Pathname
|
|
85
|
+
# names one file and is never split; anything else is a String with
|
|
86
|
+
# shell quoting.
|
|
87
|
+
def path_words(path)
|
|
88
|
+
return path.to_s.empty? ? [] : [path.to_s] if path.is_a?(Pathname)
|
|
89
|
+
|
|
90
|
+
Shellwords.split(path.to_s)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The name a bare path registers under: the executable's basename.
|
|
94
|
+
# "/usr/local/bin/bash -c -w" is :bash, "uv run --script" is :uv.
|
|
95
|
+
def name_for(entry) = File.basename(entry[:path]).to_sym
|
|
96
|
+
|
|
97
|
+
# The file an entry's executable names, or nil: a path with a
|
|
98
|
+
# directory part must be an executable file as given (no ~ or PATH
|
|
99
|
+
# expansion, since none happens when it runs); a bare name must be
|
|
100
|
+
# found on +env_path+.
|
|
101
|
+
def resolve(executable, env_path = ENV.fetch("PATH", nil))
|
|
102
|
+
return Schedule.which(executable, env_path) unless executable.include?(File::SEPARATOR)
|
|
103
|
+
|
|
104
|
+
executable if File.file?(executable) && File.executable?(executable)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# +entry+ when its executable resolves; ArgumentError naming the
|
|
108
|
+
# shell otherwise, so a .loki fails as it loads rather than with an
|
|
109
|
+
# exit 127 the first time a task runs.
|
|
110
|
+
def validate!(name, entry, env_path = ENV.fetch("PATH", nil))
|
|
111
|
+
return entry if resolve(entry[:path], env_path)
|
|
112
|
+
|
|
113
|
+
problem = entry[:path].include?(File::SEPARATOR) ? "is not an executable file" : "is not an executable on PATH"
|
|
114
|
+
raise ArgumentError, "shell #{name.inspect}: #{entry[:path]} #{problem}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The entry for +name+, or a bare fallback that runs +name+ itself as
|
|
118
|
+
# found on PATH.
|
|
119
|
+
def shell_for(name)
|
|
120
|
+
shells.fetch(name.to_sym) { { path: name.to_s, args: [].freeze, ext: DEFAULT_EXTENSIONS.fetch(name.to_s, ".tmp") }.freeze }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The argv that runs +file+ with +shell+.
|
|
124
|
+
def argv(shell, file)
|
|
125
|
+
[shell[:path], *shell[:args], file]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# A process's exit code as a shell reports it: its status, or 128
|
|
129
|
+
# plus the signal number when a signal ended it.
|
|
130
|
+
def exit_code(status) = status.exitstatus || (128 + status.termsig)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
module ClassMethods
|
|
134
|
+
def shells = Shell.shells
|
|
135
|
+
|
|
136
|
+
# `default_shell :zsh` selects the shell `sh` runs, leaving the
|
|
137
|
+
# registry alone; with no argument it returns the current name. Any
|
|
138
|
+
# other form registers first, through `shell`, then selects:
|
|
139
|
+
#
|
|
140
|
+
# default_shell "/usr/local/bin/bash -c -w" # :bash
|
|
141
|
+
# default_shell :strict, "bash", args: "-eu -o pipefail"
|
|
142
|
+
# default_shell :zsh, args: "-f" # path is the name
|
|
143
|
+
def default_shell(name_or_path = nil, path = nil, args: nil, ext: nil)
|
|
144
|
+
return Shell.default_shell unless name_or_path
|
|
145
|
+
|
|
146
|
+
selecting = name_or_path.is_a?(Symbol) && !(path || args || ext)
|
|
147
|
+
Shell.default_shell(selecting ? selectable!(name_or_path) : shell(name_or_path, path, args:, ext:))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Register a shell and return its name. With a Symbol first:
|
|
151
|
+
#
|
|
152
|
+
# shell :strict, "bash -eu -o pipefail"
|
|
153
|
+
# shell :uv, "uv run --script", ext: ".py"
|
|
154
|
+
# shell :fish # fish from PATH, defaults
|
|
155
|
+
#
|
|
156
|
+
# With a bare path (String or Pathname) the name is the executable's
|
|
157
|
+
# basename, and any options in the string are parsed into args:
|
|
158
|
+
#
|
|
159
|
+
# shell "/usr/local/bin/bash -c -w" # :bash, args ["-c", "-w"]
|
|
160
|
+
#
|
|
161
|
+
# The executable must exist: an executable file at that path, or a
|
|
162
|
+
# bare name found on PATH. Otherwise ArgumentError, as the .loki loads.
|
|
163
|
+
def shell(name_or_path, path = nil, args: nil, ext: nil)
|
|
164
|
+
name, path = split_shell_args(name_or_path, path)
|
|
165
|
+
entry = Shell.entry(path, args:, ext:)
|
|
166
|
+
name ||= Shell.name_for(entry)
|
|
167
|
+
Shell.shells[name] = Shell.validate!(name, entry)
|
|
168
|
+
name
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# +name+ when it is registered or runs as itself from PATH; otherwise
|
|
174
|
+
# ArgumentError as the .loki loads, as `shell` does for a bad path.
|
|
175
|
+
def selectable!(name)
|
|
176
|
+
return name if Shell.shells.key?(name)
|
|
177
|
+
|
|
178
|
+
Shell.validate!(name, Shell.shell_for(name))
|
|
179
|
+
name
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The [name, path] of a `shell` call: a Symbol first is the name, and
|
|
183
|
+
# its path defaults to the name itself; a String or Pathname first is
|
|
184
|
+
# the path, and the name comes from the entry.
|
|
185
|
+
def split_shell_args(name_or_path, path)
|
|
186
|
+
return [nil, name_or_path] unless name_or_path.is_a?(Symbol)
|
|
187
|
+
|
|
188
|
+
[name_or_path, path || name_or_path.to_s]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def self.included(base) = base.extend(ClassMethods)
|
|
193
|
+
|
|
194
|
+
# The scripts `sh` is running right now, so that INT or TERM sent to
|
|
195
|
+
# asgard alone (`kill <pid>`, a process manager) reaches them too:
|
|
196
|
+
# asgard passes the signal on, waits for them, then exits with 128
|
|
197
|
+
# plus the signal's number. Only the child's own pid is signalled,
|
|
198
|
+
# never a process group, since the children share asgard's group.
|
|
11
199
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
200
|
+
# Ctrl-C at a terminal already reaches every process in the foreground
|
|
201
|
+
# group, children included, so the signal is passed on only to a child
|
|
202
|
+
# still running GRACE seconds later: a script that stops on its own
|
|
203
|
+
# Ctrl-C is not signalled a second time.
|
|
204
|
+
module Children
|
|
205
|
+
SIGNALS = %w[INT TERM].freeze
|
|
206
|
+
GRACE = 0.3
|
|
207
|
+
|
|
208
|
+
@pids = [].freeze # swapped whole, never changed: the trap reads it without a lock
|
|
209
|
+
@active = 0
|
|
210
|
+
@previous = {}
|
|
211
|
+
@received = nil
|
|
212
|
+
@mutex = Mutex.new
|
|
213
|
+
|
|
214
|
+
class << self
|
|
215
|
+
# The signal that arrived while a script ran, or nil.
|
|
216
|
+
attr_reader :received
|
|
217
|
+
|
|
218
|
+
# Runs +argv+ as a child and returns its Process::Status, or nil
|
|
219
|
+
# when it could not be started.
|
|
220
|
+
def run(argv)
|
|
221
|
+
enter
|
|
222
|
+
pid = Process.spawn(*argv)
|
|
223
|
+
add(pid)
|
|
224
|
+
Process.wait2(pid).last
|
|
225
|
+
rescue SystemCallError
|
|
226
|
+
nil
|
|
227
|
+
ensure
|
|
228
|
+
remove(pid) if pid
|
|
229
|
+
leave
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A pid asgard may signal: an Integer above 1 that is not its own.
|
|
233
|
+
def signalable?(pid) = pid.is_a?(Integer) && pid > 1 && pid != Process.pid
|
|
234
|
+
|
|
235
|
+
def alive?(pid) = signalable?(pid) && Pid.alive?(pid)
|
|
236
|
+
|
|
237
|
+
# 128 plus +signal+'s number, as a shell reports it; nil for nil.
|
|
238
|
+
def exit_status_for(signal) = signal && (128 + Signal.list.fetch(signal))
|
|
239
|
+
|
|
240
|
+
# The status asgard exits with after a signal, or nil.
|
|
241
|
+
def exit_status = exit_status_for(received)
|
|
242
|
+
|
|
243
|
+
# Sends +signal+ to every child still running, then to everything
|
|
244
|
+
# each child started. A shell killed while it waits does not pass
|
|
245
|
+
# the signal on, so the command it was running would be left
|
|
246
|
+
# behind. The tree is read before any signal, since a child that
|
|
247
|
+
# dies hands its own children to init.
|
|
248
|
+
def forward(signal, table = process_table)
|
|
249
|
+
@pids.each do |pid|
|
|
250
|
+
[pid, *descendants_of(pid, table)].each { |target| signal(signal, target) }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Every process below +pid+ in +table+ ([pid, parent pid] pairs),
|
|
255
|
+
# nearest first.
|
|
256
|
+
def descendants_of(pid, table)
|
|
257
|
+
children = table.filter_map { |child, parent| child if parent == pid }
|
|
258
|
+
children + children.flat_map { |child| descendants_of(child, table) }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# [pid, parent pid] for every process in +ps_output+ (one pair per
|
|
262
|
+
# line); lines that are not two numbers are dropped.
|
|
263
|
+
def process_table(ps_output = ps_listing)
|
|
264
|
+
ps_output.to_s.lines.filter_map do |line|
|
|
265
|
+
pair = line.split.map { Integer(it, exception: false) }
|
|
266
|
+
pair if pair.size == 2 && pair.all?
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Every process's pid and parent pid as +ps+ prints them, or "" when
|
|
271
|
+
# ps cannot run (a slim image without procps). It rescues on its own:
|
|
272
|
+
# a default argument is evaluated outside the method's rescue, so a
|
|
273
|
+
# missing ps would otherwise escape forward before any signal went out.
|
|
274
|
+
def ps_listing(ps = Pid::PS)
|
|
275
|
+
IO.popen([ps, "-A", "-o", "pid=,ppid="], err: File::NULL, &:read)
|
|
276
|
+
rescue SystemCallError
|
|
277
|
+
""
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def signal(signal, pid)
|
|
281
|
+
Process.kill(signal, pid) if alive?(pid)
|
|
282
|
+
rescue Errno::ESRCH
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
def add(pid) = @mutex.synchronize { @pids = [*@pids, pid].freeze }
|
|
289
|
+
def remove(pid) = @mutex.synchronize { @pids = (@pids - [pid]).freeze }
|
|
290
|
+
|
|
291
|
+
# The first script to start installs the traps; the last to finish
|
|
292
|
+
# puts the previous handlers back.
|
|
293
|
+
def enter = @mutex.synchronize { install_traps if (@active += 1) == 1 }
|
|
294
|
+
def leave = @mutex.synchronize { restore_traps if (@active -= 1).zero? }
|
|
295
|
+
|
|
296
|
+
# A signal asgard was started ignoring (INT under `cmd &`) stays
|
|
297
|
+
# ignored.
|
|
298
|
+
def install_traps
|
|
299
|
+
@received = nil
|
|
300
|
+
SIGNALS.each do |signal|
|
|
301
|
+
previous = Signal.trap(signal) { on_signal(signal) }
|
|
302
|
+
previous == "IGNORE" ? Signal.trap(signal, previous) : @previous[signal] = previous || "DEFAULT"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def restore_traps
|
|
307
|
+
@previous.each { |signal, handler| Signal.trap(signal, handler) }
|
|
308
|
+
@previous = {}
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Runs in trap context, where a Mutex cannot be taken: the thread
|
|
312
|
+
# does the waiting and the signalling.
|
|
313
|
+
def on_signal(signal)
|
|
314
|
+
@received ||= signal
|
|
315
|
+
Thread.new do
|
|
316
|
+
sleep GRACE
|
|
317
|
+
forward(signal)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
47
320
|
end
|
|
48
321
|
end
|
|
49
322
|
|
|
323
|
+
# One shebang call: the shell name the task gave, its registry entry and
|
|
324
|
+
# the stripped script.
|
|
325
|
+
Run = Data.define(:name, :shell, :script) do
|
|
326
|
+
def ext = shell[:ext]
|
|
327
|
+
def argv(file) = Shell.argv(shell, file)
|
|
328
|
+
def missing_message = "asgard: shell #{name.inspect} not found: #{argv('...').shelljoin}"
|
|
329
|
+
|
|
330
|
+
# Says the shell is missing and exits 127. $stderr.puts, not warn:
|
|
331
|
+
# warn is silent under -W0 and this must show.
|
|
332
|
+
def missing!
|
|
333
|
+
$stderr.puts missing_message # rubocop:disable Style/StderrPuts
|
|
334
|
+
exit 127
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Write +script+ to a tempfile and run it with the shell registered as
|
|
339
|
+
# +name+, or with the default shell when only the script is given.
|
|
340
|
+
# Returns true; exits with the command's status code on failure. `sh`
|
|
341
|
+
# is the same method under a shorter name (see the alias below).
|
|
342
|
+
#
|
|
343
|
+
# background: true starts the script as a job named after the calling
|
|
344
|
+
# task (`doc_server-1`); a Symbol or String names the job itself. The
|
|
345
|
+
# call returns a Jobs::Job at once and the job outlives asgard; see
|
|
346
|
+
# `asgard jobs`.
|
|
347
|
+
def shebang(name_or_script, script = nil, silent: false, background: nil)
|
|
348
|
+
name, script = script ? [name_or_script, script] : [Shell.default_shell, name_or_script]
|
|
349
|
+
run = Run.new(name:, shell: Shell.shell_for(name), script: script.strip)
|
|
350
|
+
job = _background_job_name(background)
|
|
351
|
+
$stdout.puts run.script unless silent
|
|
352
|
+
job ? _start_job(run, job, silent:) : _run_script(run)
|
|
353
|
+
end
|
|
354
|
+
alias_method :sh, :shebang # rubocop:disable Style/Alias -- the method form reads as the API statement it is
|
|
355
|
+
|
|
50
356
|
private
|
|
51
357
|
|
|
52
|
-
# The
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
358
|
+
# The job name a background: value asks for; only `true` consumes a
|
|
359
|
+
# sequence number, and only inside a task body.
|
|
360
|
+
def _background_job_name(background)
|
|
361
|
+
return nil unless background
|
|
362
|
+
|
|
363
|
+
task = current_task
|
|
364
|
+
sequence = _next_job_sequence if background == true && task
|
|
365
|
+
Jobs.job_name(background, task:, sequence:)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def _start_job(run, name, silent:)
|
|
369
|
+
root = Asgard.project_root
|
|
370
|
+
state = Jobs::State.new(project: Schedule.project_slug(root), root:)
|
|
371
|
+
Jobs::Launcher.start(run, name:, task: current_task, state:, silent:)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def _run_script(run)
|
|
375
|
+
Tempfile.create(["asgard_", run.ext]) do |f|
|
|
376
|
+
f.write(run.script)
|
|
377
|
+
f.flush
|
|
378
|
+
status = Children.run(run.argv(f.path))
|
|
379
|
+
run.missing! unless status
|
|
380
|
+
signalled = Children.exit_status
|
|
381
|
+
exit(signalled) if signalled
|
|
382
|
+
exit(Shell.exit_code(status)) unless status.success?
|
|
383
|
+
true
|
|
384
|
+
end
|
|
57
385
|
end
|
|
58
386
|
end
|
|
59
387
|
end
|