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
data/lib/asgard/doctor.rb
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "pathname"
|
|
4
|
-
|
|
5
3
|
require_relative "doctor/task_sections"
|
|
6
4
|
require_relative "doctor/report"
|
|
7
5
|
|
|
@@ -41,7 +39,7 @@ module Asgard
|
|
|
41
39
|
current = Pathname.new(dir)
|
|
42
40
|
loop do
|
|
43
41
|
candidate = current + ".loki"
|
|
44
|
-
markers << candidate.to_s if candidate.
|
|
42
|
+
markers << candidate.to_s if candidate.file?
|
|
45
43
|
parent = current.parent
|
|
46
44
|
break if parent == current
|
|
47
45
|
|
|
@@ -82,9 +80,9 @@ module Asgard
|
|
|
82
80
|
ImportTracer.doctor = self
|
|
83
81
|
@loaded_files << @marker
|
|
84
82
|
|
|
85
|
-
before = Asgard::Base.
|
|
86
|
-
|
|
87
|
-
@new_subclasses = (Asgard::Base.
|
|
83
|
+
before = Asgard::Base._asgard_subclasses.dup
|
|
84
|
+
Asgard.load_root(@marker)
|
|
85
|
+
@new_subclasses = (Asgard::Base._asgard_subclasses - before + [Tasks]).uniq
|
|
88
86
|
rescue StandardError, ScriptError => e
|
|
89
87
|
@findings << Finding.new(level: :error, message: "load failed: #{e.class}: #{e.message}")
|
|
90
88
|
ensure
|
|
@@ -123,7 +121,7 @@ module Asgard
|
|
|
123
121
|
loc = caller_locations(1, 1).first
|
|
124
122
|
before = $LOADED_FEATURES.dup
|
|
125
123
|
result = super(path, from: loc)
|
|
126
|
-
added = $LOADED_FEATURES - before
|
|
124
|
+
added = ImportTracer.loki_files($LOADED_FEATURES - before)
|
|
127
125
|
ImportTracer.doctor&.send(:record_import, ImportTracer.trace_message("import", path, added), added) unless
|
|
128
126
|
loc&.absolute_path&.end_with?("kernel_methods.rb")
|
|
129
127
|
result
|
|
@@ -132,11 +130,17 @@ module Asgard
|
|
|
132
130
|
def import_up(name = ".loki")
|
|
133
131
|
before = $LOADED_FEATURES.dup
|
|
134
132
|
result = super
|
|
135
|
-
added = $LOADED_FEATURES - before
|
|
133
|
+
added = ImportTracer.loki_files($LOADED_FEATURES - before)
|
|
136
134
|
ImportTracer.doctor&.send(:record_import, ImportTracer.trace_message("import_up", name, added), added)
|
|
137
135
|
result
|
|
138
136
|
end
|
|
139
137
|
|
|
138
|
+
# $LOADED_FEATURES also grows with every `require` a .loki file runs
|
|
139
|
+
# (net/http, uri, ...); only .loki paths were brought in by import.
|
|
140
|
+
def self.loki_files(added)
|
|
141
|
+
added.select { |f| f.end_with?(".loki") }
|
|
142
|
+
end
|
|
143
|
+
|
|
140
144
|
def self.trace_message(verb, arg, added)
|
|
141
145
|
inspected = arg.inspect
|
|
142
146
|
return "#{verb} #{inspected} -- nothing new (not found or already loaded)" if added.empty?
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
# warn is silent when $VERBOSE is nil and bypasses $stderr in Ruby 4.0 (see
|
|
6
|
+
# base/task_dsl.rb); these messages are for the user, so they always print.
|
|
7
|
+
# rubocop:disable Style/StderrPuts
|
|
8
|
+
|
|
9
|
+
module Asgard
|
|
10
|
+
module Jobs
|
|
11
|
+
# `asgard jobs SUBCOMMAND` — lists, stops and restarts the background
|
|
12
|
+
# jobs that `sh ..., background:` started. Subclasses Asgard::Base
|
|
13
|
+
# rather than Tasks so `asgard jobs help` lists only these
|
|
14
|
+
# subcommands, not every project task.
|
|
15
|
+
class Commands < Asgard::Base
|
|
16
|
+
namespace "jobs"
|
|
17
|
+
default_command :list
|
|
18
|
+
|
|
19
|
+
HEADERS = %w[JOB TASK PID STATUS STARTED COMMAND].freeze
|
|
20
|
+
KEYS = %i[name task pid state started command].freeze
|
|
21
|
+
FLEXIBLE = [5].freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# One `list` row per record, with what the job is doing now.
|
|
25
|
+
def rows(state) = state.records.map { |record| row(record, state) }
|
|
26
|
+
|
|
27
|
+
def row(record, state)
|
|
28
|
+
name = record[:name]
|
|
29
|
+
exited = state.exit_time(name)
|
|
30
|
+
status = Jobs.status(exit_code: state.exit_code(name), verified: state.verified?(record),
|
|
31
|
+
leftovers: State.leftovers?(record, exited_at: exited))
|
|
32
|
+
seconds = (exited - Time.iso8601(record[:started])).round(1) if status.is_a?(Array)
|
|
33
|
+
{ name:, task: record[:task], pid: record[:pid], state: Jobs.describe_status(status, seconds:),
|
|
34
|
+
started: Time.iso8601(record[:started]).strftime("%Y-%m-%d %H:%M:%S"), command: record[:command] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One line per record file +states+ left out of the listing.
|
|
38
|
+
def skipped_lines(states) = states.flat_map(&:invalid_records).map { |path| "jobs: skipped #{path}: not a job record" }
|
|
39
|
+
|
|
40
|
+
# The argv that follows a log the way `tail -f` does, from the top.
|
|
41
|
+
def tail_argv(path) = ["tail", "-n", "+1", "-f", path]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc "tree", "Print a tree of the jobs subcommands", hide: true
|
|
45
|
+
# Redefined only to re-register tree as hidden in this namespace.
|
|
46
|
+
def tree = super # rubocop:disable Lint/UselessMethodDefinition
|
|
47
|
+
|
|
48
|
+
no_commands do
|
|
49
|
+
def jobs_root = @jobs_root ||= Asgard.project_root
|
|
50
|
+
|
|
51
|
+
def jobs_project = @jobs_project ||= Schedule.project_slug(jobs_root)
|
|
52
|
+
|
|
53
|
+
def state = @state ||= State.new(project: jobs_project, root: jobs_root)
|
|
54
|
+
|
|
55
|
+
# True when a command should cover every project: asked for with
|
|
56
|
+
# --all, or there is no .loki here, so no single project to scope to.
|
|
57
|
+
def every_project? = options[:all] || Asgard.find_task_file.nil?
|
|
58
|
+
|
|
59
|
+
# Every project's state, keyed by "Project: slug (/root)".
|
|
60
|
+
def all_states
|
|
61
|
+
State.all.to_h do |slug, root|
|
|
62
|
+
title = ["Project: #{slug} (#{root})", ("[root missing]" unless File.directory?(root))].compact.join(" ")
|
|
63
|
+
[title, State.new(project: slug, root:)]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def record_for!(name) = state.read_record(name) || abort("jobs: no such job: #{name} (see `asgard jobs list`)")
|
|
68
|
+
|
|
69
|
+
def stopper = Stopper.new(timeout: options[:timeout] || Stopper::TIMEOUT)
|
|
70
|
+
|
|
71
|
+
# The records `stop` ends, newest start first, and the names that
|
|
72
|
+
# matched nothing. A task name reaches the jobs of every task it
|
|
73
|
+
# depends on, which needs this project's .loki loaded.
|
|
74
|
+
def stop_targets(names)
|
|
75
|
+
records, unknown =
|
|
76
|
+
if every_project? then [all_states.values.flat_map(&:records), []]
|
|
77
|
+
elsif names.empty? then [state.records, []]
|
|
78
|
+
else Jobs.resolve(names, state.records, closure: ->(task) { Jobs.closure(task, Tasks._deps) })
|
|
79
|
+
end
|
|
80
|
+
[records.sort_by { |record| [record[:started].to_s, record[:name].to_s] }.reverse, unknown]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def no_jobs_message
|
|
84
|
+
return "No jobs have been started on this machine." if every_project?
|
|
85
|
+
|
|
86
|
+
"No jobs recorded for #{jobs_project}. Start one with `sh \"...\", background: true` inside a task.\n" \
|
|
87
|
+
"Use `asgard jobs list --all` to see every project's jobs."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
desc "list", "Show this project's jobs: running, exited with a status, or gone (--all: every project's)"
|
|
92
|
+
method_option :all, aliases: "-a", type: :boolean, desc: "List every project's jobs, not just this one's"
|
|
93
|
+
def list
|
|
94
|
+
states = every_project? ? all_states : { nil => state }
|
|
95
|
+
Commands.skipped_lines(states.values).each { |line| $stderr.puts line }
|
|
96
|
+
groups = states.transform_values { |each_state| Commands.rows(each_state) }.reject { |_, rows| rows.empty? }
|
|
97
|
+
return puts(no_jobs_message) if groups.empty?
|
|
98
|
+
|
|
99
|
+
puts groups.map { |title, rows| Schedule::Table.draw(rows, title:, headers: HEADERS, keys: KEYS, flexible: FLEXIBLE) }.join("\n\n")
|
|
100
|
+
puts "\nState: #{State.base_dir}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc "stop [NAME...]", "Stop jobs by job name or by task (and the tasks it depends on); no name stops every job of this project"
|
|
104
|
+
method_option :all, aliases: "-a", type: :boolean, desc: "Stop every project's jobs"
|
|
105
|
+
method_option :timeout, aliases: "-t", type: :numeric, desc: "Seconds to wait after TERM before KILL (default #{Stopper::TIMEOUT})"
|
|
106
|
+
def stop(*names)
|
|
107
|
+
abort "jobs: stop --all takes no job names; name the jobs without --all" if options[:all] && names.any?
|
|
108
|
+
records, unmatched = stop_targets(names)
|
|
109
|
+
idle, unknown = unmatched.partition { |name| Tasks.all_commands.key?(name) }
|
|
110
|
+
idle.each { |name| puts "#{name} has no running jobs" }
|
|
111
|
+
unknown.each { |name| $stderr.puts "jobs: no such job or task: #{name}" }
|
|
112
|
+
puts "No jobs recorded for #{jobs_project}." if records.empty? && unmatched.empty?
|
|
113
|
+
outcomes = records.map { |record| stopper.stop_and_report(record) }
|
|
114
|
+
exit 1 if unknown.any? || outcomes.include?(:killed)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
desc "log NAME", "Print one job's log (stdout and stderr of its latest run; each start empties it)"
|
|
118
|
+
method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -f)"
|
|
119
|
+
def log(name)
|
|
120
|
+
path = record_for!(name)[:log]
|
|
121
|
+
return $stderr.puts "#{name} has no log yet (#{path})." unless File.exist?(path)
|
|
122
|
+
return exec(*Commands.tail_argv(path)) if options[:follow]
|
|
123
|
+
|
|
124
|
+
IO.copy_stream(path, $stdout)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
desc "restart NAME", "Stop the job, then run its owning task again"
|
|
128
|
+
method_option :timeout, aliases: "-t", type: :numeric, desc: "Seconds to wait after TERM before KILL (default #{Stopper::TIMEOUT})"
|
|
129
|
+
def restart(name)
|
|
130
|
+
record = record_for!(name)
|
|
131
|
+
task = record[:task].to_s
|
|
132
|
+
abort "jobs: #{name} was not started from a task, so there is nothing to run again" if task.empty?
|
|
133
|
+
stopper.stop_and_report(record)
|
|
134
|
+
Tasks._reset_ran!
|
|
135
|
+
Tasks.start([task])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
desc "clean", "Forget every job that is no longer running: its record, script, status and log"
|
|
139
|
+
# Each removal re-checks under the job's start lock (see
|
|
140
|
+
# State#forget_unless_active), so a start that ran meanwhile wins.
|
|
141
|
+
def clean
|
|
142
|
+
invalid = state.invalid_records.map { |path| [File.basename(path, ".json"), " (not a job record)"] }
|
|
143
|
+
gone = state.records.reject { |record| state.active?(record) }.map { |record| [record[:name], ""] }
|
|
144
|
+
removed = (invalid + gone).select { |name, _| state.forget_unless_active(name) }
|
|
145
|
+
return puts("Nothing to clean for #{jobs_project}.") if removed.empty?
|
|
146
|
+
|
|
147
|
+
removed.each { |name, note| puts "removed #{name}#{note}" }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
# rubocop:enable Style/StderrPuts
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Asgard
|
|
6
|
+
# Background jobs: a process a task starts on purpose, by name, that
|
|
7
|
+
# outlives asgard and that a later `asgard jobs` can list and stop.
|
|
8
|
+
# `sh "mkdocs serve", background: true` starts one; see
|
|
9
|
+
# Jobs::Launcher for how, Jobs::State for what is kept on disk and
|
|
10
|
+
# Jobs::Commands for the management commands. This file holds the pure
|
|
11
|
+
# rules: naming, records, name resolution and status.
|
|
12
|
+
module Jobs
|
|
13
|
+
# A job name a user may choose: letters, digits and underscore, so it
|
|
14
|
+
# is safe as a file name and can never look like an auto-generated
|
|
15
|
+
# "<task>-<N>" name.
|
|
16
|
+
NAME = /\A\w+\z/
|
|
17
|
+
|
|
18
|
+
# One started job, as `sh ..., background:` returns it.
|
|
19
|
+
Job = Data.define(:name, :task, :pid, :log)
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# The job name a `background:` value asks for, or nil for the
|
|
24
|
+
# foreground. +task+ is the task whose body made the call and
|
|
25
|
+
# +sequence+ its next job number, consulted only for `true`: a named
|
|
26
|
+
# job never consumes a number, so adding or removing one never
|
|
27
|
+
# renumbers the rest.
|
|
28
|
+
def job_name(background, task:, sequence: nil)
|
|
29
|
+
case background
|
|
30
|
+
when nil, false then nil
|
|
31
|
+
when true then sequenced_name(task, sequence)
|
|
32
|
+
when Symbol, String then chosen_name(background)
|
|
33
|
+
else raise ArgumentError, "background: must be true, a Symbol or a String, not #{background.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# How a task name's characters outside \w are spelled in its job name,
|
|
38
|
+
# which keeps to [\w-] because a record's file name must for `jobs
|
|
39
|
+
# list` to find it. Every spelling starts with "-" and none with
|
|
40
|
+
# "-<digit>", so no two task names share a job name and none reads as
|
|
41
|
+
# the "-<N>" counter: ready? is ready-q-1, never the ready_-1 of ready_.
|
|
42
|
+
NAME_ESCAPES = { "?" => "-q", "!" => "-b", "=" => "-e", "-" => "--" }.freeze
|
|
43
|
+
|
|
44
|
+
def sequenced_name(task, sequence)
|
|
45
|
+
raise ArgumentError, "sh background: true must be called from a task body" unless task
|
|
46
|
+
|
|
47
|
+
:"#{escape_task(task)}-#{sequence}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Any other character (a non-ASCII letter) is "-u" and six hex digits.
|
|
51
|
+
def escape_task(task) = task.to_s.gsub(/\W/) { |char| NAME_ESCAPES.fetch(char) { format("-u%06x", char.ord) } }
|
|
52
|
+
|
|
53
|
+
def chosen_name(value)
|
|
54
|
+
name = value.to_s.strip
|
|
55
|
+
raise ArgumentError, "background: #{value.inspect} is not a job name (letters, digits and _ only)" unless name.match?(NAME)
|
|
56
|
+
|
|
57
|
+
name.to_sym
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether +record+ is one a started job wrote: a Hash with a name, a
|
|
61
|
+
# pid and a group id above 1 (kill(2) reads -1 and 0 as "everyone"
|
|
62
|
+
# and "us"), a script path to verify against and a start time. A hand
|
|
63
|
+
# edit or another tool's JSON fails this and stays out of listings.
|
|
64
|
+
def valid_record?(record)
|
|
65
|
+
return false unless record.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
name, pid, pgid, script, started = record.values_at(:name, :pid, :pgid, :script, :started)
|
|
68
|
+
text?(name) && text?(script) && process_id?(pid) && process_id?(pgid) && time?(started)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def text?(value) = value.is_a?(String) && !value.empty?
|
|
72
|
+
|
|
73
|
+
def process_id?(id) = id.is_a?(Integer) && id > 1
|
|
74
|
+
|
|
75
|
+
def time?(value)
|
|
76
|
+
value.is_a?(String) && Time.iso8601(value).is_a?(Time)
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The first non-blank line of a script, for the listing.
|
|
82
|
+
def summary(script) = script.to_s.each_line.map(&:strip).find { |line| !line.empty? }.to_s
|
|
83
|
+
|
|
84
|
+
# The record written to disk for a started job.
|
|
85
|
+
def record(name:, task:, pid:, pgid:, cwd:, script:, log:, command:, started:)
|
|
86
|
+
{ name: name.to_s, task: task.to_s, pid:, pgid:, started:, cwd:, script:, log:, command: }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# +task+ plus every task reachable through +deps+ (a `_deps` Hash of
|
|
90
|
+
# stage lists), in discovery order. `stop servers` reaches the `db-1`
|
|
91
|
+
# a dependency started.
|
|
92
|
+
def closure(task, deps)
|
|
93
|
+
seen = []
|
|
94
|
+
queue = [task.to_sym]
|
|
95
|
+
until queue.empty?
|
|
96
|
+
current = queue.shift
|
|
97
|
+
next if seen.include?(current)
|
|
98
|
+
|
|
99
|
+
seen << current
|
|
100
|
+
queue.concat(Array(deps[current]).flatten.map(&:to_sym))
|
|
101
|
+
end
|
|
102
|
+
seen
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The records +names+ pick out: an exact job name first, else every
|
|
106
|
+
# record whose task is the name or, through +closure+ (a callable from
|
|
107
|
+
# task name to task names), one it depends on. Returns [records,
|
|
108
|
+
# unknown names].
|
|
109
|
+
def resolve(names, records, closure: nil)
|
|
110
|
+
found = []
|
|
111
|
+
unknown = []
|
|
112
|
+
names.each do |name|
|
|
113
|
+
matches = matching_records(name.to_s, records, closure)
|
|
114
|
+
matches.empty? ? unknown << name.to_s : found.concat(matches)
|
|
115
|
+
end
|
|
116
|
+
[found.uniq, unknown]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def matching_records(name, records, closure)
|
|
120
|
+
exact = records.select { |record| record[:name].to_s == name }
|
|
121
|
+
return exact unless exact.empty?
|
|
122
|
+
|
|
123
|
+
tasks = (closure ? closure.call(name) : [name]).map(&:to_s)
|
|
124
|
+
records.select { |record| tasks.include?(record[:task].to_s) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# What a record's job is doing now: :running while its pid verifies,
|
|
128
|
+
# [:exited, code] once the trampoline wrote a status (with
|
|
129
|
+
# :processes_left while processes the script started remain in its
|
|
130
|
+
# group), :gone when neither (the trampoline died, or the machine
|
|
131
|
+
# rebooted).
|
|
132
|
+
def status(exit_code:, verified:, leftovers: false)
|
|
133
|
+
return [:exited, exit_code, (:processes_left if leftovers)].compact if exit_code
|
|
134
|
+
return :running if verified
|
|
135
|
+
|
|
136
|
+
:gone
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The STATUS column: "running", "exited 3 (2.5s)", "exited 0 (2.5s,
|
|
140
|
+
# processes left)" or "gone".
|
|
141
|
+
def describe_status(status, seconds: nil)
|
|
142
|
+
return status.to_s unless status.is_a?(Array)
|
|
143
|
+
|
|
144
|
+
_, code, left = status
|
|
145
|
+
notes = [(format("%.1fs", seconds) if seconds), ("processes left" if left)].compact
|
|
146
|
+
notes.empty? ? "exited #{code}" : "exited #{code} (#{notes.join(', ')})"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Asgard
|
|
7
|
+
module Jobs
|
|
8
|
+
# Starts a job: a new session with stdio detached, running the job's
|
|
9
|
+
# shell under a POSIX /bin/sh trampoline that writes the exit status
|
|
10
|
+
# to a file once nobody is left to wait on it. Everything but `launch`
|
|
11
|
+
# is pure, and `launch` is three lines.
|
|
12
|
+
module Launcher
|
|
13
|
+
# The trampoline body. The trap is load-bearing: a group TERM reaches
|
|
14
|
+
# this shell too, and an untrapped shell would die at once, before
|
|
15
|
+
# the status is written. With a handler it finishes waiting for the
|
|
16
|
+
# child, then runs on to the echo; the child still gets its own TERM
|
|
17
|
+
# with the default disposition, since handled traps reset on exec.
|
|
18
|
+
# Never `trap ''`: an ignored signal is inherited across exec. Two
|
|
19
|
+
# commands, not one, so no shell execs the body away and vanishes.
|
|
20
|
+
TRAMPOLINE = 'trap ":" TERM INT HUP; "$@"; echo $? > "$ASGARD_JOB_STATUS"'
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# /bin/sh by absolute path: never bash and never a registry entry,
|
|
25
|
+
# so the user's default_shell and the presence of bash do not
|
|
26
|
+
# matter. The status path travels in the environment, so nothing
|
|
27
|
+
# here is quoted.
|
|
28
|
+
def argv(shell_argv) = ["/bin/sh", "-c", TRAMPOLINE, "asgard-job", *shell_argv]
|
|
29
|
+
|
|
30
|
+
# The variables added to the task's environment. Python fully
|
|
31
|
+
# buffers stdout when it is a file, so a print loop would show
|
|
32
|
+
# nothing in the log until exit without PYTHONUNBUFFERED.
|
|
33
|
+
def environment(name, status_path, env = ENV)
|
|
34
|
+
added = { "ASGARD_JOB" => name.to_s, "ASGARD_JOB_STATUS" => status_path }
|
|
35
|
+
added["PYTHONUNBUFFERED"] = "1" unless env.key?("PYTHONUNBUFFERED")
|
|
36
|
+
added
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# stdin from /dev/null, stdout and stderr appended to the log, so
|
|
40
|
+
# the job never takes SIGTTIN or writes into a terminal that
|
|
41
|
+
# happens to be attached.
|
|
42
|
+
# close_others: a descriptor asgard inherited (a CI runner's, a
|
|
43
|
+
# `3>file`) has no business staying open for the job's lifetime.
|
|
44
|
+
def spawn_options(log_path) = { in: File::NULL, %i[out err] => [log_path, "a"], close_others: true }
|
|
45
|
+
|
|
46
|
+
# Forks, starts a new session (pid = pgid = sid, no controlling
|
|
47
|
+
# terminal) and execs; returns the child's pid, detached. The parent
|
|
48
|
+
# waits on a pipe whose write end the child holds until exec closes
|
|
49
|
+
# it (pipes are close-on-exec), so by the time the pid is recorded it
|
|
50
|
+
# already leads its own group, and already runs the trampoline: a
|
|
51
|
+
# `stop` issued at once signals the job, never the parent's group,
|
|
52
|
+
# and a start issued at once sees the job's own command line.
|
|
53
|
+
def launch(env, argv, options)
|
|
54
|
+
reader, writer = IO.pipe
|
|
55
|
+
pid = fork do
|
|
56
|
+
reader.close
|
|
57
|
+
Process.setsid
|
|
58
|
+
exec(env, *argv, **options)
|
|
59
|
+
rescue SystemCallError => e
|
|
60
|
+
# exit!, not raise: no at_exit handler of the parent may run here.
|
|
61
|
+
$stderr.puts "asgard: could not start job: #{e.message}" # rubocop:disable Style/StderrPuts
|
|
62
|
+
$stderr.flush # exit! skips Ruby's own flush
|
|
63
|
+
exit!(127)
|
|
64
|
+
end
|
|
65
|
+
writer.close
|
|
66
|
+
reader.read
|
|
67
|
+
reader.close
|
|
68
|
+
Process.detach(pid)
|
|
69
|
+
pid
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Starts +run+ as the job +name+ owned by +task+, or returns the job
|
|
73
|
+
# already running under that name. A missing shell exits 127 up
|
|
74
|
+
# front, as foreground `sh` does; the trampoline would otherwise
|
|
75
|
+
# hide it as a status nobody is watching for. A ps that cannot
|
|
76
|
+
# verify jobs raises Pid::Unusable first: a job nothing could find
|
|
77
|
+
# again would be started twice.
|
|
78
|
+
def start(run, name:, task:, state:, silent: false, out: $stdout)
|
|
79
|
+
Pid.require_ps!
|
|
80
|
+
state.with_lock(name) do
|
|
81
|
+
existing = state.read_record(name)
|
|
82
|
+
return already_running(existing, out:) if existing && state.verified?(existing)
|
|
83
|
+
|
|
84
|
+
run.missing! unless Shell.resolve(run.shell[:path])
|
|
85
|
+
job = spawn_job(run, name:, task:, state:)
|
|
86
|
+
out.puts "started #{job.name} (pid #{job.pid}), log: #{job.log}" unless silent
|
|
87
|
+
job
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def already_running(record, out:)
|
|
92
|
+
out.puts "#{record[:name]} already running (pid #{record[:pid]}, started by #{record[:task]}), log: #{record[:log]}"
|
|
93
|
+
Job.new(name: record[:name].to_sym, task: record[:task].to_sym, pid: record[:pid], log: record[:log])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def spawn_job(run, name:, task:, state:)
|
|
97
|
+
state.prepare.remove(name)
|
|
98
|
+
script = state.script_path(name, run.ext)
|
|
99
|
+
log = state.log_path(name)
|
|
100
|
+
File.write(script, "#{run.script}\n")
|
|
101
|
+
FileUtils.touch(log) # so `jobs log` and a reader never race the child's open
|
|
102
|
+
pid = launch(environment(name, state.status_path(name)), argv(run.argv(script)), spawn_options(log))
|
|
103
|
+
state.write_record(Jobs.record(name:, task:, pid:, pgid: pid, cwd: Dir.pwd, script:, log:,
|
|
104
|
+
command: Jobs.summary(run.script), started: Time.now.iso8601(6)))
|
|
105
|
+
Job.new(name: name.to_sym, task: task&.to_sym, pid:, log:)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../pid"
|
|
5
|
+
require_relative "../state_dir"
|
|
6
|
+
|
|
7
|
+
module Asgard
|
|
8
|
+
module Jobs
|
|
9
|
+
# Where one project's jobs keep what outlives the asgard run that
|
|
10
|
+
# started them, under $XDG_STATE_HOME/asgard/jobs/<project-slug>/:
|
|
11
|
+
# per job name a JSON record, the script as handed to the shell, the
|
|
12
|
+
# log, and the exit status the trampoline writes when the job ends.
|
|
13
|
+
# Everything stays until the next start of that name or `jobs clean`,
|
|
14
|
+
# so the log of a crashed job can still be read.
|
|
15
|
+
class State
|
|
16
|
+
# A job's record: a chosen name (\w+) or a sequenced "<task>-<N>".
|
|
17
|
+
# Dotfiles never match, which is how a half-written tempfile stays out.
|
|
18
|
+
RECORD = /\A[\w-]+\.json\z/
|
|
19
|
+
|
|
20
|
+
attr_reader :dir, :root
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def base_dir(home: Dir.home, env: ENV) = StateDir.base_dir("jobs", home:, env:)
|
|
24
|
+
|
|
25
|
+
# Every project with job state under base, as [slug, root] pairs.
|
|
26
|
+
def all(base: base_dir) = StateDir.all(base:)
|
|
27
|
+
|
|
28
|
+
# Whether a record's job is still the process we started: its pid
|
|
29
|
+
# is alive and runs the trampoline whose command line carries the
|
|
30
|
+
# script path. A reused pid after a reboot fails this, and so does
|
|
31
|
+
# one that is now asgard itself, and so does a record that is not
|
|
32
|
+
# a job's (Jobs.valid_record?). A ps that cannot tell raises
|
|
33
|
+
# Pid::Unusable rather than calling every job gone.
|
|
34
|
+
def verified?(record)
|
|
35
|
+
return false unless Jobs.valid_record?(record)
|
|
36
|
+
|
|
37
|
+
Pid.require_ps!
|
|
38
|
+
Pid.matches?(record[:pid], record[:script], self_ok: false) || survives?(record)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Whether the job outlived its trampoline: some process in its group
|
|
42
|
+
# still runs the script. A group id is never reused while the group
|
|
43
|
+
# has members, so a match is still the job we started.
|
|
44
|
+
def survives?(record, ps: Pid.method(:group_command_lines))
|
|
45
|
+
pgid = record[:pgid]
|
|
46
|
+
script = record[:script].to_s
|
|
47
|
+
return false unless job_group?(pgid) && !script.empty?
|
|
48
|
+
|
|
49
|
+
ps.call(pgid).any? { |command| command.include?(script) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether +pgid+ could be a job's group at all: a group id above 1
|
|
53
|
+
# that is not asgard's own. Nothing else is ever looked up or
|
|
54
|
+
# signalled.
|
|
55
|
+
def job_group?(pgid) = Jobs.process_id?(pgid) && pgid != Process.getpgrp
|
|
56
|
+
|
|
57
|
+
# Whether the job still owns processes: it is running, or its
|
|
58
|
+
# script exited and left processes in its group (`sleep 60 &`).
|
|
59
|
+
# Those are what `stop` signals and why `clean` keeps the record.
|
|
60
|
+
def active?(record) = verified?(record) || (Jobs.valid_record?(record) && leftovers?(record, exited_at: exited_at(record)))
|
|
61
|
+
|
|
62
|
+
# Whether group +pgid+ still holds a process that started before
|
|
63
|
+
# the trampoline exited at +exited_at+. The id cannot be reused
|
|
64
|
+
# while the group has members, but once they are gone a later
|
|
65
|
+
# group may take it, and every member of such a group is younger
|
|
66
|
+
# than our exit; one second of slack covers ps's resolution.
|
|
67
|
+
def leftovers?(record, exited_at:, ps: Pid.method(:group_processes))
|
|
68
|
+
pgid = record[:pgid]
|
|
69
|
+
return false unless exited_at && job_group?(pgid)
|
|
70
|
+
|
|
71
|
+
ps.call(pgid).any? { |member| member.started <= exited_at + 1 }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The status file of a record's job, beside its script.
|
|
75
|
+
def status_file(record) = File.join(File.dirname(record[:script]), "#{record[:name]}.status")
|
|
76
|
+
|
|
77
|
+
# When the trampoline wrote the job's status, or nil.
|
|
78
|
+
def exited_at(record) = mtime_of(status_file(record))
|
|
79
|
+
|
|
80
|
+
# When +path+ was last written, or nil when it does not exist.
|
|
81
|
+
def mtime_of(path) = File.exist?(path) ? File.mtime(path) : nil
|
|
82
|
+
|
|
83
|
+
# Writes 137 (128 + KILL) as the job's status unless one is
|
|
84
|
+
# written: a group KILL takes the trampoline with it, so nothing
|
|
85
|
+
# else records how the job ended. A record that is not a job's
|
|
86
|
+
# writes nothing.
|
|
87
|
+
def record_kill(record)
|
|
88
|
+
return unless Jobs.valid_record?(record)
|
|
89
|
+
|
|
90
|
+
File.open(status_file(record), "wx", 0o600) { |file| file.puts(128 + 9) }
|
|
91
|
+
rescue Errno::EEXIST, Errno::ENOENT
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def initialize(project:, root:, base: State.base_dir)
|
|
97
|
+
@root = root
|
|
98
|
+
@dir = File.join(base, Schedule.slug(project))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def record_path(name) = File.join(dir, "#{name}.json")
|
|
102
|
+
def script_path(name, ext) = File.join(dir, "#{name}#{ext}")
|
|
103
|
+
def log_path(name) = File.join(dir, "#{name}.log")
|
|
104
|
+
def status_path(name) = File.join(dir, "#{name}.status")
|
|
105
|
+
def lock_path(name) = File.join(dir, ".#{name}.lock")
|
|
106
|
+
|
|
107
|
+
# Runs the block holding an exclusive flock for +name+, so two starts
|
|
108
|
+
# of one name (two terminals, two parallel dependencies) take turns:
|
|
109
|
+
# the second sees the first's record and finds the job running. The
|
|
110
|
+
# lock file is a dotfile, so RECORD and `remove` never see it.
|
|
111
|
+
def with_lock(name)
|
|
112
|
+
FileUtils.mkdir_p(dir)
|
|
113
|
+
File.open(lock_path(name), File::RDWR | File::CREAT, 0o600) do |file|
|
|
114
|
+
file.flock(File::LOCK_EX)
|
|
115
|
+
yield
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Creates the directory and records the root.
|
|
120
|
+
def prepare
|
|
121
|
+
StateDir.record_root(dir, root)
|
|
122
|
+
self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def write_record(record) = StateDir.write_json(record_path(record[:name]), record)
|
|
126
|
+
|
|
127
|
+
# The record for +name+, or nil when there is none or it is not a
|
|
128
|
+
# job's record (Jobs.valid_record?).
|
|
129
|
+
def read_record(name) = StateDir.read_json(record_path(name)).then { Jobs.valid_record?(it) ? it : nil }
|
|
130
|
+
|
|
131
|
+
# Every job's record, oldest start first. Only "<name>.json" counts,
|
|
132
|
+
# never the dot-prefixed tempfile write_json is still filling, and a
|
|
133
|
+
# file that is not a job's record is left out (see invalid_records).
|
|
134
|
+
def records
|
|
135
|
+
record_files.filter_map { |path| StateDir.read_json(path) }.select { |record| Jobs.valid_record?(record) }
|
|
136
|
+
.sort_by { |record| [record[:started], record[:name]] }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The record files that are not a job's record: unparsable JSON, a
|
|
140
|
+
# hand edit, another tool's file.
|
|
141
|
+
def invalid_records = record_files.reject { |path| Jobs.valid_record?(StateDir.read_json(path)) }
|
|
142
|
+
|
|
143
|
+
def record_files
|
|
144
|
+
Dir.children(dir).grep(RECORD).sort.map { |file| File.join(dir, file) }
|
|
145
|
+
rescue Errno::ENOENT
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def verified?(record) = State.verified?(record)
|
|
150
|
+
|
|
151
|
+
def active?(record) = State.active?(record)
|
|
152
|
+
|
|
153
|
+
# Forgets +name+ (record, script, status and log) unless its job
|
|
154
|
+
# still owns processes; true when it did. Holds the start lock and
|
|
155
|
+
# reads the record again inside it, so a start that ran meanwhile
|
|
156
|
+
# keeps the fresh record it wrote. A record that is not a job's is
|
|
157
|
+
# forgotten too: it cannot be running.
|
|
158
|
+
def forget_unless_active(name)
|
|
159
|
+
with_lock(name) do
|
|
160
|
+
next false unless File.exist?(record_path(name))
|
|
161
|
+
|
|
162
|
+
record = read_record(name)
|
|
163
|
+
next false if record && active?(record)
|
|
164
|
+
|
|
165
|
+
remove(name, keep_log: false)
|
|
166
|
+
true
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The exit code the trampoline wrote, or nil while none is written.
|
|
171
|
+
def exit_code(name)
|
|
172
|
+
path = status_path(name)
|
|
173
|
+
Integer(File.read(path).strip, exception: false) if File.exist?(path)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# When the status was written, or nil.
|
|
177
|
+
def exit_time(name) = State.mtime_of(status_path(name))
|
|
178
|
+
|
|
179
|
+
# Forgets a job: its record, script and status go; the log is kept
|
|
180
|
+
# but emptied, so a restart appends to a fresh file, unless
|
|
181
|
+
# keep_log: false deletes it too (`jobs clean`).
|
|
182
|
+
def remove(name, keep_log: true)
|
|
183
|
+
log = log_path(name)
|
|
184
|
+
files = Dir.exist?(dir) ? Dir.children(dir).select { |file| file.start_with?("#{name}.") }.map { |file| File.join(dir, file) } : []
|
|
185
|
+
FileUtils.rm_f(keep_log ? files - [log] : files)
|
|
186
|
+
File.truncate(log, 0) if keep_log && File.exist?(log)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|