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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -99
  3. data/README.md +141 -46
  4. data/bin/asgard +10 -0
  5. data/lib/asgard/base/dispatch.rb +257 -31
  6. data/lib/asgard/base/registry.rb +7 -3
  7. data/lib/asgard/base.rb +140 -9
  8. data/lib/asgard/declarations.rb +21 -0
  9. data/lib/asgard/doctor/task_sections.rb +11 -3
  10. data/lib/asgard/doctor.rb +12 -8
  11. data/lib/asgard/jobs/commands.rb +152 -0
  12. data/lib/asgard/jobs/declaration.rb +149 -0
  13. data/lib/asgard/jobs/launcher.rb +109 -0
  14. data/lib/asgard/jobs/state.rb +190 -0
  15. data/lib/asgard/jobs/stopper.rb +141 -0
  16. data/lib/asgard/jobs.rb +25 -0
  17. data/lib/asgard/kernel_methods.rb +19 -14
  18. data/lib/asgard/leading_flag.rb +10 -0
  19. data/lib/asgard/pid.rb +130 -0
  20. data/lib/asgard/schedule/commands.rb +39 -11
  21. data/lib/asgard/schedule/declaration.rb +139 -12
  22. data/lib/asgard/schedule/job_files.rb +24 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -18
  24. data/lib/asgard/schedule/systemd.rb +69 -27
  25. data/lib/asgard/schedule/table.rb +34 -16
  26. data/lib/asgard/schedule.rb +49 -9
  27. data/lib/asgard/shell.rb +372 -44
  28. data/lib/asgard/state_dir.rb +84 -0
  29. data/lib/asgard/tasks.rb +8 -0
  30. data/lib/asgard/version.rb +1 -1
  31. data/lib/asgard/watch/changes.rb +26 -1
  32. data/lib/asgard/watch/commands.rb +52 -12
  33. data/lib/asgard/watch/daemon.rb +334 -43
  34. data/lib/asgard/watch/declaration.rb +135 -40
  35. data/lib/asgard/watch/dispatcher.rb +61 -10
  36. data/lib/asgard/watch/mailbox.rb +50 -0
  37. data/lib/asgard/watch/reporter.rb +50 -34
  38. data/lib/asgard/watch/spawn.rb +41 -16
  39. data/lib/asgard/watch/state.rb +93 -27
  40. data/lib/asgard/watch/worker.rb +44 -9
  41. data/lib/asgard/watch.rb +4 -11
  42. data/lib/asgard.rb +80 -14
  43. metadata +167 -186
  44. data/.envrc +0 -6
  45. data/.github/workflows/deploy-github-pages.yml +0 -52
  46. data/.loki +0 -39
  47. data/.reek.yml +0 -166
  48. data/.rubocop.yml +0 -163
  49. data/Archspec.rb +0 -24
  50. data/CLAUDE.md +0 -139
  51. data/COMMITS.md +0 -196
  52. data/bin/console +0 -11
  53. data/bin/setup +0 -8
  54. data/doc_tasks.loki +0 -21
  55. data/docs/api.md +0 -368
  56. data/docs/assets/css/custom.css +0 -93
  57. data/docs/assets/images/asgard.jpg +0 -0
  58. data/docs/changelog.md +0 -1
  59. data/docs/dependencies.md +0 -313
  60. data/docs/environment.md +0 -129
  61. data/docs/examples.md +0 -194
  62. data/docs/getting-started.md +0 -181
  63. data/docs/helpers.md +0 -262
  64. data/docs/index.md +0 -87
  65. data/docs/options.md +0 -224
  66. data/docs/schedule.md +0 -106
  67. data/docs/shell.md +0 -237
  68. data/docs/subcommands.md +0 -181
  69. data/docs/task-files.md +0 -407
  70. data/docs/tasks.md +0 -326
  71. data/docs/variables.md +0 -338
  72. data/docs/watch.md +0 -123
  73. data/examples/.env +0 -4
  74. data/examples/.envrc +0 -3
  75. data/examples/.loki +0 -24
  76. data/examples/bad.loki +0 -63
  77. data/examples/concurrent.loki +0 -58
  78. data/examples/confirm.loki +0 -48
  79. data/examples/db_subcommands.loki +0 -74
  80. data/examples/depends_on_block/bad/.loki +0 -51
  81. data/examples/depends_on_block/good/.loki +0 -83
  82. data/examples/do.loki +0 -45
  83. data/examples/env_usage.loki +0 -27
  84. data/examples/kitchen_sink.loki +0 -210
  85. data/examples/server_subcommands.loki +0 -65
  86. data/examples/subdir/.loki +0 -12
  87. data/examples/subdir/import_demo.loki +0 -14
  88. data/examples/subdir/import_up_demo.loki +0 -18
  89. data/examples/watcher_examples/.envrc +0 -3
  90. data/examples/watcher_examples/.loki +0 -54
  91. data/examples/watcher_examples/README.md +0 -112
  92. data/examples/watcher_examples/bin/hello +0 -3
  93. data/examples/watcher_examples/data/records.json +0 -1
  94. data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
  95. data/examples/watcher_examples/docs/index.md +0 -4
  96. data/examples/watcher_examples/lib/greeter.rb +0 -7
  97. data/gem_tasks.loki +0 -51
  98. data/git.loki +0 -13
  99. data/mkdocs.yml +0 -166
  100. data/quality.loki +0 -266
  101. data/quality_rails.loki +0 -47
  102. 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
