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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -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) = new(**VARIABLES.to_h { |member, variable| [member, env[variable].to_s.split("\n")] })
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
- # or its exit status with duration and finish time.
34
- def last_run(entry)
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 = File.basename(watch_root)
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
- pid = Process.spawn(watch_asgard, "watcher", "run", chdir: watch_root, pgroup: true, out: [log, "a"], err: %i[child out])
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
- while Reporter.alive?(pid)
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})", Commands.project_rows(other, own ? declared_records : other.read_declarations)]
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 -f)"
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("tail -n +1 -f #{path.shellescape}", silent: true, exec: true) if options[:follow]
244
+ return sh(Commands.follow_command(path), silent: true) if options[:follow]
205
245
 
206
246
  IO.copy_stream(path, $stdout)
207
247
  end