asgard 0.3.2 → 0.4.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.
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "etc"
5
+
6
+ module Asgard
7
+ module Schedule
8
+ # Linux backend: each declaration becomes a systemd user service + timer
9
+ # pair (~/.config/systemd/user/asgard.<project>.<name>.{service,timer}).
10
+ # Calendar timers are Persistent=, so a run missed while the machine was
11
+ # off or asleep happens on the next boot/wake. Requires systemd 240+ (for
12
+ # StandardOutput=append:). User timers only run while you are logged in
13
+ # unless lingering is enabled (`loginctl enable-linger`); #notes says so.
14
+ # Implements the backend API documented in declaration.rb.
15
+ class Systemd
16
+ UNIT_PREFIX = "asgard"
17
+ DAY_ABBREVIATIONS = %w[Sun Mon Tue Wed Thu Fri Sat].freeze
18
+
19
+ # ---- pure helpers -----------------------------------------------------
20
+
21
+ def self.unit_prefix(project) = "#{UNIT_PREFIX}.#{Schedule.slug(project)}."
22
+
23
+ def self.unit(project, name) = "#{unit_prefix(project)}#{name}"
24
+
25
+ # { hour: 17, minute: 30, days: [1, 5] } => "Mon,Fri *-*-* 17:30:00"
26
+ def self.on_calendar(entry)
27
+ days = entry[:days]&.map { |day| DAY_ABBREVIATIONS.fetch(day) }&.join(",")
28
+ [days, format("*-*-* %<hour>02d:%<minute>02d:00", entry)].compact.join(" ")
29
+ end
30
+
31
+ # systemd expands %specifiers everywhere; % must be doubled to be literal.
32
+ def self.escape_specifiers(text) = text.to_s.gsub("%", "%%")
33
+
34
+ # A double-quoted word for ExecStart=/Environment=. $ is doubled too so
35
+ # ExecStart= doesn't expand it as a variable.
36
+ def self.quote(text)
37
+ %("#{escape_specifiers(text).gsub('\\') { '\\\\' }.gsub('"', '\\"').gsub('$', '$$')}")
38
+ end
39
+
40
+ def self.service_unit(description:, arguments:, working_directory:, environment:, log_path:)
41
+ env_lines = environment.map { |key, value| "Environment=#{quote("#{key}=#{value}")}" }
42
+ log = escape_specifiers(log_path)
43
+ <<~UNIT
44
+ [Unit]
45
+ Description=#{escape_specifiers(description)}
46
+
47
+ [Service]
48
+ Type=oneshot
49
+ WorkingDirectory=#{escape_specifiers(working_directory)}
50
+ #{env_lines.join("\n")}
51
+ ExecStart=#{arguments.map { |arg| quote(arg) }.join(' ')}
52
+ StandardOutput=append:#{log}
53
+ StandardError=append:#{log}
54
+ UNIT
55
+ end
56
+
57
+ # every: runs one interval after the timer starts, then one interval
58
+ # after each run, matching launchd's StartInterval.
59
+ def self.timer_unit(description:, service:, calendar: nil, every: nil)
60
+ schedule = if every
61
+ ["OnActiveSec=#{every}", "OnUnitActiveSec=#{every}"]
62
+ else
63
+ [*calendar.map { |entry| "OnCalendar=#{on_calendar(entry)}" }, "Persistent=true"]
64
+ end
65
+ <<~UNIT
66
+ [Unit]
67
+ Description=#{escape_specifiers(description)}
68
+
69
+ [Timer]
70
+ #{schedule.join("\n")}
71
+ AccuracySec=1s
72
+ Unit=#{service}
73
+
74
+ [Install]
75
+ WantedBy=timers.target
76
+ UNIT
77
+ end
78
+
79
+ # `systemctl show -p A -p B` output => { "A" => "...", "B" => "..." }
80
+ def self.parse_show(output) = output.lines.to_h { |line| line.chomp.split("=", 2) }.reject { |k, _| k.to_s.empty? }
81
+
82
+ # ---- backend API ------------------------------------------------------
83
+
84
+ def initialize(project:, root:, home: Dir.home, runner: Schedule.runner, env: ENV, user: Etc.getpwuid(Process.uid).name)
85
+ @project = project
86
+ @root = root
87
+ @runner = runner
88
+ @user = user
89
+ @config = env["XDG_CONFIG_HOME"] || File.join(home, ".config")
90
+ @state = env["XDG_STATE_HOME"] || File.join(home, ".local", "state")
91
+ end
92
+
93
+ def scheduler = "systemd"
94
+
95
+ def unit(name) = self.class.unit(@project, name)
96
+
97
+ def service_path(name) = File.join(@config, "systemd", "user", "#{unit(name)}.service")
98
+
99
+ def timer_path(name) = File.join(@config, "systemd", "user", "#{unit(name)}.timer")
100
+
101
+ def log_path(name) = File.join(@state, "asgard", "#{unit(name)}.log")
102
+
103
+ def files(spec, asgard:, direnv:)
104
+ name, task, args, every = spec.values_at(:name, :task, :args, :every)
105
+ klass = self.class
106
+ description = "#{Schedule.command_line(task, args)} (#{@project})"
107
+ service = klass.service_unit(
108
+ description:,
109
+ arguments: Schedule.program_arguments(task, args, root: @root, asgard:, direnv:),
110
+ working_directory: @root,
111
+ environment: Schedule.environment(spec),
112
+ log_path: log_path(name)
113
+ )
114
+ timer = klass.timer_unit(
115
+ description:,
116
+ service: "#{unit(name)}.service",
117
+ calendar: every ? nil : Schedule.calendar(at: spec[:at], on: spec[:on]),
118
+ every:
119
+ )
120
+ { service_path(name) => service, timer_path(name) => timer }
121
+ end
122
+
123
+ # A timer that exists but is disabled was stopped on purpose; keep it so.
124
+ def install(spec, asgard:, direnv:)
125
+ name = spec[:name]
126
+ timer_file = timer_path(name)
127
+ timer_unit = timer(name)
128
+ stopped = File.exist?(timer_file) && !enabled?(name)
129
+ FileUtils.mkdir_p [File.dirname(timer_file), File.dirname(log_path(name))]
130
+ files(spec, asgard:, direnv:).each { |path, content| File.write(path, content) }
131
+ systemctl! "daemon-reload"
132
+ return :stopped if stopped
133
+
134
+ systemctl! "enable", timer_unit
135
+ systemctl! "restart", timer_unit # picks up a changed schedule
136
+ :active
137
+ end
138
+
139
+ def uninstall(name)
140
+ service_unit = service(name)
141
+ systemctl "disable", "--now", timer(name)
142
+ systemctl "stop", service_unit
143
+ FileUtils.rm_f [service_path(name), timer_path(name)]
144
+ systemctl "daemon-reload"
145
+ systemctl "reset-failed", service_unit
146
+ end
147
+
148
+ def start(name) = systemctl!("enable", "--now", timer(name))
149
+
150
+ def stop(name) = systemctl!("disable", "--now", timer(name))
151
+
152
+ def trigger(name) = systemctl!("start", "--no-block", service(name))
153
+
154
+ def installed_names
155
+ prefix = self.class.unit_prefix(@project)
156
+ Dir.glob(timer_path("*")).map { |path| File.basename(path, ".timer").delete_prefix(prefix) }.sort
157
+ end
158
+
159
+ def status(name)
160
+ _, active = systemctl("is-active", "--quiet", timer(name))
161
+ state = if active then :active
162
+ elsif enabled?(name) then :not_loaded
163
+ else :stopped
164
+ end
165
+ out, = systemctl("show", service(name), "-p", "ExecMainStatus", "-p", "ExecMainExitTimestampMonotonic")
166
+ props = self.class.parse_show(out)
167
+ stamp = props["ExecMainExitTimestampMonotonic"].to_s
168
+ ran = !stamp.empty? && stamp != "0"
169
+ { state:, last_exit: ran ? props["ExecMainStatus"] : nil }
170
+ end
171
+
172
+ def notes
173
+ out, ok = run("loginctl", "show-user", @user.to_s, "-p", "Linger", "--value")
174
+ return [] unless ok && out.strip == "no"
175
+
176
+ ["systemd: user timers only run while #{@user} is logged in; run `loginctl enable-linger` to keep them running."]
177
+ end
178
+
179
+ private
180
+
181
+ def timer(name) = "#{unit(name)}.timer"
182
+
183
+ def service(name) = "#{unit(name)}.service"
184
+
185
+ def enabled?(name) = systemctl("is-enabled", "--quiet", timer(name)).last
186
+
187
+ def systemctl(*) = run("systemctl", "--user", *)
188
+
189
+ def systemctl!(*args)
190
+ out, ok = systemctl(*args)
191
+ raise Error, "systemctl --user #{args.join(' ')} failed: #{out.strip}" unless ok
192
+
193
+ out
194
+ end
195
+
196
+ def run(*argv) = @runner.call(*argv)
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "schedule/declaration"
4
+ require_relative "schedule/launchd"
5
+ require_relative "schedule/systemd"
6
+ require_relative "schedule/commands"
7
+
8
+ module Asgard
9
+ # Runs asgard tasks periodically under the platform's own scheduler.
10
+ # Declare entries at class level in a .loki file:
11
+ #
12
+ # class Tasks
13
+ # schedule :daily_summary, at: "17:30", on: :weekdays
14
+ # schedule :sync, every: 3600
15
+ # schedule :report, options: "--period week", at: "16:00", on: :friday
16
+ # end
17
+ #
18
+ # then manage them with `asgard schedule install|list|start|stop|...`.
19
+ module Schedule
20
+ class << self
21
+ # Declared entries for this run, keyed by entry name.
22
+ def declarations
23
+ @declarations ||= {}
24
+ end
25
+
26
+ # Records one declaration. Redeclaring an identical entry is a no-op
27
+ # (a .loki file loaded twice); a different entry under the same name
28
+ # is an error.
29
+ def declare(task, **settings)
30
+ spec = normalize(task, **settings)
31
+ name = spec[:name]
32
+ taken = declarations[name]
33
+ raise ArgumentError, "schedule: #{name.inspect} is already declared; give one a distinct as: name" if taken && taken != spec
34
+
35
+ declarations[name] = spec
36
+ end
37
+ end
38
+
39
+ # The class-level `schedule` declaration helper for Tasks.
40
+ module DSL
41
+ def schedule(task, **) = Schedule.declare(task, **)
42
+ end
43
+ end
44
+ end
data/lib/asgard/shell.rb CHANGED
@@ -8,17 +8,21 @@ module Asgard
8
8
  # Run a shell script. Multiline strings are passed to bash -c; single-line
9
9
  # strings are passed to system directly. Exits with the command's status
10
10
  # code on failure.
11
- def sh(script, silent: false)
11
+ #
12
+ # Pass exec: true to replace the current process instead of forking —
13
+ # useful for a task's final, long-running command (e.g. a dev server)
14
+ # so the asgard/ruby process doesn't sit resident in memory alongside it.
15
+ def sh(script, silent: false, exec: false)
12
16
  script = script.strip
13
17
  $stdout.puts script unless silent
18
+ argv = shell_argv(script)
14
19
 
15
- success = if script.include?("\n")
16
- system("bash", "-c", script)
17
- else
18
- system(script)
19
- end
20
-
21
- exit($CHILD_STATUS.exitstatus) unless success
20
+ if exec
21
+ $stdout.flush
22
+ Kernel.exec(*argv)
23
+ else
24
+ exit($CHILD_STATUS.exitstatus) unless system(*argv)
25
+ end
22
26
  end
23
27
 
24
28
  # Write +script+ to a tempfile and execute it with +interpreter+.
@@ -42,5 +46,14 @@ module Asgard
42
46
  exit($CHILD_STATUS.exitstatus) unless $CHILD_STATUS.success?
43
47
  end
44
48
  end
49
+
50
+ private
51
+
52
+ # The argv passed to system/exec for +script+: multi-line scripts run
53
+ # through `bash -c` so assignments carry across lines; single-line
54
+ # scripts run directly.
55
+ def shell_argv(script)
56
+ script.include?("\n") ? ["bash", "-c", script] : [script]
57
+ end
45
58
  end
46
59
  end
data/lib/asgard/tasks.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "schedule"
4
+
3
5
  # Tasks is the single conventional entry point for all .loki files.
4
6
  # It is pre-defined by the gem so .loki files never need to declare a class.
5
7
  # Auxiliary *.loki files define modules which are imported into Tasks.
@@ -32,4 +34,14 @@ class Tasks < Asgard::Base
32
34
  default: false,
33
35
  desc: "Diagnose .loki resolution, imports, and task definitions for the CWD, then exit"
34
36
  no_negate :doctor
37
+
38
+ # Class-level `schedule :task, at: "HH:MM"` declarations (see Asgard::Schedule).
39
+ extend Asgard::Schedule::DSL
40
+
41
+ # Gem-owned, so the command is _schedule; the map keeps `asgard schedule`
42
+ # as the name users type, and ancestor_name keeps it in subcommand help.
43
+ desc "schedule SUBCOMMAND", "Manage schedules declared with `schedule :task, ...` (launchd on macOS, systemd on Linux)"
44
+ subcommand "_schedule", Asgard::Schedule::Commands
45
+ map "schedule" => :_schedule
46
+ Asgard::Schedule::Commands.commands.each_value { |command| command.ancestor_name = "schedule" }
35
47
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Asgard
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/asgard.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "asgard/errors"
3
4
  require_relative "asgard/version"
4
5
  require_relative "asgard/kernel_methods"
5
6
  require_relative "asgard/shell"
@@ -8,9 +9,6 @@ require_relative "asgard/tasks"
8
9
  require_relative "asgard/doctor"
9
10
 
10
11
  module Asgard
11
- class Error < StandardError; end
12
- class CircularDependencyError < Error; end
13
-
14
12
  # Search the current directory and its ancestors for a .loki task file.
15
13
  # Returns the path string, or nil if not found.
16
14
  def self.find_task_file
@@ -35,10 +33,17 @@ module Asgard
35
33
  newly_defined = Asgard::Base.subclasses - before
36
34
  (newly_defined + [Tasks]).uniq.each(&:validate_deps!)
37
35
  Tasks._reset_ran!
38
- Tasks.start(argv)
36
+ result = Tasks.start(argv)
37
+ # Quality-gate convention: a task signals failure by returning :fail
38
+ # (see dev/quality.loki's *_check tasks). Surface that as a nonzero
39
+ # exit so callers (CI, cross-repo runners) can rely on $?.
40
+ exit(1) if result == :fail
41
+ result
39
42
  rescue CircularDependencyError => e
40
43
  abort "asgard: circular dependency — #{e.message}"
41
44
  rescue Error => e
42
45
  abort "asgard: #{e.message}"
46
+ rescue Interrupt
47
+ exit(130)
43
48
  end
44
49
  end
data/mkdocs.yml CHANGED
@@ -158,6 +158,7 @@ nav:
158
158
  - Subcommands: subcommands.md
159
159
  - Shell Helpers: shell.md
160
160
  - Environment: environment.md
161
+ - Scheduled Tasks: schedule.md
161
162
  - Task Files: task-files.md
162
163
  - API Reference: api.md
163
164
  - Examples: examples.md
data/quality.loki CHANGED
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
  # Quality gate tasks — imported by .loki
3
3
 
4
+ require "shellwords"
5
+
4
6
  class Tasks
7
+ # One process for every test/test_*.rb, so SimpleCov sees the whole suite.
8
+ TEST_LOADER = %q(Dir["test/test_*.rb"].each { |f| require File.expand_path(f) }).shellescape.freeze
9
+
5
10
  desc "Run the test suite"
6
11
  def test_check
7
- output = `bundle exec ruby -Ilib:test test/test_asgard.rb 2>&1`
12
+ output = `bundle exec ruby -Ilib:test -e #{TEST_LOADER} 2>&1`
8
13
  result = $?.success? ? :pass : :fail
9
14
  File.write("test_output.txt", output)
10
15
  summary = output.lines.reverse.find { |l| l =~ /\d+ runs,/ }&.strip || result.to_s
@@ -14,7 +19,7 @@ class Tasks
14
19
 
15
20
  desc "Run the test suite with verbose output"
16
21
  def test_verbose
17
- sh "bundle exec ruby -Ilib:test test/test_asgard.rb -v"
22
+ sh "bundle exec ruby -Ilib:test -e #{TEST_LOADER} -- -v"
18
23
  end
19
24
 
20
25
  desc "Run every *_check quality gate task in parallel"
data/quality_rails.loki CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
- # Rails-specific quality gate tasks — imported by .loki only when the Rails
3
- # constant is defined.
2
+ # Rails-specific quality gate tasks — imported by .loki only when RAILS_ROOT
3
+ # is set. asgard runs as a separate process outside the app it's checking,
4
+ # so `defined?(Rails)` never sees the app's Rails constant.
4
5
  #
5
6
  # `quality` (in quality.loki) discovers every *_check task at run time by
6
7
  # introspecting Tasks.all_commands, rather than a fixed depends_on list — so
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asgard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer
@@ -40,7 +40,8 @@ dependencies:
40
40
  description: |
41
41
  A powerful Ruby-based task runner for any kind of project with task dependency tracking
42
42
  and concurrent execution of designated tasks. Uses Thor for its rich CLI options, var
43
- declarations, dotenv, sh/shebang helpers, and importable task files.
43
+ declarations, dotenv, sh/shebang helpers, importable task files, and scheduled
44
+ execution of tasks via launchd (macOS) or systemd timers (Linux).
44
45
  email:
45
46
  - dewayne@vanhoozer.me
46
47
  executables:
@@ -62,6 +63,7 @@ files:
62
63
  - bin/asgard
63
64
  - bin/console
64
65
  - bin/setup
66
+ - doc_tasks.loki
65
67
  - docs/api.md
66
68
  - docs/assets/css/custom.css
67
69
  - docs/assets/images/asgard.jpg
@@ -73,6 +75,7 @@ files:
73
75
  - docs/helpers.md
74
76
  - docs/index.md
75
77
  - docs/options.md
78
+ - docs/schedule.md
76
79
  - docs/shell.md
77
80
  - docs/subcommands.md
78
81
  - docs/task-files.md
@@ -83,6 +86,8 @@ files:
83
86
  - examples/bad.loki
84
87
  - examples/concurrent.loki
85
88
  - examples/db_subcommands.loki
89
+ - examples/depends_on_block/bad/.loki
90
+ - examples/depends_on_block/good/.loki
86
91
  - examples/env_usage.loki
87
92
  - examples/kitchen_sink.loki
88
93
  - examples/server_subcommands.loki
@@ -100,7 +105,13 @@ files:
100
105
  - lib/asgard/doctor.rb
101
106
  - lib/asgard/doctor/report.rb
102
107
  - lib/asgard/doctor/task_sections.rb
108
+ - lib/asgard/errors.rb
103
109
  - lib/asgard/kernel_methods.rb
110
+ - lib/asgard/schedule.rb
111
+ - lib/asgard/schedule/commands.rb
112
+ - lib/asgard/schedule/declaration.rb
113
+ - lib/asgard/schedule/launchd.rb
114
+ - lib/asgard/schedule/systemd.rb
104
115
  - lib/asgard/shell.rb
105
116
  - lib/asgard/tasks.rb
106
117
  - lib/asgard/version.rb
@@ -131,7 +142,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
142
  - !ruby/object:Gem::Version
132
143
  version: '0'
133
144
  requirements: []
134
- rubygems_version: 4.0.19
145
+ rubygems_version: 4.0.21
135
146
  specification_version: 4
136
147
  summary: A powerful Ruby-based task runner
137
148
  test_files: []