- EXTRA = 4 # a space either side of a cell, plus slack for tty-table's rounding
14
- BORDERS = HEADERS.size + 1 # one before each column and one after the last
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 five columns.
27
- def self.draw(rows, title: nil, io: $stdout, headers: HEADERS)
28
- table = new(rows, color: io.tty? && !ENV.key?("NO_COLOR"), width: terminal_width(io), headers:).render
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 = headers
37
- @cells = rows.map { |row| row.values_at(:name, :command, :schedule, :state, :last_exit) }
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
- grid.render(:unicode, multiline: true, resize: true, width: total_width, column_widths:, padding: [0, 1]).to_s
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
- def total_width = column_widths.sum + BORDERS
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 = FLEXIBLE.max_by { |index| @column_widths[index] }
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
- *lead, state, last_exit = cells
73
- state_color = { "active" => :green, "running" => :green, "stopped" => :yellow }.fetch(state, :red)
74
- exit_color = last_exit.match?(/\A[1-9]\d*\b/) ? :red : :dim
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
@@ -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. Redeclaring an identical entry is a no-op
28
- # (a .loki file loaded twice); a different entry under the same name
29
- # is an error.
30
- def declare(task, **settings)
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
- declarations[name] = spec
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 'English'
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
- # Run a shell script. Multiline strings are passed to bash -c; single-line
9
- # strings are passed to system directly. Exits with the command's status
10
- # code on failure.
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
- # Pass exec: true to replace the current process instead of forking —
13
- # useful for a task's final, long-running command (e.g. a dev server)
14
- # so the asgard/ruby process doesn't sit resident in memory alongside it.
15
- def sh(script, silent: false, exec: false)
16
- script = script.strip
17
- $stdout.puts script unless silent
18
- argv = shell_argv(script)
19
-
20
- if exec
21
- $stdout.flush
22
- Kernel.exec(*argv)
23
- else
24
- exit($CHILD_STATUS.exitstatus) unless system(*argv)
25
- end
26
- end
27
-
28
- # Write +script+ to a tempfile and execute it with +interpreter+.
29
- # Useful for embedding Python, Node, Ruby, or any shebang-style body.
30
- def shebang(interpreter, script, silent: false)
31
- extensions = {
32
- python3: ".py", python: ".py",
33
- node: ".js",
34
- ruby: ".rb",
35
- perl: ".pl",
36
- bash: ".sh", sh: ".sh"
37
- }
38
- ext = extensions.fetch(interpreter.to_sym, ".tmp")
39
-
40
- $stdout.puts script unless silent
41
-
42
- Tempfile.create(["asgard_", ext]) do |f|
43
- f.write(script)
44
- f.flush
45
- system(interpreter.to_s, f.path)
46
- exit($CHILD_STATUS.exitstatus) unless $CHILD_STATUS.success?
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 argv passed to system/exec for +script+: multi-line scripts run
53
- # through `bash -c` so assignments carry across lines; single-line
54
- # scripts run directly.
55
- def shell_argv(script)
56
- script.include?("\n") ? ["bash", "-c", script] : [script]
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