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/base/dispatch.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "English"
|
|
4
|
+
|
|
3
5
|
module Asgard
|
|
4
6
|
class Base < Thor
|
|
5
7
|
# Task execution engine: resolves and runs declared dependencies (in
|
|
@@ -15,6 +17,69 @@ module Asgard
|
|
|
15
17
|
base.extend(ClassMethods)
|
|
16
18
|
end
|
|
17
19
|
|
|
20
|
+
# A finished task's outcome: :failed when its run ended in an error.
|
|
21
|
+
def self.run_state(target, done:, failed:)
|
|
22
|
+
return :failed if failed.key?(target)
|
|
23
|
+
|
|
24
|
+
done.include?(target) ? :done : :free
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A group's errors without the ones that only echo another failure:
|
|
28
|
+
# a thread that waited on a failed shared dependency. When nothing
|
|
29
|
+
# else is left, the echoes are all there is to report.
|
|
30
|
+
def self.primary_failures(errors)
|
|
31
|
+
primary = errors.reject { |_, error| error.is_a?(DepFailed) }
|
|
32
|
+
primary.empty? ? errors : primary
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A task that is already running further up +chain+ (the tasks this
|
|
36
|
+
# thread is inside, outermost first) and is asked for again can never
|
|
37
|
+
# finish: `def a = invoke(:b)` with `def b = invoke(:a)`. It would
|
|
38
|
+
# wait on itself forever, so name the cycle instead.
|
|
39
|
+
#
|
|
40
|
+
# Each entry, +target+ included, is [class, task name]: a task of the
|
|
41
|
+
# same name in another class is a different task. A subcommand
|
|
42
|
+
# group's `build` runs inside the `build` that dispatches to it, and
|
|
43
|
+
# `jobs restart` runs a project task that may itself be `restart`.
|
|
44
|
+
def self.check_cycle!(chain, target)
|
|
45
|
+
start = chain.index(target)
|
|
46
|
+
return unless start
|
|
47
|
+
|
|
48
|
+
raise Asgard::CircularDependencyError, [*chain[start..], target].map(&:last).join(" -> ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The options a dependency runs with. They start as the typed task's
|
|
52
|
+
# (+typed+), so an option the dependency does not declare passes
|
|
53
|
+
# through unchanged. The dependency's own defaults (+declared+, class
|
|
54
|
+
# options included) apply over those. Last, each option the user
|
|
55
|
+
# actually gave (+raw+, read against +typed_declared+, the typed
|
|
56
|
+
# task's declarations) reaches the dependency when it declares an
|
|
57
|
+
# option of that name. Words are read only as the typed task means
|
|
58
|
+
# them: a short alias that names something else in the dependency
|
|
59
|
+
# (-f for --file, not --force) never reaches it. A required option
|
|
60
|
+
# of the dependency is not enforced, as it was not before.
|
|
61
|
+
def self.dep_options(declared, typed_declared, raw, typed)
|
|
62
|
+
defaults = Thor::Options.new(declared, {}, false, true).parse([])
|
|
63
|
+
given = given_options(typed_declared, raw).slice(*declared.values.map(&:human_name))
|
|
64
|
+
typed.merge(defaults).merge(given).freeze
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Only the options +raw+ spells out, by name, with no defaults filled
|
|
68
|
+
# in: an Array of command-line words read against +declared+, or the
|
|
69
|
+
# Hash a caller passed to new.
|
|
70
|
+
def self.given_options(declared, raw)
|
|
71
|
+
return raw.to_h.transform_keys(&:to_s) unless raw.is_a?(Array)
|
|
72
|
+
|
|
73
|
+
bare = declared.transform_values { without_default(it) }
|
|
74
|
+
Thor::Options.new(bare, {}, false, true).parse(raw).to_h
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A copy of +option+ that parses the same words but has no default.
|
|
78
|
+
def self.without_default(option)
|
|
79
|
+
Thor::Option.new(option.name, type: option.type, aliases: option.aliases, enum: option.enum,
|
|
80
|
+
repeatable: option.repeatable, lazy_default: option.lazy_default)
|
|
81
|
+
end
|
|
82
|
+
|
|
18
83
|
module ClassMethods
|
|
19
84
|
def _running
|
|
20
85
|
@_running ||= Set.new
|
|
@@ -28,6 +93,13 @@ module Asgard
|
|
|
28
93
|
@_cond ||= Hash.new { |h, k| h[k] = ConditionVariable.new }
|
|
29
94
|
end
|
|
30
95
|
|
|
96
|
+
# Tasks whose body raised or exited, keyed by name, with the error
|
|
97
|
+
# that ended them: a thread that waited on one re-raises instead of
|
|
98
|
+
# running its own body on a prerequisite that never finished.
|
|
99
|
+
def _failed
|
|
100
|
+
@_failed ||= {}
|
|
101
|
+
end
|
|
102
|
+
|
|
31
103
|
# Completed tasks' return values, keyed by task name — lets a task
|
|
32
104
|
# already run by one thread hand its result to another thread that
|
|
33
105
|
# shares the dependency, without either touching `self`.
|
|
@@ -46,6 +118,7 @@ module Asgard
|
|
|
46
118
|
@_done = Set.new
|
|
47
119
|
@_cond = Hash.new { |h, k| h[k] = ConditionVariable.new }
|
|
48
120
|
@_results = {}
|
|
121
|
+
@_failed = {}
|
|
49
122
|
end
|
|
50
123
|
end
|
|
51
124
|
end
|
|
@@ -56,14 +129,26 @@ module Asgard
|
|
|
56
129
|
$DEBUG = true if options[:debug]
|
|
57
130
|
$VERBOSE = true if options[:verbose]
|
|
58
131
|
target = command.name.to_sym
|
|
59
|
-
|
|
132
|
+
Dispatch.check_cycle!(_task_frames.map { [it.owner, it.task] }, [self.class, target])
|
|
133
|
+
case _acquire_run_token(target)
|
|
134
|
+
when :done then return _cached_result(target)
|
|
135
|
+
when :failed then raise DepFailed.new(target, self.class._failed[target])
|
|
136
|
+
end
|
|
60
137
|
|
|
61
138
|
result = nil
|
|
139
|
+
error = nil
|
|
62
140
|
begin
|
|
63
|
-
|
|
64
|
-
|
|
141
|
+
result = _in_task_frame(target) do
|
|
142
|
+
resolved_deps = _run_deps_for(target)
|
|
143
|
+
_with_dep_results(resolved_deps) { command.run(self, *) }
|
|
144
|
+
end
|
|
145
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- an exit or interrupt ends the task too
|
|
146
|
+
# Captured here, not read from $! in an ensure: $! still holds a
|
|
147
|
+
# caller's exception when this runs inside its rescue block.
|
|
148
|
+
error = e
|
|
149
|
+
raise
|
|
65
150
|
ensure
|
|
66
|
-
|
|
151
|
+
_signal_done(target, result, error)
|
|
67
152
|
end
|
|
68
153
|
result
|
|
69
154
|
end
|
|
@@ -78,9 +163,41 @@ module Asgard
|
|
|
78
163
|
dep_results[task.to_sym]
|
|
79
164
|
end
|
|
80
165
|
|
|
166
|
+
# One running task, from its dependencies through its body: the class
|
|
167
|
+
# it belongs to, its name and how many background jobs it has started
|
|
168
|
+
# so far. Frames live on
|
|
169
|
+
# a thread-local stack, so a task running as a dependency in a
|
|
170
|
+
# parallel stage sees itself, not the task the user typed. A parallel
|
|
171
|
+
# stage's thread starts with a copy of its parent's stack, which is
|
|
172
|
+
# how a cycle through that stage is still seen (see check_cycle!).
|
|
173
|
+
TaskFrame = Struct.new(:owner, :task, :jobs)
|
|
174
|
+
|
|
175
|
+
# The task whose body is running on this thread, or nil outside one
|
|
176
|
+
# (a helper called at .loki top level).
|
|
177
|
+
def current_task = _task_frames.last&.task
|
|
178
|
+
|
|
179
|
+
# The next sequence number for a background job of the current task:
|
|
180
|
+
# 1 on the first call of each run of the body, then 2, 3, ...
|
|
181
|
+
def _next_job_sequence
|
|
182
|
+
frame = _task_frames.last
|
|
183
|
+
raise Asgard::Error, "no task is running on this thread" unless frame
|
|
184
|
+
|
|
185
|
+
frame.jobs += 1
|
|
186
|
+
end
|
|
187
|
+
|
|
81
188
|
private
|
|
82
189
|
|
|
83
|
-
def
|
|
190
|
+
def _task_frames = Thread.current[:asgard_task_frames] ||= []
|
|
191
|
+
|
|
192
|
+
def _in_task_frame(task)
|
|
193
|
+
frames = _task_frames
|
|
194
|
+
frames.push(TaskFrame.new(self.class, task, 0))
|
|
195
|
+
yield
|
|
196
|
+
ensure
|
|
197
|
+
frames.pop
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def _with_dep_results(results)
|
|
84
201
|
thread = Thread.current
|
|
85
202
|
previous = thread[:asgard_dep_results]
|
|
86
203
|
thread[:asgard_dep_results] = results
|
|
@@ -89,71 +206,180 @@ module Asgard
|
|
|
89
206
|
thread[:asgard_dep_results] = previous
|
|
90
207
|
end
|
|
91
208
|
|
|
92
|
-
def
|
|
209
|
+
def _cached_result(target)
|
|
93
210
|
klass = self.class
|
|
94
211
|
klass._ran_mutex.synchronize { klass._results[target] }
|
|
95
212
|
end
|
|
96
213
|
|
|
97
|
-
|
|
214
|
+
# :run when this thread is to run +target+, :done when another
|
|
215
|
+
# already ran it, :failed when that run raised or exited. A thread
|
|
216
|
+
# that finds it running waits for the outcome.
|
|
217
|
+
def _acquire_run_token(target)
|
|
98
218
|
klass = self.class
|
|
99
219
|
mutex = klass._ran_mutex
|
|
100
220
|
done = klass._done
|
|
101
221
|
running = klass._running
|
|
102
222
|
|
|
103
223
|
mutex.synchronize do
|
|
104
|
-
if done.include?(target)
|
|
105
|
-
false
|
|
106
|
-
elsif running.include?(target)
|
|
224
|
+
if running.include?(target) && !done.include?(target)
|
|
107
225
|
klass._cond[target].wait(mutex) until done.include?(target)
|
|
108
|
-
false
|
|
109
|
-
else
|
|
110
|
-
running.add(target)
|
|
111
|
-
true
|
|
112
226
|
end
|
|
227
|
+
next Dispatch.run_state(target, done:, failed: klass._failed) if done.include?(target)
|
|
228
|
+
|
|
229
|
+
running.add(target)
|
|
230
|
+
:run
|
|
113
231
|
end
|
|
114
232
|
end
|
|
115
233
|
|
|
116
|
-
def
|
|
234
|
+
def _run_deps_for(target)
|
|
117
235
|
stages = self.class._deps[target]
|
|
118
236
|
return {} unless stages&.any?
|
|
119
237
|
|
|
120
|
-
stages.each_with_object({}) { |group, acc| acc.merge!(
|
|
238
|
+
stages.each_with_object({}) { |group, acc| acc.merge!(_run_dep_group(group)) }
|
|
121
239
|
end
|
|
122
240
|
|
|
123
|
-
def
|
|
124
|
-
return { group.first =>
|
|
241
|
+
def _run_dep_group(group)
|
|
242
|
+
return { group.first => _run_dep(group.first) } unless group.size > 1
|
|
125
243
|
|
|
126
|
-
threads = group.map { |task| [task,
|
|
127
|
-
errors =
|
|
244
|
+
threads = group.map { |task| [task, _dep_thread(task)] }
|
|
245
|
+
errors = {}
|
|
128
246
|
results = {}
|
|
247
|
+
# Join every thread before raising, so one failure never ends the
|
|
248
|
+
# process while the other tasks' subprocesses run on, orphaned.
|
|
129
249
|
threads.each do |task, t|
|
|
130
250
|
results[task] = t.value
|
|
131
|
-
rescue => e
|
|
132
|
-
errors
|
|
251
|
+
rescue StandardError, ScriptError => e
|
|
252
|
+
errors[task] = e
|
|
133
253
|
end
|
|
134
254
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
errors.each { |e| warn "asgard: #{e.message}" }
|
|
139
|
-
raise Asgard::Error, "#{errors.size} parallel dependencies failed"
|
|
140
|
-
end
|
|
255
|
+
errors = Dispatch.primary_failures(errors)
|
|
256
|
+
_raise_dep_failure(errors.values.first) if errors.size == 1
|
|
257
|
+
_raise_group_failure(errors) if errors.any?
|
|
141
258
|
|
|
142
259
|
results
|
|
143
260
|
end
|
|
144
261
|
|
|
145
|
-
|
|
262
|
+
# A SystemExit that ended a dependency thread (a failing `sh`, an
|
|
263
|
+
# `abort`), carried out of the thread as an ordinary error. Ruby
|
|
264
|
+
# re-raises an unhandled SystemExit from any thread in the main
|
|
265
|
+
# thread, which would end the process in the middle of the join.
|
|
266
|
+
class DepExit < StandardError
|
|
267
|
+
attr_reader :status
|
|
268
|
+
|
|
269
|
+
def initialize(status)
|
|
270
|
+
@status = status
|
|
271
|
+
super("exit #{status}")
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Raised in a thread that waited on +task+ when +task+ failed. Its
|
|
276
|
+
# +error+ is the failure itself, which some other thread reports, so
|
|
277
|
+
# the joiner counts only that one (see primary_failures).
|
|
278
|
+
class DepFailed < StandardError
|
|
279
|
+
attr_reader :task, :error
|
|
280
|
+
|
|
281
|
+
def initialize(task, error)
|
|
282
|
+
@task = task
|
|
283
|
+
@error = error
|
|
284
|
+
super("dependency '#{task}' failed")
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Ruby's own report would print the full backtrace to stderr before
|
|
289
|
+
# the error reaches asgard's handler; the joiner re-raises it anyway.
|
|
290
|
+
# Each parallel task runs on its own instance (same options), so two
|
|
291
|
+
# tasks in one stage never race on an instance variable; results
|
|
292
|
+
# pass between tasks through dep_result, not through self.
|
|
293
|
+
def _dep_thread(task)
|
|
294
|
+
instance = _parallel_instance
|
|
295
|
+
frames = _task_frames.dup
|
|
296
|
+
Thread.new do
|
|
297
|
+
thread = Thread.current
|
|
298
|
+
thread.report_on_exception = false
|
|
299
|
+
thread[:asgard_task_frames] = frames
|
|
300
|
+
instance.__send__(:_run_dep, task)
|
|
301
|
+
rescue SystemExit => e
|
|
302
|
+
raise DepExit, e.status
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Built as Thor built this instance: the same command line and the
|
|
307
|
+
# same config, which names the typed command. Thor then reads the
|
|
308
|
+
# words against that command's options, as it did the first time,
|
|
309
|
+
# so check_unknown_options! knows them. The task the copy runs reads
|
|
310
|
+
# its own options from the same words (see dep_options).
|
|
311
|
+
def _parallel_instance
|
|
312
|
+
config = _initializer_config
|
|
313
|
+
typed = config[:current_command]
|
|
314
|
+
config = config.merge(command_options: typed.options) if typed
|
|
315
|
+
instance = self.class.new([], _raw_options, config)
|
|
316
|
+
instance.options = options
|
|
317
|
+
instance
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# The options this instance was created with, before parsing: the
|
|
321
|
+
# command line's words, or the Hash a caller passed to new.
|
|
322
|
+
def _raw_options = @_initializer[1]
|
|
323
|
+
|
|
324
|
+
# The config Thor created this instance with; its :current_command
|
|
325
|
+
# is the task that was typed (or invoked).
|
|
326
|
+
def _initializer_config = @_initializer[2] || {}
|
|
327
|
+
|
|
328
|
+
# What the command line's words are read against: the class options
|
|
329
|
+
# plus the typed task's own.
|
|
330
|
+
def _typed_declared
|
|
331
|
+
typed = _initializer_config[:current_command]
|
|
332
|
+
declared = self.class.class_options
|
|
333
|
+
typed ? declared.merge(typed.options) : declared
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Runs the block with +opts+ as this instance's options.
|
|
337
|
+
def _with_options(opts)
|
|
338
|
+
previous = options
|
|
339
|
+
self.options = opts
|
|
340
|
+
yield
|
|
341
|
+
ensure
|
|
342
|
+
self.options = previous
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def _dep_options_for(command)
|
|
346
|
+
Dispatch.dep_options(self.class.class_options.merge(command.options), _typed_declared, _raw_options, options)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# One failure: a dependency that exited ends asgard with its status,
|
|
350
|
+
# as it would have on its own; anything else propagates unchanged.
|
|
351
|
+
def _raise_dep_failure(error)
|
|
352
|
+
error = error.error while error.is_a?(DepFailed) && error.error
|
|
353
|
+
case error
|
|
354
|
+
when DepExit, SystemExit then exit(error.status)
|
|
355
|
+
else raise(error)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Every script in the group stopped because asgard was signalled:
|
|
360
|
+
# that is the one failure, so it exits as asgard would on its own.
|
|
361
|
+
def _raise_group_failure(errors)
|
|
362
|
+
signalled = Shell::Children.exit_status
|
|
363
|
+
exit(signalled) if signalled
|
|
364
|
+
errors.each { |task, e| $stderr.puts "asgard: dependency '#{task}' failed: #{_describe_failure(e)}" } # rubocop:disable Style/StderrPuts
|
|
365
|
+
raise Asgard::Error, "parallel dependencies failed: #{errors.keys.join(', ')}"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def _describe_failure(error) = error.is_a?(DepExit) ? error.message : "#{error.class}: #{error.message}"
|
|
369
|
+
|
|
370
|
+
def _signal_done(target, result, error = nil)
|
|
146
371
|
klass = self.class
|
|
147
372
|
klass._ran_mutex.synchronize do
|
|
148
373
|
klass._done.add(target)
|
|
149
374
|
klass._results[target] = result
|
|
375
|
+
klass._failed[target] = error if error
|
|
150
376
|
klass._cond[target].broadcast
|
|
151
377
|
end
|
|
152
378
|
end
|
|
153
379
|
|
|
154
|
-
def
|
|
380
|
+
def _run_dep(task)
|
|
155
381
|
command = self.class.all_commands[task.to_s]
|
|
156
|
-
invoke_command(command) if command
|
|
382
|
+
_with_options(_dep_options_for(command)) { invoke_command(command) } if command
|
|
157
383
|
end
|
|
158
384
|
end
|
|
159
385
|
end
|
data/lib/asgard/base/registry.rb
CHANGED
|
@@ -7,13 +7,16 @@ module Asgard
|
|
|
7
7
|
# --doctor`) uses to detect a silently-overridden `def`, same file or
|
|
8
8
|
# across files.
|
|
9
9
|
module Registry
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
# Every subclass of Asgard::Base, at any depth, in definition order.
|
|
11
|
+
# Not `subclasses`: that is Ruby's own Class#subclasses (direct
|
|
12
|
+
# subclasses only), which a .loki may well call.
|
|
13
|
+
def _asgard_subclasses
|
|
14
|
+
@_asgard_subclasses ||= []
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def inherited(subclass)
|
|
15
18
|
super
|
|
16
|
-
Asgard::Base.
|
|
19
|
+
Asgard::Base._asgard_subclasses << subclass
|
|
17
20
|
subclass.instance_variable_set(:@_deps, {})
|
|
18
21
|
subclass.instance_variable_set(:@_method_log, Hash.new { |h, k| h[k] = [] })
|
|
19
22
|
subclass.instance_variable_set(:@_pending_deps, [])
|
|
@@ -23,6 +26,7 @@ module Asgard
|
|
|
23
26
|
subclass.instance_variable_set(:@_done, Set.new)
|
|
24
27
|
subclass.instance_variable_set(:@_cond, Hash.new { |h, k| h[k] = ConditionVariable.new })
|
|
25
28
|
subclass.instance_variable_set(:@_ran_mutex, Mutex.new)
|
|
29
|
+
subclass.instance_variable_set(:@_failed, {})
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
# Every source_location a method name has ever been defined at, in
|
data/lib/asgard/base.rb
CHANGED
|
@@ -17,11 +17,45 @@ module Asgard
|
|
|
17
17
|
extend TaskDSL
|
|
18
18
|
include Dispatch
|
|
19
19
|
|
|
20
|
+
THOR_LIB = File.dirname(Thor.method(:subcommand).source_location.first)
|
|
21
|
+
ASGARD_LIB = File.expand_path("..", __dir__)
|
|
22
|
+
|
|
23
|
+
# Names Thor's machinery relies on: its reserved words plus the readers
|
|
24
|
+
# dispatch calls on every instance, which Thor itself does not guard.
|
|
25
|
+
# A task or helper by one of these names breaks every command, with
|
|
26
|
+
# nothing in the failure pointing at the cause.
|
|
27
|
+
RESERVED_NAMES = (Thor::THOR_RESERVED_WORDS + %w[args parent_options]).map(&:to_sym).freeze
|
|
28
|
+
|
|
29
|
+
# The helpers lib/asgard/kernel_methods.rb adds to Kernel.
|
|
30
|
+
KERNEL_HELPERS = %i[debug? verbose? env changes loki_up import import_up].freeze
|
|
31
|
+
|
|
32
|
+
# Asgard's own helpers a task body calls (sh, env, dep_result, ...)
|
|
33
|
+
# and the methods dispatch runs every task through. A task or helper
|
|
34
|
+
# by one of these names would silently replace it.
|
|
35
|
+
ASGARD_HELPERS = [Shell, Dispatch].flat_map { |mod| mod.instance_methods(false) + mod.private_instance_methods(false) }
|
|
36
|
+
.concat(KERNEL_HELPERS).uniq.freeze
|
|
37
|
+
|
|
20
38
|
class << self
|
|
21
39
|
# A usage error (missing argument, unknown command or option) exits 1,
|
|
22
40
|
# so CI and `asgard do` sequences see the failure. Thor's default is 0.
|
|
23
41
|
def exit_on_failure? = true
|
|
24
42
|
|
|
43
|
+
# A subcommand group's help lists only the group's own commands. The
|
|
44
|
+
# tasks it inherits from Tasks still run, but Thor would list them
|
|
45
|
+
# too, under the class's default namespace (doc_commands for
|
|
46
|
+
# DocCommands), burying the group's own.
|
|
47
|
+
def printable_commands(all = true, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
|
|
48
|
+
super(all && !subcommand, subcommand)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `subcommand "doc", DocCommands` names the group doc, so `tree` shows
|
|
52
|
+
# it as doc rather than Thor's doc_commands. A group that set its own
|
|
53
|
+
# namespace keeps it.
|
|
54
|
+
def subcommand(name, klass)
|
|
55
|
+
klass.namespace(name) if klass.namespace == Thor::Util.namespace_from_thor_class(klass)
|
|
56
|
+
super
|
|
57
|
+
end
|
|
58
|
+
|
|
25
59
|
def header(text = nil)
|
|
26
60
|
return @_header if text.nil?
|
|
27
61
|
(@_header ||= []) << text
|
|
@@ -32,22 +66,119 @@ module Asgard
|
|
|
32
66
|
(@_footer ||= []).unshift(text)
|
|
33
67
|
end
|
|
34
68
|
|
|
69
|
+
# A method Thor generates (a `subcommand` dispatcher, a subcommand
|
|
70
|
+
# class's string-eval'd `help`) reports a source_location inside Thor.
|
|
71
|
+
# Credit it to the first caller frame outside Thor and asgard — the
|
|
72
|
+
# .loki line that called `subcommand` — so Doctor lists it there.
|
|
73
|
+
def definition_site(loc, callers)
|
|
74
|
+
return loc unless thor_generated?(loc.first)
|
|
75
|
+
|
|
76
|
+
site = callers.find { |frame| user_frame?(frame.path) }
|
|
77
|
+
site ? [site.path, site.lineno] : loc
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def thor_generated?(path)
|
|
81
|
+
path.start_with?("(eval", THOR_LIB)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def user_frame?(path)
|
|
85
|
+
!thor_generated?(path) && !path.start_with?(ASGARD_LIB)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
HIDDEN_DESC = "(hidden task: defined without a desc)"
|
|
89
|
+
|
|
90
|
+
# A public method a .loki defines without a desc is a hidden task:
|
|
91
|
+
# it runs from the command line and as a dependency, and help leaves
|
|
92
|
+
# it out. Not one: a method that already has a desc pending, anything
|
|
93
|
+
# inside no_commands (helpers), non-public methods, the gem's own
|
|
94
|
+
# _-prefixed names, and a name Thor already has a command for (help).
|
|
95
|
+
# Nor a method every object already has (to_s, inspect,
|
|
96
|
+
# respond_to_missing?): see object_method?.
|
|
97
|
+
def implicit_hidden_task?(method_name)
|
|
98
|
+
name = method_name.to_s
|
|
99
|
+
!@usage && !no_commands? && !name.start_with?("_") && !object_method?(method_name) &&
|
|
100
|
+
public_method_defined?(method_name) && !all_commands.key?(name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A method Thor (and so Object) already defines: a public one
|
|
104
|
+
# (to_s, inspect, display), or a private one that is not a Kernel
|
|
105
|
+
# module function (method_missing, respond_to_missing?). Kernel's
|
|
106
|
+
# module functions (test, format, system) stay free to be task names.
|
|
107
|
+
def object_method?(method_name)
|
|
108
|
+
Thor.public_method_defined?(method_name) ||
|
|
109
|
+
(Thor.private_method_defined?(method_name) && !Kernel.respond_to?(method_name))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The task `asgard help NAME` describes: a built-in group's mapped
|
|
113
|
+
# name ("jobs") is its registered subcommand ("_jobs"); anything else
|
|
114
|
+
# is NAME unchanged.
|
|
115
|
+
def help_target(command)
|
|
116
|
+
mapped = map[command.to_s]&.to_s
|
|
117
|
+
subcommands.include?(mapped) ? mapped : command
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def _check_reserved_name!(method_name)
|
|
121
|
+
thor = RESERVED_NAMES.include?(method_name)
|
|
122
|
+
return unless thor || ASGARD_HELPERS.include?(method_name)
|
|
123
|
+
|
|
124
|
+
file, line = instance_method(method_name).source_location
|
|
125
|
+
remove_method(method_name) # leave the original in place, so the class still dispatches
|
|
126
|
+
what = thor ? "a name Thor reserves" : "an asgard helper"
|
|
127
|
+
raise Asgard::Error, "'#{method_name}' at #{file}:#{line} is #{what} and cannot be a task or helper; rename it"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# `private def helper = ...` makes the method private only after
|
|
131
|
+
# method_added has seen it public and made it a hidden task, and Ruby
|
|
132
|
+
# has no hook for the change (overriding `private` would break its
|
|
133
|
+
# no-argument form in every subclass). So whenever the command table
|
|
134
|
+
# is read (dispatch, help, doctor), a hidden task whose method is no
|
|
135
|
+
# longer public is dropped from it.
|
|
136
|
+
def all_commands
|
|
137
|
+
super.tap { _drop_non_public_hidden_tasks(it) }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Removes from +all+ (and this class's own table) each hidden task,
|
|
141
|
+
# one defined without a desc, whose method is private or protected,
|
|
142
|
+
# with any depends_on attached to it.
|
|
143
|
+
def _drop_non_public_hidden_tasks(all)
|
|
144
|
+
stale = all.select { |name, command| command.description == HIDDEN_DESC && !public_method_defined?(name) }
|
|
145
|
+
stale.each_key do |name|
|
|
146
|
+
all.delete(name)
|
|
147
|
+
commands.delete(name)
|
|
148
|
+
_deps.delete(name.to_sym)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A redefined to_s or inspect with no desc of its own: not a task, and
|
|
153
|
+
# not worth Thor's "without usage or description" warning either.
|
|
154
|
+
def plain_object_method?(method_name) = !@usage && !@_pending_single_desc && object_method?(method_name)
|
|
155
|
+
|
|
156
|
+
# Turns a pending single-argument `desc "text"` into Thor's desc for
|
|
157
|
+
# the method just defined.
|
|
158
|
+
def _apply_pending_desc(name)
|
|
159
|
+
return unless @_pending_single_desc && !no_commands?
|
|
160
|
+
|
|
161
|
+
pending_desc = @_pending_single_desc
|
|
162
|
+
pending_opts = @_pending_single_desc_opts || {}
|
|
163
|
+
@_pending_single_desc = nil
|
|
164
|
+
@_pending_single_desc_opts = nil
|
|
165
|
+
desc(name, pending_desc, pending_opts)
|
|
166
|
+
end
|
|
167
|
+
|
|
35
168
|
def method_added(method_name)
|
|
169
|
+
_check_reserved_name!(method_name)
|
|
170
|
+
return no_commands { super } if plain_object_method?(method_name)
|
|
171
|
+
|
|
36
172
|
name = method_name.to_s
|
|
37
173
|
private_method = name.start_with?("_")
|
|
38
174
|
|
|
39
175
|
unless private_method
|
|
40
176
|
loc = instance_method(method_name).source_location
|
|
41
|
-
_method_log[method_name] << loc if loc
|
|
177
|
+
_method_log[method_name] << definition_site(loc, caller_locations) if loc
|
|
42
178
|
end
|
|
43
179
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
pending_opts = @_pending_single_desc_opts || {}
|
|
47
|
-
@_pending_single_desc = nil
|
|
48
|
-
@_pending_single_desc_opts = nil
|
|
49
|
-
desc(name, pending_desc, pending_opts)
|
|
50
|
-
end
|
|
180
|
+
_apply_pending_desc(name)
|
|
181
|
+
desc(name, HIDDEN_DESC, hide: true) if implicit_hidden_task?(method_name)
|
|
51
182
|
|
|
52
183
|
return super unless @usage
|
|
53
184
|
|
|
@@ -70,7 +201,7 @@ module Asgard
|
|
|
70
201
|
|
|
71
202
|
say header_text.join("\n\n") if header_text && top_level
|
|
72
203
|
say "\n"
|
|
73
|
-
super
|
|
204
|
+
super(klass.help_target(command), subcommand)
|
|
74
205
|
say footer_text.join("\n\n") if footer_text && top_level
|
|
75
206
|
end
|
|
76
207
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asgard
|
|
4
|
+
# What `schedule` and `watch` share: a registry of declared entries,
|
|
5
|
+
# keyed by entry name.
|
|
6
|
+
module Declarations
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Stores +spec+ (a Hash with a :name) in +declarations+ and returns it.
|
|
10
|
+
# An identical entry already there is a no-op (a .loki file loaded
|
|
11
|
+
# twice); a different one under the same name is an error that names
|
|
12
|
+
# +kind+ ("schedule", "watch").
|
|
13
|
+
def record(declarations, spec, kind)
|
|
14
|
+
name = spec[:name]
|
|
15
|
+
taken = declarations[name]
|
|
16
|
+
raise ArgumentError, "#{kind}: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
|
|
17
|
+
|
|
18
|
+
declarations[name] = spec
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -7,6 +7,14 @@ module Asgard
|
|
|
7
7
|
# more than one recorded location (the each/each_gem class of bug) —
|
|
8
8
|
# earlier ones OVERRIDDEN (dead), the last one the active redefinition.
|
|
9
9
|
module TaskSections
|
|
10
|
+
# A hidden task (one defined without a desc) says so, after any
|
|
11
|
+
# override note.
|
|
12
|
+
def self.with_hidden(status, hidden)
|
|
13
|
+
return status unless hidden
|
|
14
|
+
|
|
15
|
+
[status, "hidden (no desc; not shown by help)"].compact.join("; ")
|
|
16
|
+
end
|
|
17
|
+
|
|
10
18
|
private
|
|
11
19
|
|
|
12
20
|
def build_task_sections
|
|
@@ -20,12 +28,12 @@ module Asgard
|
|
|
20
28
|
file_map = Hash.new { |h, k| h[k] = [] }
|
|
21
29
|
@loaded_files.each { |f| file_map[f] }
|
|
22
30
|
|
|
23
|
-
commands = klass.all_commands
|
|
31
|
+
commands = klass.all_commands
|
|
24
32
|
klass._method_log.each do |name, locations|
|
|
25
|
-
|
|
33
|
+
command = commands[name.to_s] or next
|
|
26
34
|
|
|
27
35
|
locations.each_with_index do |(file, line), idx|
|
|
28
|
-
file_map[file] << [name, line, task_status(locations, idx)]
|
|
36
|
+
file_map[file] << [name, line, TaskSections.with_hidden(task_status(locations, idx), command.hidden?)]
|
|
29
37
|
end
|
|
30
38
|
end
|
|
31
39
|
file_map.each_value { |tasks| tasks.sort_by! { |t| t[1] } }
|