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/watch/state.rb
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
-
|
|
5
|
-
require "tempfile"
|
|
4
|
+
require_relative "../state_dir"
|
|
6
5
|
|
|
7
6
|
module Asgard
|
|
8
7
|
module Watch
|
|
@@ -13,41 +12,61 @@ module Asgard
|
|
|
13
12
|
# default ~/.local/state), with a file naming the project root so
|
|
14
13
|
# `watcher list --all` can find every project without loading it.
|
|
15
14
|
class State
|
|
16
|
-
ROOT_FILE =
|
|
15
|
+
ROOT_FILE = StateDir::ROOT_FILE
|
|
16
|
+
|
|
17
|
+
# Names the inherited descriptor of the held watcher.lock, from
|
|
18
|
+
# `start` to the daemon it spawns and across a reload's exec.
|
|
19
|
+
LOCK_ENV = "ASGARD_WATCH_LOCK_FD"
|
|
20
|
+
|
|
21
|
+
# Names the file of changes a reloading daemon saw but did not run,
|
|
22
|
+
# for the daemon its exec starts to run instead.
|
|
23
|
+
PENDING_ENV = "ASGARD_WATCH_PENDING"
|
|
17
24
|
|
|
18
25
|
attr_reader :dir, :root
|
|
19
26
|
|
|
20
27
|
class << self
|
|
21
|
-
def base_dir(home: Dir.home, env: ENV)
|
|
22
|
-
File.join(env["XDG_STATE_HOME"] || File.join(home, ".local", "state"), "asgard", "watch")
|
|
23
|
-
end
|
|
28
|
+
def base_dir(home: Dir.home, env: ENV) = StateDir.base_dir("watch", home:, env:)
|
|
24
29
|
|
|
25
30
|
# Every project with a state directory under base, as [slug, root] pairs.
|
|
26
|
-
def all(base: base_dir)
|
|
27
|
-
Dir.glob(File.join(base, "*", ROOT_FILE)).map do |file|
|
|
28
|
-
[File.basename(File.dirname(file)), File.read(file).strip]
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
+
def all(base: base_dir) = StateDir.all(base:)
|
|
31
32
|
|
|
32
33
|
# What `list` needs of a declaration, with the events Set as an Array.
|
|
33
34
|
def declaration_record(spec) = spec.slice(:name, :task, :paths).merge(events: spec[:events].to_a)
|
|
34
35
|
|
|
35
36
|
# Written whole then renamed, so a reader never sees a partial file.
|
|
36
|
-
def write_json(path, value)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
def write_json(path, value) = StateDir.write_json(path, value)
|
|
38
|
+
|
|
39
|
+
def read_json(path) = StateDir.read_json(path)
|
|
40
|
+
|
|
41
|
+
# The pending triggers a reload left at +path+ ([{ entry:, changes: }]),
|
|
42
|
+
# removing the file so they run once; [] for no path or no file.
|
|
43
|
+
def take_pending(path)
|
|
44
|
+
return [] unless path
|
|
45
|
+
|
|
46
|
+
pending = read_json(path) || []
|
|
47
|
+
FileUtils.rm_f(path)
|
|
48
|
+
pending
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
# Whether pid is a watcher daemon, not merely a live process.
|
|
52
|
+
def daemon?(pid) = Reporter.asgard_process?(pid, "watcher", "run")
|
|
47
53
|
|
|
48
54
|
# Whether an entry record describes a task still running: no exit
|
|
49
|
-
# recorded and its pid
|
|
50
|
-
|
|
55
|
+
# recorded and its pid still that task's asgard process. The
|
|
56
|
+
# current process always counts, as for asgard_process?.
|
|
57
|
+
def running?(entry)
|
|
58
|
+
return false if entry.key?(:finished)
|
|
59
|
+
|
|
60
|
+
pid = entry[:pid]
|
|
61
|
+
pid == Process.pid || task_process?(entry[:task].to_s, command: Pid.command_line(pid))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether +command+ (a command line, or nil for no process) runs
|
|
65
|
+
# asgard with +task+ as a whole word: "t" is not "test_check".
|
|
66
|
+
def task_process?(task, command:)
|
|
67
|
+
words = command.to_s.split
|
|
68
|
+
words.any? { |word| File.basename(word) == "asgard" } && words.include?(task)
|
|
69
|
+
end
|
|
51
70
|
end
|
|
52
71
|
|
|
53
72
|
# base: is the directory every project's state lives under.
|
|
@@ -57,33 +76,74 @@ module Asgard
|
|
|
57
76
|
end
|
|
58
77
|
|
|
59
78
|
def pid_path = File.join(dir, "watcher.pid")
|
|
79
|
+
def lock_path = File.join(dir, "watcher.lock")
|
|
60
80
|
def log_path = File.join(dir, "watcher.log")
|
|
61
81
|
def root_path = File.join(dir, ROOT_FILE)
|
|
62
82
|
def declarations_path = File.join(dir, "declarations.json")
|
|
83
|
+
def pending_path = File.join(dir, "pending.json")
|
|
63
84
|
def entries_dir = File.join(dir, "entries")
|
|
64
85
|
def entry_path(name) = File.join(entries_dir, "#{name}.json")
|
|
65
86
|
|
|
66
87
|
# Creates the directories and records the root.
|
|
67
88
|
def prepare
|
|
89
|
+
StateDir.record_root(dir, root)
|
|
68
90
|
FileUtils.mkdir_p(entries_dir)
|
|
69
|
-
File.write(root_path, "#{root}\n")
|
|
70
91
|
self
|
|
71
92
|
end
|
|
72
93
|
|
|
94
|
+
# One watcher per project: whoever holds an exclusive flock on
|
|
95
|
+
# watcher.lock. The open File while held, nil when another process
|
|
96
|
+
# holds it. The lock ends when the last descriptor of this open
|
|
97
|
+
# closes, so a daemon that inherits it keeps it for its lifetime.
|
|
98
|
+
# The descriptor is close-on-exec: only `start` and a reload hand it
|
|
99
|
+
# on, by name (see LOCK_ENV); a task the daemon spawns, and anything
|
|
100
|
+
# that task leaves running, never holds it.
|
|
101
|
+
def acquire_lock
|
|
102
|
+
FileUtils.mkdir_p(dir)
|
|
103
|
+
file = File.open(lock_path, File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen -- held, not read
|
|
104
|
+
file.close_on_exec = true
|
|
105
|
+
return file if file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
106
|
+
|
|
107
|
+
file.close
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The lock a parent handed down (see LOCK_ENV), or nil when none was
|
|
112
|
+
# or the descriptor is not this project's lock file. Handing it down
|
|
113
|
+
# cleared close-on-exec, so it is set again here: otherwise every
|
|
114
|
+
# task spawned would inherit the lock and could outlive the daemon
|
|
115
|
+
# holding it.
|
|
116
|
+
def adopt_lock(env = ENV)
|
|
117
|
+
fd = Integer(env[LOCK_ENV].to_s, exception: false) or return nil
|
|
118
|
+
io = File.for_fd(fd, autoclose: false)
|
|
119
|
+
return nil unless File.exist?(lock_path) && File.identical?(io, lock_path) && io.flock(File::LOCK_EX | File::LOCK_NB)
|
|
120
|
+
|
|
121
|
+
io.autoclose = true
|
|
122
|
+
io.close_on_exec = true
|
|
123
|
+
io
|
|
124
|
+
rescue Errno::EBADF, ArgumentError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The inherited lock, else a fresh one; nil when another holds it.
|
|
129
|
+
def lock(env = ENV) = adopt_lock(env) || acquire_lock
|
|
130
|
+
|
|
73
131
|
def write_pid(pid) = File.write(pid_path, "#{pid}\n")
|
|
74
132
|
|
|
75
133
|
def read_pid = File.exist?(pid_path) ? File.read(pid_path).to_i : nil
|
|
76
134
|
|
|
77
135
|
# The daemon's pid when one is running, else nil. A pidfile left by a
|
|
78
|
-
# crash counts as not running
|
|
136
|
+
# crash counts as not running, even when another process has since
|
|
137
|
+
# been given its pid.
|
|
79
138
|
def running_pid
|
|
80
139
|
pid = read_pid
|
|
81
|
-
pid if pid&.positive? &&
|
|
140
|
+
pid if pid&.positive? && State.daemon?(pid)
|
|
82
141
|
end
|
|
83
142
|
|
|
84
143
|
def clear = FileUtils.rm_f(pid_path)
|
|
85
144
|
|
|
86
|
-
# The declarations
|
|
145
|
+
# The declarations of the running daemon (written by every `watcher
|
|
146
|
+
# run`, a reload included), trimmed to what `list` shows.
|
|
87
147
|
def write_declarations(specs)
|
|
88
148
|
State.write_json(declarations_path, specs.map { |spec| State.declaration_record(spec) })
|
|
89
149
|
end
|
|
@@ -95,7 +155,13 @@ module Asgard
|
|
|
95
155
|
def read_entry(name) = State.read_json(entry_path(name))
|
|
96
156
|
|
|
97
157
|
# Every entry's latest record, keyed by entry name.
|
|
98
|
-
|
|
158
|
+
# A corrupt record is left out (see StateDir.read_json).
|
|
159
|
+
def entries
|
|
160
|
+
Dir.glob(File.join(entries_dir, "*.json")).filter_map do |file|
|
|
161
|
+
record = State.read_json(file)
|
|
162
|
+
[File.basename(file, ".json"), record] if record
|
|
163
|
+
end.to_h
|
|
164
|
+
end
|
|
99
165
|
|
|
100
166
|
# The records of tasks still running.
|
|
101
167
|
def running_entries = entries.values.select { |entry| State.running?(entry) }
|
data/lib/asgard/watch/worker.rb
CHANGED
|
@@ -17,10 +17,14 @@ module Asgard
|
|
|
17
17
|
# The child's pid. The task runs in the project root with exactly
|
|
18
18
|
# the description's environment, in its own process group, with
|
|
19
19
|
# its output appended to the log.
|
|
20
|
-
def spawn(description)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
def spawn(description) = Process.spawn(description[:env], *description[:argv], **spawn_options(description))
|
|
21
|
+
|
|
22
|
+
# stdin is /dev/null: the task's group is a background group on any
|
|
23
|
+
# terminal the daemon has, where a read would stop it with SIGTTIN
|
|
24
|
+
# and leave its worker waiting forever.
|
|
25
|
+
def spawn_options(description)
|
|
26
|
+
{ chdir: description[:root], pgroup: true, unsetenv_others: true, in: File::NULL,
|
|
27
|
+
out: [description[:log], "a"], err: %i[child out] }
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
# Blocks until the child exits; its exit code.
|
|
@@ -30,13 +34,17 @@ module Asgard
|
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
# A signalled child reports 128 + the signal number, as shells do.
|
|
33
|
-
def exit_code(status) =
|
|
37
|
+
def exit_code(status) = Shell.exit_code(status)
|
|
34
38
|
end
|
|
35
39
|
|
|
36
|
-
# The worker loop in its own Ractor. The Ractor is handed
|
|
37
|
-
#
|
|
40
|
+
# The worker loop in its own Ractor. The Ractor is handed its queue,
|
|
41
|
+
# the reports and the spawner (all shareable) and nothing else, and
|
|
42
|
+
# pops through a Mailbox so it sleeps while idle: whoever pushes onto
|
|
43
|
+
# +queue+ must wake it, through Mailbox.new(queue, the_ractor).
|
|
38
44
|
def self.start(queue, reports, spawner: Spawner)
|
|
39
|
-
Ractor.new(queue, reports, spawner)
|
|
45
|
+
Ractor.new(queue, reports, spawner) do |inbox, outbox, runner|
|
|
46
|
+
Asgard::Watch::Worker.new(Asgard::Watch::Mailbox.new(inbox, Ractor.current), outbox, spawner: runner).run
|
|
47
|
+
end
|
|
40
48
|
end
|
|
41
49
|
|
|
42
50
|
def initialize(queue, reports, spawner: Spawner, clock: Process)
|
|
@@ -54,18 +62,45 @@ module Asgard
|
|
|
54
62
|
end
|
|
55
63
|
end
|
|
56
64
|
|
|
65
|
+
# One trigger. A spawn that fails (the root or the executable gone,
|
|
66
|
+
# an environment too large or malformed) is reported as an exit with
|
|
67
|
+
# no pid, so the worker carries on with the next trigger instead of
|
|
68
|
+
# ending.
|
|
57
69
|
def run_one(description)
|
|
58
70
|
entry, task = description.values_at(:entry, :task)
|
|
59
|
-
pid =
|
|
71
|
+
pid = spawn_or_report(description) or return
|
|
60
72
|
started_at, started = stamp
|
|
61
73
|
report(kind: :spawn, entry:, task:, pid:, at: started_at, changes: description[:changes])
|
|
62
74
|
status = @spawner.wait(pid)
|
|
63
75
|
finished_at, finished = stamp
|
|
64
76
|
report(kind: :exit, entry:, task:, pid:, at: finished_at, status:, seconds: finished - started)
|
|
77
|
+
ensure
|
|
78
|
+
Worker.remove_changes_file(description[:changes_file])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The exit code a spawn failure reports, as a shell would: 126 when
|
|
82
|
+
# the system could not run it, 127 when something was not found or
|
|
83
|
+
# spawn refused its arguments (an ArgumentError: a bad env: name).
|
|
84
|
+
def self.spawn_failure_status(error) = error.is_a?(SystemCallError) && !error.is_a?(Errno::ENOENT) ? 126 : 127
|
|
85
|
+
|
|
86
|
+
# The task has exited, so the change lists spilled for it are done.
|
|
87
|
+
def self.remove_changes_file(path)
|
|
88
|
+
File.delete(path) if path
|
|
89
|
+
rescue Errno::ENOENT
|
|
90
|
+
nil
|
|
65
91
|
end
|
|
66
92
|
|
|
67
93
|
private
|
|
68
94
|
|
|
95
|
+
def spawn_or_report(description)
|
|
96
|
+
@spawner.spawn(description)
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
at, = stamp
|
|
99
|
+
report(kind: :exit, entry: description[:entry], task: description[:task], pid: nil, at:,
|
|
100
|
+
status: Worker.spawn_failure_status(e), seconds: 0.0, error: e.message)
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
69
104
|
# [wall-clock seconds since the epoch, monotonic seconds]
|
|
70
105
|
def stamp = [@clock.clock_gettime(Process::CLOCK_REALTIME), @clock.clock_gettime(Process::CLOCK_MONOTONIC)]
|
|
71
106
|
|
data/lib/asgard/watch.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "declarations"
|
|
3
4
|
require_relative "watch/declaration"
|
|
4
5
|
require_relative "watch/changes"
|
|
5
6
|
require_relative "watch/spawn"
|
|
7
|
+
require_relative "watch/mailbox"
|
|
6
8
|
require_relative "watch/worker"
|
|
7
9
|
require_relative "watch/reporter"
|
|
8
10
|
require_relative "watch/dispatcher"
|
|
@@ -30,17 +32,8 @@ module Asgard
|
|
|
30
32
|
@declarations ||= {}
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
# Records one declaration
|
|
34
|
-
|
|
35
|
-
# is an error.
|
|
36
|
-
def declare(task, **settings)
|
|
37
|
-
spec = normalize(task, **settings)
|
|
38
|
-
name = spec[:name]
|
|
39
|
-
taken = declarations[name]
|
|
40
|
-
raise ArgumentError, "watch: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
|
|
41
|
-
|
|
42
|
-
declarations[name] = spec
|
|
43
|
-
end
|
|
35
|
+
# Records one declaration (see Declarations.record).
|
|
36
|
+
def declare(task, **) = Declarations.record(declarations, normalize(task, **), "watch")
|
|
44
37
|
end
|
|
45
38
|
|
|
46
39
|
# The class-level `watch` declaration helper for Tasks.
|
data/lib/asgard.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "asgard/errors"
|
|
4
4
|
require_relative "asgard/version"
|
|
5
|
+
require_relative "asgard/leading_flag"
|
|
5
6
|
require_relative "asgard/kernel_methods"
|
|
6
7
|
require_relative "asgard/shell"
|
|
7
8
|
require_relative "asgard/base"
|
|
@@ -15,29 +16,92 @@ module Asgard
|
|
|
15
16
|
loki_up
|
|
16
17
|
end
|
|
17
18
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
|
|
19
|
+
# The directory of the nearest .loki, or the current directory without
|
|
20
|
+
# one: where the watcher and background jobs file their state. Fixed
|
|
21
|
+
# for the length of a run! (see pin_project_root), so a task that
|
|
22
|
+
# changes directory first still files its jobs under this project.
|
|
23
|
+
def self.project_root = @project_root || File.realpath((find_task_file&.dirname || Dir.pwd).to_s)
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
def self.pin_project_root(root) = @project_root = root
|
|
26
|
+
|
|
27
|
+
# Whether an import path, as written, is a glob pattern.
|
|
28
|
+
def self.glob?(path) = path.match?(/[*?\[{]/)
|
|
29
|
+
|
|
30
|
+
# +path+ with every glob metacharacter escaped, so Dir.glob matches it
|
|
31
|
+
# literally: a project in "glob[1]" still finds its own files.
|
|
32
|
+
def self.glob_escape(path) = path.to_s.gsub(/[\\*?\[\]{}]/) { "\\#{it}" }
|
|
33
|
+
|
|
34
|
+
# `asgard schedule list`, `asgard watcher list` and `asgard jobs list`
|
|
35
|
+
# report every project's entries, so they work from a directory with no
|
|
36
|
+
# .loki above it.
|
|
37
|
+
def self.machine_wide?(argv) = [%w[schedule list], %w[watcher list], %w[jobs list], %w[jobs]].include?(argv.first(2))
|
|
38
|
+
|
|
39
|
+
# The root .loki is loaded directly rather than through `import`, so
|
|
40
|
+
# record it the way import would. Otherwise an `import_up` in a child
|
|
41
|
+
# file finds the root, reloads it, and the two import each other without
|
|
42
|
+
# end.
|
|
43
|
+
def self.load_root(path)
|
|
44
|
+
path = File.expand_path(path.to_s)
|
|
45
|
+
$LOADED_FEATURES << path unless $LOADED_FEATURES.include?(path)
|
|
46
|
+
load path
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A frame of the backtrace inside a task file: "/proj/.loki:12" from
|
|
50
|
+
# "/proj/.loki:12:in '<class:Tasks>'".
|
|
51
|
+
LOKI_FRAME = /\A(.*?\.loki:\d+)/
|
|
52
|
+
|
|
53
|
+
# One line for an error raised while the task files load: the nearest
|
|
54
|
+
# .loki location, the message's first line and the class. A SyntaxError
|
|
55
|
+
# names its own location in its message, so that is used as is.
|
|
56
|
+
def self.load_error_message(error)
|
|
57
|
+
first = error.message.lines.first.to_s.strip
|
|
58
|
+
return "#{first} (#{error.class})" if error.is_a?(SyntaxError) && first.match?(LOKI_FRAME)
|
|
59
|
+
|
|
60
|
+
where = Array(error.backtrace).lazy.filter_map { |frame| frame[LOKI_FRAME, 1] }.first
|
|
61
|
+
[where, "#{first} (#{error.class})"].compact.join(": ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Loads the task files; anything they raise ends asgard with one line
|
|
65
|
+
# (see load_error_message), or the full backtrace with ASGARD_TRACE set.
|
|
66
|
+
def self.load_tasks(task_file)
|
|
67
|
+
load_root(task_file) if task_file
|
|
68
|
+
rescue Error
|
|
69
|
+
raise
|
|
70
|
+
rescue ScriptError, StandardError => e
|
|
71
|
+
raise if ENV["ASGARD_TRACE"]
|
|
72
|
+
|
|
73
|
+
abort "asgard: #{load_error_message(e)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# --version and --doctor answer and exit before any task file loads.
|
|
77
|
+
def self.answer_flags(argv)
|
|
24
78
|
first = argv.first
|
|
25
79
|
abort "asgard: unknown command '#{first}'" if first&.start_with?("_")
|
|
26
|
-
if
|
|
80
|
+
if leading_flag?(argv, "--version")
|
|
27
81
|
puts Asgard::VERSION
|
|
28
82
|
exit
|
|
29
83
|
end
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
84
|
+
return unless leading_flag?(argv, "--doctor")
|
|
85
|
+
|
|
86
|
+
Asgard::Doctor.new.run
|
|
87
|
+
exit
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Finds and loads the task files and checks every class they defined.
|
|
91
|
+
def self.boot(argv)
|
|
34
92
|
task_file = find_task_file
|
|
35
93
|
abort "asgard: no .loki file found in #{Dir.pwd}" unless task_file || machine_wide?(argv)
|
|
36
|
-
before = Asgard::Base.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
94
|
+
before = Asgard::Base._asgard_subclasses.dup
|
|
95
|
+
load_tasks(task_file)
|
|
96
|
+
((Asgard::Base._asgard_subclasses - before) + [Tasks]).uniq.each(&:validate_deps!)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Main entry point invoked by the asgard executable.
|
|
100
|
+
def self.run!(argv)
|
|
101
|
+
answer_flags(argv)
|
|
102
|
+
boot(argv)
|
|
40
103
|
Tasks._reset_ran!
|
|
104
|
+
pin_project_root(project_root)
|
|
41
105
|
result = Tasks.start(argv)
|
|
42
106
|
# Quality-gate convention: a task signals failure by returning :fail
|
|
43
107
|
# (see dev/quality.loki's *_check tasks). Surface that as a nonzero
|
|
@@ -50,5 +114,7 @@ module Asgard
|
|
|
50
114
|
abort "asgard: #{e.message}"
|
|
51
115
|
rescue Interrupt
|
|
52
116
|
exit(130)
|
|
117
|
+
ensure
|
|
118
|
+
pin_project_root(nil)
|
|
53
119
|
end
|
|
54
120
|
end
|