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
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.exist?
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.subclasses.dup
86
- load @marker
87
- @new_subclasses = (Asgard::Base.subclasses - before + [Tasks]).uniq
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