asgard 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +179 -99
- data/README.md +141 -46
- data/bin/asgard +10 -0
- data/lib/asgard/base/dispatch.rb +257 -31
- data/lib/asgard/base/registry.rb +7 -3
- data/lib/asgard/base.rb +140 -9
- data/lib/asgard/declarations.rb +21 -0
- data/lib/asgard/doctor/task_sections.rb +11 -3
- data/lib/asgard/doctor.rb +12 -8
- data/lib/asgard/jobs/commands.rb +152 -0
- data/lib/asgard/jobs/declaration.rb +149 -0
- data/lib/asgard/jobs/launcher.rb +109 -0
- data/lib/asgard/jobs/state.rb +190 -0
- data/lib/asgard/jobs/stopper.rb +141 -0
- data/lib/asgard/jobs.rb +25 -0
- data/lib/asgard/kernel_methods.rb +19 -14
- data/lib/asgard/leading_flag.rb +10 -0
- data/lib/asgard/pid.rb +130 -0
- data/lib/asgard/schedule/commands.rb +39 -11
- data/lib/asgard/schedule/declaration.rb +139 -12
- data/lib/asgard/schedule/job_files.rb +24 -0
- data/lib/asgard/schedule/launchd.rb +55 -18
- data/lib/asgard/schedule/systemd.rb +69 -27
- data/lib/asgard/schedule/table.rb +34 -16
- data/lib/asgard/schedule.rb +49 -9
- data/lib/asgard/shell.rb +372 -44
- data/lib/asgard/state_dir.rb +84 -0
- data/lib/asgard/tasks.rb +8 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +26 -1
- data/lib/asgard/watch/commands.rb +52 -12
- data/lib/asgard/watch/daemon.rb +334 -43
- data/lib/asgard/watch/declaration.rb +135 -40
- data/lib/asgard/watch/dispatcher.rb +61 -10
- data/lib/asgard/watch/mailbox.rb +50 -0
- data/lib/asgard/watch/reporter.rb +50 -34
- data/lib/asgard/watch/spawn.rb +41 -16
- data/lib/asgard/watch/state.rb +93 -27
- data/lib/asgard/watch/worker.rb +44 -9
- data/lib/asgard/watch.rb +4 -11
- data/lib/asgard.rb +80 -14
- metadata +167 -186
- data/.envrc +0 -6
- data/.github/workflows/deploy-github-pages.yml +0 -52
- data/.loki +0 -39
- data/.reek.yml +0 -166
- data/.rubocop.yml +0 -163
- data/Archspec.rb +0 -24
- data/CLAUDE.md +0 -139
- data/COMMITS.md +0 -196
- data/bin/console +0 -11
- data/bin/setup +0 -8
- data/doc_tasks.loki +0 -21
- data/docs/api.md +0 -368
- data/docs/assets/css/custom.css +0 -93
- data/docs/assets/images/asgard.jpg +0 -0
- data/docs/changelog.md +0 -1
- data/docs/dependencies.md +0 -313
- data/docs/environment.md +0 -129
- data/docs/examples.md +0 -194
- data/docs/getting-started.md +0 -181
- data/docs/helpers.md +0 -262
- data/docs/index.md +0 -87
- data/docs/options.md +0 -224
- data/docs/schedule.md +0 -106
- data/docs/shell.md +0 -237
- data/docs/subcommands.md +0 -181
- data/docs/task-files.md +0 -407
- data/docs/tasks.md +0 -326
- data/docs/variables.md +0 -338
- data/docs/watch.md +0 -123
- data/examples/.env +0 -4
- data/examples/.envrc +0 -3
- data/examples/.loki +0 -24
- data/examples/bad.loki +0 -63
- data/examples/concurrent.loki +0 -58
- data/examples/confirm.loki +0 -48
- data/examples/db_subcommands.loki +0 -74
- data/examples/depends_on_block/bad/.loki +0 -51
- data/examples/depends_on_block/good/.loki +0 -83
- data/examples/do.loki +0 -45
- data/examples/env_usage.loki +0 -27
- data/examples/kitchen_sink.loki +0 -210
- data/examples/server_subcommands.loki +0 -65
- data/examples/subdir/.loki +0 -12
- data/examples/subdir/import_demo.loki +0 -14
- data/examples/subdir/import_up_demo.loki +0 -18
- data/examples/watcher_examples/.envrc +0 -3
- data/examples/watcher_examples/.loki +0 -54
- data/examples/watcher_examples/README.md +0 -112
- data/examples/watcher_examples/bin/hello +0 -3
- data/examples/watcher_examples/data/records.json +0 -1
- data/examples/watcher_examples/docs/drafts/ideas.md +0 -5
- data/examples/watcher_examples/docs/index.md +0 -4
- data/examples/watcher_examples/lib/greeter.rb +0 -7
- data/gem_tasks.loki +0 -51
- data/git.loki +0 -13
- data/mkdocs.yml +0 -166
- data/quality.loki +0 -266
- data/quality_rails.loki +0 -47
- data/xyzzy.loki +0 -12
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "tempfile"
|
|
6
|
+
|
|
7
|
+
module Asgard
|
|
8
|
+
# The layout every kind of per-project state shares: a directory per
|
|
9
|
+
# project under $XDG_STATE_HOME/asgard/<kind>/<project-slug>/ (by default
|
|
10
|
+
# ~/.local/state), a file in it naming the project root so a machine-wide
|
|
11
|
+
# listing can find every project without loading its .loki, and JSON
|
|
12
|
+
# records written whole then renamed. The watcher files its state under
|
|
13
|
+
# "watch" and background jobs under "jobs".
|
|
14
|
+
module StateDir
|
|
15
|
+
ROOT_FILE = "root"
|
|
16
|
+
|
|
17
|
+
# The size a log may reach before it is rotated, unless ASGARD_LOG_MAX
|
|
18
|
+
# (bytes) says otherwise.
|
|
19
|
+
LOG_MAX = 10 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def base_dir(kind, home: Dir.home, env: ENV)
|
|
24
|
+
File.join(env["XDG_STATE_HOME"] || File.join(home, ".local", "state"), "asgard", kind.to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every project with a state directory under base, as [slug, root] pairs.
|
|
28
|
+
def all(kind = nil, base: base_dir(kind))
|
|
29
|
+
Dir.glob(File.join(base, "*", ROOT_FILE)).map do |file|
|
|
30
|
+
[File.basename(File.dirname(file)), File.read(file).strip]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Written whole then renamed, so a reader never sees a partial file.
|
|
35
|
+
# The tempfile is a dotfile: a "*.json" glob or a name pattern that
|
|
36
|
+
# lists records never picks up one that is still being written.
|
|
37
|
+
def write_json(path, value)
|
|
38
|
+
dir = File.dirname(path)
|
|
39
|
+
FileUtils.mkdir_p(dir)
|
|
40
|
+
Tempfile.create([".asgard", ".json"], dir) do |file|
|
|
41
|
+
file.write(JSON.pretty_generate(value))
|
|
42
|
+
file.flush
|
|
43
|
+
File.rename(file.path, path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The parsed record, or nil when there is none or it is not JSON (a
|
|
48
|
+
# disk that filled up, a hand edit): one bad file must not take down
|
|
49
|
+
# every listing that reads it.
|
|
50
|
+
def read_json(path)
|
|
51
|
+
return nil unless File.exist?(path)
|
|
52
|
+
|
|
53
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The log size limit: ASGARD_LOG_MAX when it is a positive number of
|
|
59
|
+
# bytes, else LOG_MAX.
|
|
60
|
+
def log_limit(env = ENV)
|
|
61
|
+
bytes = Integer(env["ASGARD_LOG_MAX"].to_s, exception: false)
|
|
62
|
+
bytes&.positive? ? bytes : LOG_MAX
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Moves +path+ to +path+.1 (replacing an older one) once it is larger
|
|
66
|
+
# than +max_bytes+; the new name when it did, else nil. A task still
|
|
67
|
+
# writing to the old file carries on into path.1, and the next line
|
|
68
|
+
# starts a fresh file.
|
|
69
|
+
def rotate(path, max_bytes:)
|
|
70
|
+
return nil unless File.exist?(path) && File.size(path) > max_bytes
|
|
71
|
+
|
|
72
|
+
File.rename(path, "#{path}.1")
|
|
73
|
+
"#{path}.1"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Creates +dir+, readable by its owner only (logs and job scripts can
|
|
77
|
+
# hold secrets), and records the project root in it.
|
|
78
|
+
def record_root(dir, root)
|
|
79
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
80
|
+
File.chmod(0o700, dir)
|
|
81
|
+
File.write(File.join(dir, ROOT_FILE), "#{root}\n")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/asgard/tasks.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "schedule"
|
|
4
4
|
require_relative "watch"
|
|
5
|
+
require_relative "jobs"
|
|
5
6
|
|
|
6
7
|
# Tasks is the single conventional entry point for all .loki files.
|
|
7
8
|
# It is pre-defined by the gem so .loki files never need to declare a class.
|
|
@@ -18,11 +19,13 @@ class Tasks < Asgard::Base
|
|
|
18
19
|
type: :boolean,
|
|
19
20
|
default: false,
|
|
20
21
|
desc: "Enable debug mode ($DEBUG = true)"
|
|
22
|
+
no_negate :debug
|
|
21
23
|
|
|
22
24
|
class_option :verbose,
|
|
23
25
|
type: :boolean,
|
|
24
26
|
default: false,
|
|
25
27
|
desc: "Enable verbose output ($VERBOSE = true)"
|
|
28
|
+
no_negate :verbose
|
|
26
29
|
|
|
27
30
|
class_option :version,
|
|
28
31
|
type: :boolean,
|
|
@@ -53,4 +56,9 @@ class Tasks < Asgard::Base
|
|
|
53
56
|
subcommand "_watcher", Asgard::Watch::Commands
|
|
54
57
|
map "watcher" => :_watcher
|
|
55
58
|
Asgard::Watch::Commands.commands.each_value { |command| command.ancestor_name = "watcher" }
|
|
59
|
+
|
|
60
|
+
desc "jobs SUBCOMMAND", "List, stop and restart the background jobs started with `sh ..., background: true`"
|
|
61
|
+
subcommand "_jobs", Asgard::Jobs::Commands
|
|
62
|
+
map "jobs" => :_jobs
|
|
63
|
+
Asgard::Jobs::Commands.commands.each_value { |command| command.ancestor_name = "jobs" }
|
|
56
64
|
end
|
data/lib/asgard/version.rb
CHANGED
data/lib/asgard/watch/changes.rb
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Asgard
|
|
4
6
|
# The files a watcher trigger saw change, as three lists of paths relative
|
|
5
7
|
# to the project root. The watcher hands them to the task through three
|
|
6
8
|
# environment variables (newline-separated, since paths may hold spaces
|
|
7
9
|
# or colons); the `changes` Kernel method reads them back. Every list is
|
|
8
10
|
# empty when the task is run by hand, so one task body serves both.
|
|
11
|
+
# Lists too big for the environment come in a JSON file named by
|
|
12
|
+
# ASGARD_CHANGES_FILE instead.
|
|
9
13
|
class Changes < Data.define(:modified, :added, :removed)
|
|
10
14
|
VARIABLES = { modified: "ASGARD_MODIFIED", added: "ASGARD_ADDED", removed: "ASGARD_REMOVED" }.freeze
|
|
15
|
+
FILE_VARIABLE = "ASGARD_CHANGES_FILE"
|
|
11
16
|
|
|
12
17
|
class << self
|
|
13
18
|
# The changes behind this process, read once from ENV. Tests and a
|
|
@@ -17,7 +22,20 @@ module Asgard
|
|
|
17
22
|
def forget = @current = nil
|
|
18
23
|
|
|
19
24
|
# Parses the trigger variables; a missing one is an empty list.
|
|
20
|
-
def from_env(env = ENV)
|
|
25
|
+
def from_env(env = ENV)
|
|
26
|
+
file = env[FILE_VARIABLE].to_s
|
|
27
|
+
return from_file(file) unless file.empty?
|
|
28
|
+
|
|
29
|
+
new(**VARIABLES.to_h { |member, variable| [member, env[variable].to_s.split("\n")] })
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The lists a spilled trigger wrote; empty once the file is gone.
|
|
33
|
+
def from_file(path)
|
|
34
|
+
lists = JSON.parse(File.read(path), symbolize_names: true)
|
|
35
|
+
new(**VARIABLES.keys.to_h { |member| [member, Array(lists[member])] })
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
new
|
|
38
|
+
end
|
|
21
39
|
end
|
|
22
40
|
|
|
23
41
|
def initialize(modified: [], added: [], removed: [])
|
|
@@ -40,6 +58,13 @@ module Asgard
|
|
|
40
58
|
|
|
41
59
|
module_function
|
|
42
60
|
|
|
61
|
+
# One Changes holding every path of +list+, each kind in first-seen
|
|
62
|
+
# order without repeats: what several triggers that waited for the same
|
|
63
|
+
# busy task hand its next run.
|
|
64
|
+
def merge_changes(list)
|
|
65
|
+
Changes.new(**Changes.members.to_h { |member| [member, list.flat_map(&member).uniq] })
|
|
66
|
+
end
|
|
67
|
+
|
|
43
68
|
# A listener batch ({ modified:, added:, removed: }) narrowed to the
|
|
44
69
|
# kinds in events (a Set from normalize_events), as a Changes; nil
|
|
45
70
|
# when nothing is left, so the dispatcher skips the trigger.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "pathname"
|
|
4
3
|
require "shellwords"
|
|
5
4
|
require "time"
|
|
6
5
|
|
|
@@ -30,14 +29,25 @@ module Asgard
|
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# The LAST RUN column from an entry's record: never, still running,
|
|
33
|
-
#
|
|
34
|
-
|
|
32
|
+
# did not finish (no exit was recorded and its process is gone: a
|
|
33
|
+
# crash, a kill -9), or its exit status with duration and finish
|
|
34
|
+
# time. +running+ says whether an unfinished entry's task still runs.
|
|
35
|
+
def last_run(entry, running: State.method(:running?))
|
|
35
36
|
return "never" unless entry
|
|
36
|
-
return "running (pid #{entry[:pid]})" unless entry.key?(:finished)
|
|
37
|
+
return "#{running.call(entry) ? 'running' : 'did not finish'} (pid #{entry[:pid]})" unless entry.key?(:finished)
|
|
37
38
|
|
|
38
39
|
finished = Time.iso8601(entry[:finished]).strftime("%Y-%m-%d %H:%M:%S")
|
|
39
40
|
"#{entry[:status]} (#{format('%.1fs', entry[:seconds])}, #{finished})"
|
|
40
41
|
end
|
|
42
|
+
|
|
43
|
+
# How `start` launches `watcher run`: in the project root, its own
|
|
44
|
+
# process group, output to the log, and no stdin, which it would
|
|
45
|
+
# otherwise share with the terminal that ran `start`.
|
|
46
|
+
def daemon_spawn_options(root, log) = { chdir: root, pgroup: true, in: File::NULL, out: [log, "a"], err: %i[child out] }
|
|
47
|
+
|
|
48
|
+
# The shell command `log -f` runs. -F, not -f: it keeps following
|
|
49
|
+
# watcher.log when rotation renames it and a new one starts.
|
|
50
|
+
def follow_command(path) = "tail -n +1 -F #{path.shellescape}"
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
desc "tree", "Print a tree of the watcher subcommands", hide: true
|
|
@@ -49,7 +59,7 @@ module Asgard
|
|
|
49
59
|
|
|
50
60
|
def watch_root = @watch_root ||= File.realpath((Asgard.find_task_file&.dirname || Pathname.pwd).to_s)
|
|
51
61
|
|
|
52
|
-
def watch_project =
|
|
62
|
+
def watch_project = @watch_project ||= Schedule.project_slug(watch_root)
|
|
53
63
|
|
|
54
64
|
def state = @state ||= State.new(project: watch_project, root: watch_root)
|
|
55
65
|
|
|
@@ -95,35 +105,64 @@ module Asgard
|
|
|
95
105
|
def daemon
|
|
96
106
|
specs = declared_watches
|
|
97
107
|
refuse_if_another_running
|
|
98
|
-
config = Daemon::Config.new(declarations: specs, root: watch_root, state:, context: spawn_context, tasks: Tasks.all_commands.keys
|
|
108
|
+
config = Daemon::Config.new(declarations: specs, root: watch_root, state:, context: spawn_context, tasks: Tasks.all_commands.keys,
|
|
109
|
+
pending: ENV.delete(State::PENDING_ENV))
|
|
99
110
|
Daemon.new(config).run
|
|
100
111
|
end
|
|
101
112
|
|
|
113
|
+
# What a reload runs before it re-execs: the checks `run` makes,
|
|
114
|
+
# without running anything. Exits non-zero, saying why on stderr,
|
|
115
|
+
# when the task files do not load, nothing is watched, or a watch
|
|
116
|
+
# names a task the project does not define.
|
|
117
|
+
desc "check", "Exit 0 when the watches would run (what a reload checks first)", hide: true
|
|
118
|
+
def check
|
|
119
|
+
declared_watches
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
102
123
|
desc "start", "Start the watcher for this project in the background"
|
|
103
124
|
def start
|
|
104
125
|
specs = declared_watches
|
|
105
126
|
refuse_if_running
|
|
106
127
|
log = state.log_path
|
|
107
128
|
state.prepare.write_declarations(specs.values)
|
|
108
|
-
|
|
129
|
+
lock = state.acquire_lock or abort("watcher is already running for #{watch_project}; `asgard watcher stop` first.")
|
|
130
|
+
pid = Process.spawn({ State::LOCK_ENV => lock.fileno.to_s }, watch_asgard, "watcher", "run",
|
|
131
|
+
**Commands.daemon_spawn_options(watch_root, log), lock.fileno => lock.fileno)
|
|
132
|
+
lock.close # the daemon holds it now
|
|
109
133
|
Process.detach(pid)
|
|
110
134
|
state.write_pid(pid)
|
|
111
135
|
puts "started watcher for #{watch_project} (pid #{pid}); log: #{log}"
|
|
112
136
|
end
|
|
113
137
|
|
|
114
|
-
desc "stop", "Stop the watcher; a task already running finishes first"
|
|
138
|
+
desc "stop", "Stop the watcher; a task already running finishes first (--force: end running tasks)"
|
|
139
|
+
method_option :force, aliases: "-f", type: :boolean, desc: "End running tasks (TERM, then KILL) instead of waiting for them"
|
|
115
140
|
def stop
|
|
116
141
|
pid = state.running_pid or abort("watcher is not running for #{watch_project}.")
|
|
117
142
|
Process.kill("TERM", pid)
|
|
118
|
-
|
|
143
|
+
waited = 0
|
|
144
|
+
while State.daemon?(pid)
|
|
119
145
|
running = state.running_entries.map { |entry| "#{entry[:entry]} (pid #{entry[:pid]})" }
|
|
120
146
|
puts "watcher: waiting for #{running.join(', ')}" unless running.empty?
|
|
121
147
|
sleep STOP_WAIT
|
|
148
|
+
force_stop(pid) if options[:force] && (waited += 1) == 1
|
|
122
149
|
end
|
|
123
150
|
state.clear
|
|
124
151
|
puts "stopped watcher for #{watch_project}"
|
|
125
152
|
end
|
|
126
153
|
|
|
154
|
+
no_commands do
|
|
155
|
+
# The second TERM, a moment after the first so the two are not
|
|
156
|
+
# merged into one pending signal: the daemon takes it as "end the
|
|
157
|
+
# running tasks", which it does only for groups it can prove are
|
|
158
|
+
# its own tasks.
|
|
159
|
+
def force_stop(pid)
|
|
160
|
+
Process.kill("TERM", pid) if State.daemon?(pid)
|
|
161
|
+
rescue Errno::ESRCH
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
127
166
|
no_commands do
|
|
128
167
|
# True when list should cover every project: asked for with --all, or
|
|
129
168
|
# there is no .loki here, so no single project to scope to.
|
|
@@ -151,7 +190,8 @@ module Asgard
|
|
|
151
190
|
State.all.to_h do |slug, root|
|
|
152
191
|
other = State.new(project: slug, root:)
|
|
153
192
|
own = Asgard.find_task_file && root == watch_root
|
|
154
|
-
["Project: #{slug} (#{root})",
|
|
193
|
+
title = ["Project: #{slug} (#{root})", ("[root missing]" unless File.directory?(root))].compact.join(" ")
|
|
194
|
+
[title, Commands.project_rows(other, own ? declared_records : other.read_declarations)]
|
|
155
195
|
end
|
|
156
196
|
end
|
|
157
197
|
end
|
|
@@ -197,11 +237,11 @@ module Asgard
|
|
|
197
237
|
end
|
|
198
238
|
|
|
199
239
|
desc "log", "Print this project's watcher log to STDOUT"
|
|
200
|
-
method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives (tail -
|
|
240
|
+
method_option :follow, aliases: "-f", type: :boolean, desc: "Keep printing new output as it arrives, across rotation (tail -F)"
|
|
201
241
|
def log
|
|
202
242
|
path = state.log_path
|
|
203
243
|
return $stderr.puts "#{watch_project} has no watcher log yet (#{path}); it is created on the first start." unless File.exist?(path)
|
|
204
|
-
return sh(
|
|
244
|
+
return sh(Commands.follow_command(path), silent: true) if options[:follow]
|
|
205
245
|
|
|
206
246
|
IO.copy_stream(path, $stdout)
|
|
207
247
|
end
|