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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
module Jobs
|
|
5
|
+
# Ends one job: TERM to its process group, a poll every 0.2s for up to
|
|
6
|
+
# the grace period, then KILL and a wait until the group holds none of
|
|
7
|
+
# the job's processes. The signal sender, the liveness check and
|
|
8
|
+
# sleep are injectable so the timing branches test without waiting.
|
|
9
|
+
class Stopper
|
|
10
|
+
POLL = 0.2
|
|
11
|
+
TIMEOUT = 10
|
|
12
|
+
WARN_AFTER = 1.0
|
|
13
|
+
RESEND_EVERY = 1.0
|
|
14
|
+
|
|
15
|
+
# A negative pid addresses a group, and kill(2) gives -1 and 0 their
|
|
16
|
+
# own meanings: every process the user owns, and the caller's own
|
|
17
|
+
# group. A record that is corrupt or hand-written must never turn
|
|
18
|
+
# into either, so only a real group id (above 1) is ever signalled.
|
|
19
|
+
#
|
|
20
|
+
# A valid id is still refused (false) when it is our own group, or
|
|
21
|
+
# when no process leads it any more: a job's pgid is its trampoline's
|
|
22
|
+
# pid, so a group whose leader is gone or moved is not the job. So is
|
|
23
|
+
# a group we may not signal (EPERM): its id now belongs to another
|
|
24
|
+
# user's processes. +kill+ is Process.kill, injectable for tests.
|
|
25
|
+
def self.signal_group(signal, pgid, own_pgrp: Process.getpgrp, pgid_of: Process.method(:getpgid),
|
|
26
|
+
kill: Process.method(:kill))
|
|
27
|
+
unless pgid.is_a?(Integer) && pgid > 1
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"refusing to signal process group #{pgid.inspect}: not a group id above 1"
|
|
30
|
+
end
|
|
31
|
+
return false unless led_or_orphaned?(pgid, own_pgrp:, pgid_of:, kill:)
|
|
32
|
+
|
|
33
|
+
kill.call(signal, -pgid)
|
|
34
|
+
rescue Errno::ESRCH
|
|
35
|
+
nil
|
|
36
|
+
rescue Errno::EPERM
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A group may be signalled when its leader (the trampoline) still
|
|
41
|
+
# leads it, or when the leader is gone but members remain: then the
|
|
42
|
+
# id cannot have been reused. Never our own group, and never one we
|
|
43
|
+
# may not signal (EPERM). ESRCH from the group check means nobody is
|
|
44
|
+
# left: nil from signal_group.
|
|
45
|
+
def self.led_or_orphaned?(pgid, own_pgrp:, pgid_of:, kill: Process.method(:kill))
|
|
46
|
+
return false if pgid == own_pgrp
|
|
47
|
+
|
|
48
|
+
leader_group = begin
|
|
49
|
+
pgid_of.call(pgid)
|
|
50
|
+
rescue Errno::ESRCH
|
|
51
|
+
kill.call(0, -pgid) # members without their leader, or ESRCH
|
|
52
|
+
pgid
|
|
53
|
+
end
|
|
54
|
+
safe_group?(pgid, pid: pgid, own_pgrp:, pgid_of: ->(_) { leader_group })
|
|
55
|
+
rescue Errno::EPERM
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether +pgid+ may be signalled as the group +pid+ leads: a group id
|
|
60
|
+
# above 1, not our own, and still the group +pid+ belongs to.
|
|
61
|
+
def self.safe_group?(pgid, pid:, own_pgrp:, pgid_of:)
|
|
62
|
+
return false unless pgid.is_a?(Integer) && pgid > 1 && pgid != own_pgrp
|
|
63
|
+
|
|
64
|
+
pgid_of.call(pid) == pgid
|
|
65
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# +alive+ is State.active?: a job whose script exited still counts
|
|
70
|
+
# while processes it left run in its group, and ps has confirmed
|
|
71
|
+
# they are there before any signal goes out.
|
|
72
|
+
def initialize(timeout: TIMEOUT, poll: POLL, out: $stdout,
|
|
73
|
+
alive: State.method(:active?), signal: Stopper.method(:signal_group), sleeper: Kernel.method(:sleep))
|
|
74
|
+
@timeout = timeout
|
|
75
|
+
@poll = poll
|
|
76
|
+
@out = out
|
|
77
|
+
@alive = alive
|
|
78
|
+
@signal = signal
|
|
79
|
+
@sleeper = sleeper
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# :stopped when TERM ended the job within the grace period, :killed
|
|
83
|
+
# when KILL had to, :not_running when there was nothing to stop. A
|
|
84
|
+
# KILL takes the trampoline too, so the 137 is recorded here (a
|
|
85
|
+
# record that is not a job's, as the timing tests use, writes none).
|
|
86
|
+
def stop(record)
|
|
87
|
+
return :not_running unless @alive.call(record)
|
|
88
|
+
|
|
89
|
+
@signal.call("TERM", record[:pgid])
|
|
90
|
+
return :stopped if exited?(record, resend: "TERM")
|
|
91
|
+
|
|
92
|
+
@signal.call("KILL", record[:pgid])
|
|
93
|
+
State.record_kill(record) if exited?(record)
|
|
94
|
+
:killed
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Whether poll number +tick+ (from 0) re-sends TERM: once every
|
|
98
|
+
# RESEND_EVERY seconds. A TERM that reached the trampoline's child
|
|
99
|
+
# between its fork and its exec met the inherited trap and was lost;
|
|
100
|
+
# by the next one the child runs the job with the default handler.
|
|
101
|
+
def self.resend_tick?(tick, poll:)
|
|
102
|
+
every = (RESEND_EVERY / poll).round
|
|
103
|
+
tick.positive? && (tick % every).zero?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Stops and reports one job; the outcome as above.
|
|
107
|
+
def stop_and_report(record)
|
|
108
|
+
outcome = stop(record)
|
|
109
|
+
@out.puts report_line(outcome, record)
|
|
110
|
+
outcome
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def report_line(outcome, record)
|
|
114
|
+
name, pid = record.values_at(:name, :pid)
|
|
115
|
+
case outcome
|
|
116
|
+
when :stopped then "stopped #{name} (pid #{pid})"
|
|
117
|
+
when :killed then "killed #{name} (pid #{pid}) after #{@timeout}s"
|
|
118
|
+
else "#{name} is not running"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
# True once the job is gone, false when the grace period ran out.
|
|
125
|
+
# Says what it is waiting for once, when the wait reaches a second
|
|
126
|
+
# (or its last poll, for a shorter grace period).
|
|
127
|
+
def exited?(record, resend: nil)
|
|
128
|
+
ticks = (@timeout / @poll).ceil
|
|
129
|
+
warn_tick = [(WARN_AFTER / @poll).round, ticks].min - 1
|
|
130
|
+
ticks.times do |tick|
|
|
131
|
+
return true unless @alive.call(record)
|
|
132
|
+
|
|
133
|
+
@signal.call(resend, record[:pgid]) if resend && Stopper.resend_tick?(tick, poll: @poll)
|
|
134
|
+
@sleeper.call(@poll)
|
|
135
|
+
@out.puts "waiting for #{record[:name]} (pid #{record[:pid]})" if tick == warn_tick
|
|
136
|
+
end
|
|
137
|
+
!@alive.call(record)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
data/lib/asgard/jobs.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "jobs/declaration"
|
|
4
|
+
require_relative "jobs/state"
|
|
5
|
+
require_relative "jobs/launcher"
|
|
6
|
+
require_relative "jobs/stopper"
|
|
7
|
+
require_relative "jobs/commands"
|
|
8
|
+
|
|
9
|
+
module Asgard
|
|
10
|
+
# Background jobs started from a task body:
|
|
11
|
+
#
|
|
12
|
+
# class Tasks
|
|
13
|
+
# def doc_server = sh "mkdocs serve", background: true # doc_server-1
|
|
14
|
+
# def servers
|
|
15
|
+
# sh "docker compose up postgres", background: true # servers-1
|
|
16
|
+
# sh "bin/rails server", background: :api # api
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# then manage them with `asgard jobs list|stop|log|restart|clean`.
|
|
21
|
+
module Jobs
|
|
22
|
+
# A job could not be managed: an unknown name, no owning task.
|
|
23
|
+
class Error < Asgard::Error; end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
# Every helper defined here is also in Asgard::Base::KERNEL_HELPERS, so a
|
|
4
|
+
# task or helper by its name is refused rather than hiding it.
|
|
5
5
|
module Kernel
|
|
6
6
|
def debug? = $DEBUG
|
|
7
7
|
def verbose? = $VERBOSE
|
|
@@ -26,7 +26,7 @@ module Kernel
|
|
|
26
26
|
dir = Pathname.new(Dir.pwd)
|
|
27
27
|
loop do
|
|
28
28
|
candidate = dir + name
|
|
29
|
-
return candidate if candidate.
|
|
29
|
+
return candidate if candidate.file? # a directory by that name is not it
|
|
30
30
|
parent = dir.parent
|
|
31
31
|
break if parent == dir
|
|
32
32
|
dir = parent
|
|
@@ -39,36 +39,41 @@ module Kernel
|
|
|
39
39
|
# defaults to the immediate caller, but is exposed as a keyword so a
|
|
40
40
|
# wrapper (e.g. Asgard::Doctor's import tracer, prepended via `super`)
|
|
41
41
|
# can thread through the *real* caller instead of its own frame.
|
|
42
|
+
#
|
|
43
|
+
# Only +path+ as written decides whether it is a glob. The caller's
|
|
44
|
+
# directory is the glob's base, never part of the pattern, so a project
|
|
45
|
+
# directory named like a glob ("glob[1]") does not change what matches.
|
|
42
46
|
def import(path, from: caller_locations(1, 1).first)
|
|
43
47
|
path = path.to_s
|
|
44
48
|
raise ArgumentError, "import: path must end with .loki (got #{path.inspect})" unless path.end_with?(".loki")
|
|
45
|
-
unless File.absolute_path?(path)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
end
|
|
49
|
-
paths = path =~ /[*?\[{]/ ? Dir.glob(path) : [path]
|
|
50
|
-
loaded = paths.map do |p|
|
|
49
|
+
base = File.dirname(from.absolute_path) unless File.absolute_path?(path)
|
|
50
|
+
paths = Asgard.glob?(path) ? Dir.glob(path, base:) : [path]
|
|
51
|
+
loaded = paths.map { File.expand_path(it, base) }.map do |p|
|
|
51
52
|
if $LOADED_FEATURES.include?(p)
|
|
52
53
|
warn "import: skip #{p} (already loaded)" if debug?
|
|
53
54
|
next false
|
|
54
55
|
end
|
|
55
56
|
warn "import: #{p}" if verbose? || debug?
|
|
56
|
-
|
|
57
|
+
# Recorded before it loads, as Asgard.load_root does, so two files
|
|
58
|
+
# that import each other each load once instead of without end.
|
|
57
59
|
$LOADED_FEATURES << p
|
|
60
|
+
load p
|
|
58
61
|
true
|
|
59
62
|
end
|
|
60
63
|
loaded.any?
|
|
61
64
|
end
|
|
62
65
|
module_function :import
|
|
63
66
|
|
|
67
|
+
# The paths found are handed to import escaped: the directory they sit
|
|
68
|
+
# in is part of a path, not a pattern.
|
|
64
69
|
def import_up(name = ".loki")
|
|
65
|
-
if name
|
|
70
|
+
if Asgard.glob?(name)
|
|
66
71
|
dir = Dir.pwd
|
|
67
72
|
loop do
|
|
68
|
-
matches = Dir.glob(
|
|
73
|
+
matches = Dir.glob(name, base: dir)
|
|
69
74
|
unless matches.empty?
|
|
70
75
|
warn "import_up: #{name} → #{dir}" if verbose? || debug?
|
|
71
|
-
return matches.map { |p| import(p) }.any?
|
|
76
|
+
return matches.map { |p| import(Asgard.glob_escape(File.join(dir, p))) }.any?
|
|
72
77
|
end
|
|
73
78
|
parent = File.dirname(dir)
|
|
74
79
|
break if parent == dir
|
|
@@ -83,7 +88,7 @@ module Kernel
|
|
|
83
88
|
return false
|
|
84
89
|
end
|
|
85
90
|
warn "import_up: #{name} → #{path}" if verbose? || debug?
|
|
86
|
-
import path
|
|
91
|
+
import Asgard.glob_escape(path)
|
|
87
92
|
end
|
|
88
93
|
module_function :import_up
|
|
89
94
|
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded alone by bin/asgard to answer --version before Thor loads, so it
|
|
4
|
+
# requires nothing.
|
|
5
|
+
module Asgard
|
|
6
|
+
# Whether +flag+ is one of asgard's own options: among the options
|
|
7
|
+
# before the first word that is not one (the task name). After it, the
|
|
8
|
+
# word belongs to the task, which may take --version as an argument.
|
|
9
|
+
def self.leading_flag?(argv, flag) = argv.take_while { |word| word.start_with?("-") }.include?(flag)
|
|
10
|
+
end
|
data/lib/asgard/pid.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Asgard
|
|
6
|
+
# Questions about a pid that outlived the process that recorded it: is
|
|
7
|
+
# it alive, what is it running, and is it still the process we started?
|
|
8
|
+
# The watcher and background jobs both keep pids on disk across asgard
|
|
9
|
+
# runs, and a pid left by a crash or a reboot can belong to an unrelated
|
|
10
|
+
# process by the time it is read back. (Not Asgard::Process: that would
|
|
11
|
+
# shadow ::Process inside the namespace.)
|
|
12
|
+
module Pid
|
|
13
|
+
# ps by absolute path: a scheduled or watched task's PATH may not
|
|
14
|
+
# hold it.
|
|
15
|
+
PS = %w[/bin/ps /usr/bin/ps].find { |path| File.executable?(path) } || "ps"
|
|
16
|
+
|
|
17
|
+
# The system's ps cannot answer what these questions ask of it.
|
|
18
|
+
class Unusable < Asgard::Error; end
|
|
19
|
+
|
|
20
|
+
# One process of a group: its pid, when it started (to ps's one-second
|
|
21
|
+
# resolution) and its command line.
|
|
22
|
+
Member = Data.define(:pid, :started, :command)
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Whether a process exists; one we may not signal still exists.
|
|
27
|
+
def alive?(pid)
|
|
28
|
+
Process.kill(0, pid)
|
|
29
|
+
true
|
|
30
|
+
rescue Errno::ESRCH
|
|
31
|
+
false
|
|
32
|
+
rescue Errno::EPERM
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The ps argv for one pid's full command line. -ww: procps cuts the
|
|
37
|
+
# line to the terminal width (COLUMNS) without it, and a job's script
|
|
38
|
+
# path sits at the very end of a long one. macOS accepts it too.
|
|
39
|
+
def ps_argv(pid) = [PS, "-ww", "-o", "command=", "-p", pid.to_s]
|
|
40
|
+
|
|
41
|
+
# The command line of a live process as `ps` prints it, or nil when
|
|
42
|
+
# there is no such process. ps is the one way that works unchanged
|
|
43
|
+
# on macOS and Linux.
|
|
44
|
+
def command_line(pid)
|
|
45
|
+
out, status = Open3.capture2(*ps_argv(pid))
|
|
46
|
+
status.success? ? out.strip : nil
|
|
47
|
+
rescue SystemCallError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The command lines of every process in group +pgid+. ps by group id
|
|
52
|
+
# is not portable, so this lists every process with its group and
|
|
53
|
+
# keeps the matching ones (see group_commands).
|
|
54
|
+
def group_command_lines(pgid)
|
|
55
|
+
out, status = Open3.capture2(PS, "-ww", "-A", "-o", "pgid=,command=")
|
|
56
|
+
status.success? ? group_commands(out, pgid) : []
|
|
57
|
+
rescue SystemCallError
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The commands in `ps -o pgid=,command=` output that belong to +pgid+.
|
|
62
|
+
def group_commands(ps_output, pgid)
|
|
63
|
+
ps_output.each_line.filter_map do |line|
|
|
64
|
+
group, command = line.strip.split(/\s+/, 2)
|
|
65
|
+
command.to_s if group.to_i == pgid
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Every process in group +pgid+ as Members. Like group_command_lines,
|
|
70
|
+
# but with each one's age, so a caller can tell a group that has kept
|
|
71
|
+
# its members from a later group that reused the id.
|
|
72
|
+
def group_processes(pgid, now: Time.now)
|
|
73
|
+
out, status = Open3.capture2(PS, "-ww", "-A", "-o", "pid=,pgid=,etime=,command=")
|
|
74
|
+
status.success? ? parse_group(out, pgid, now:) : []
|
|
75
|
+
rescue SystemCallError
|
|
76
|
+
[]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The Members of +pgid+ in `ps -o pid=,pgid=,etime=,command=` output.
|
|
80
|
+
def parse_group(ps_output, pgid, now:)
|
|
81
|
+
ps_output.each_line.filter_map do |line|
|
|
82
|
+
pid, group, etime, command = line.strip.split(/\s+/, 4)
|
|
83
|
+
Member.new(pid: pid.to_i, started: now - elapsed_seconds(etime), command: command.to_s) if group.to_i == pgid
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Seconds in a ps etime, "[[dd-]hh:]mm:ss" (or bare seconds).
|
|
88
|
+
def elapsed_seconds(etime)
|
|
89
|
+
days, clock = etime.include?("-") ? etime.split("-", 2) : [0, etime]
|
|
90
|
+
clock.split(":").map(&:to_i).reduce(0) { |total, part| (total * 60) + part } + (days.to_i * 86_400)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Why +ps+ cannot answer for jobs, or nil when it can: it must accept
|
|
94
|
+
# the flags command_line uses and name the current process. BusyBox
|
|
95
|
+
# ps (Alpine) rejects -ww and -p; without this every job would look
|
|
96
|
+
# gone and a second start would run a duplicate.
|
|
97
|
+
def ps_problem(ps: PS)
|
|
98
|
+
out, err, status = Open3.capture3(ps, "-ww", "-o", "pid=,command=", "-p", Process.pid.to_s)
|
|
99
|
+
return err.lines.first.to_s.strip.then { it.empty? ? "exit status #{status.exitstatus}" : it } unless status.success?
|
|
100
|
+
|
|
101
|
+
out.include?(Process.pid.to_s) ? nil : "it printed nothing for pid #{Process.pid}"
|
|
102
|
+
rescue SystemCallError => e
|
|
103
|
+
e.message
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Raises Unusable unless +ps+ works (see ps_problem); asks once per
|
|
107
|
+
# process and ps.
|
|
108
|
+
def require_ps!(ps: PS)
|
|
109
|
+
@usable ||= {}
|
|
110
|
+
problem = @usable.fetch(ps) { @usable[ps] = ps_problem(ps:) }
|
|
111
|
+
return unless problem
|
|
112
|
+
|
|
113
|
+
raise Unusable, "#{ps} cannot list processes: #{problem}. Background jobs need a ps that accepts " \
|
|
114
|
+
"-ww, -o and -p (procps on Linux, the system ps on macOS and BSD); on Alpine, `apk add procps`"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether pid is alive and its command line carries every word. The
|
|
118
|
+
# current process counts unless +self_ok+ is false: the watcher asks
|
|
119
|
+
# about itself, but a job record naming our own pid is a reused pid.
|
|
120
|
+
# An empty word matches nothing, since every command line holds "".
|
|
121
|
+
# +command:+ supplies the command line in place of asking ps (tests,
|
|
122
|
+
# or a caller that already has it).
|
|
123
|
+
def matches?(pid, *words, command: nil, self_ok: true)
|
|
124
|
+
return true if self_ok && pid == Process.pid
|
|
125
|
+
|
|
126
|
+
command ||= command_line(pid)
|
|
127
|
+
!command.nil? && words.all? { |word| !word.to_s.empty? && command.include?(word.to_s) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "pathname"
|
|
4
3
|
require "shellwords"
|
|
5
4
|
|
|
6
5
|
# warn is silent when $VERBOSE is nil and bypasses $stderr in Ruby 4.0 (see
|
|
@@ -23,9 +22,9 @@ module Asgard
|
|
|
23
22
|
no_commands do
|
|
24
23
|
def schedules = Schedule.declarations
|
|
25
24
|
|
|
26
|
-
def schedule_root = (Asgard.find_task_file&.dirname || Pathname.pwd).to_s
|
|
25
|
+
def schedule_root = @schedule_root ||= File.realpath((Asgard.find_task_file&.dirname || Pathname.pwd).to_s)
|
|
27
26
|
|
|
28
|
-
def schedule_project =
|
|
27
|
+
def schedule_project = @schedule_project ||= Schedule.project_slug(schedule_root)
|
|
29
28
|
|
|
30
29
|
def scheduler
|
|
31
30
|
@scheduler ||= Schedule.backend_class.new(project: schedule_project, root: schedule_root)
|
|
@@ -102,7 +101,9 @@ module Asgard
|
|
|
102
101
|
return [scheduler, name] if scheduler.installed_names.include?(name)
|
|
103
102
|
|
|
104
103
|
project, entry = name.include?("/") ? name.split("/", 2) : [nil, name]
|
|
105
|
-
matches = scheduler.installed_entries.select
|
|
104
|
+
matches = scheduler.installed_entries.select do |slug, found|
|
|
105
|
+
found == entry && (project.nil? || Schedule.project_match?(slug, project))
|
|
106
|
+
end
|
|
106
107
|
abort "#{name} is in several projects (#{matches.map(&:first).join(', ')}); name one as project/#{entry}." if matches.size > 1
|
|
107
108
|
|
|
108
109
|
matches.first&.then { |slug, found| [backend_for(slug), found] }
|
|
@@ -144,13 +145,15 @@ module Asgard
|
|
|
144
145
|
by_project.to_h { |slug, entries| [project_title(slug, entries.first.last), entry_rows(entries)] }
|
|
145
146
|
end
|
|
146
147
|
|
|
147
|
-
# "Project: slug (/full/path)", the path read from one of the project's
|
|
148
|
+
# "Project: slug (/full/path)", the path read from one of the project's
|
|
149
|
+
# job files, flagged when the directory is gone (the project moved or
|
|
150
|
+
# was deleted, and its jobs with it should be).
|
|
148
151
|
def project_title(slug, name)
|
|
149
152
|
dir = slug == current_slug ? schedule_root : backend_for(slug).installed_directory(name)
|
|
150
|
-
["Project: #{slug}", ("(#{dir})" if dir)].compact.join(" ")
|
|
153
|
+
["Project: #{slug}", ("(#{dir})" if dir), ("[root missing]" if dir && !File.directory?(dir))].compact.join(" ")
|
|
151
154
|
end
|
|
152
155
|
|
|
153
|
-
def current_slug = Asgard.find_task_file &&
|
|
156
|
+
def current_slug = Asgard.find_task_file && schedule_project
|
|
154
157
|
|
|
155
158
|
def backend_for(slug) = Schedule.backend_class.new(project: slug, root: schedule_root)
|
|
156
159
|
|
|
@@ -222,17 +225,42 @@ module Asgard
|
|
|
222
225
|
|
|
223
226
|
path = backend.log_path(entry)
|
|
224
227
|
return $stderr.puts "#{name} has no log yet (#{path}); it is created on the first run." unless File.exist?(path)
|
|
225
|
-
return sh("tail -n +1 -f #{path.shellescape}", silent: true
|
|
228
|
+
return sh("tail -n +1 -f #{path.shellescape}", silent: true) if options[:follow]
|
|
226
229
|
|
|
227
230
|
IO.copy_stream(path, $stdout)
|
|
228
231
|
end
|
|
229
232
|
|
|
230
|
-
desc "remove", "Remove all of this project's
|
|
231
|
-
def remove
|
|
233
|
+
desc "remove [NAME]", "Remove one installed entry (NAME or project/NAME), or all of this project's"
|
|
234
|
+
def remove(name = nil)
|
|
235
|
+
return remove_one(name) if name
|
|
236
|
+
|
|
232
237
|
names = scheduler.installed_names
|
|
233
238
|
return puts "No scheduled tasks installed for #{schedule_project}." if names.empty?
|
|
234
239
|
|
|
235
|
-
names.each { |
|
|
240
|
+
names.each { |entry| schedule_uninstall(entry) }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
desc "prune", "Remove every project's entries whose project directory no longer exists"
|
|
244
|
+
method_option :dry_run, aliases: "-n", type: :boolean, desc: "Only list what would be removed"
|
|
245
|
+
def prune
|
|
246
|
+
orphans = Schedule.orphaned_entries(scheduler.installed_entries,
|
|
247
|
+
directory_of: ->(slug, name) { backend_for(slug).installed_directory(name) })
|
|
248
|
+
return puts "Nothing to prune: every installed entry's project directory exists." if orphans.empty?
|
|
249
|
+
|
|
250
|
+
orphans.each do |slug, name|
|
|
251
|
+
next puts "would remove #{slug}/#{name}" if options[:dry_run]
|
|
252
|
+
|
|
253
|
+
backend_for(slug).uninstall(name)
|
|
254
|
+
puts "removed #{slug}/#{name}"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
no_commands do
|
|
259
|
+
def remove_one(name)
|
|
260
|
+
backend, entry = resolve_installed(name)
|
|
261
|
+
backend.uninstall(entry)
|
|
262
|
+
puts "removed #{name}"
|
|
263
|
+
end
|
|
236
264
|
end
|
|
237
265
|
end
|
|
238
266
|
end
|