do_run 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +674 -0
- data/README.md +244 -0
- data/bin/do +10 -0
- data/lib/do/cli.rb +448 -0
- data/lib/do/config.rb +163 -0
- data/lib/do/error.rb +14 -0
- data/lib/do/executor.rb +36 -0
- data/lib/do/manager.rb +114 -0
- data/lib/do/scheduler.rb +93 -0
- data/lib/do/systemd.rb +214 -0
- data/lib/do/task.rb +53 -0
- data/lib/do/validator.rb +124 -0
- data/lib/do/version.rb +3 -0
- data/lib/do.rb +13 -0
- metadata +85 -0
data/lib/do/config.rb
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require 'toml-rb'
|
|
2
|
+
require_relative 'task'
|
|
3
|
+
require_relative 'error'
|
|
4
|
+
|
|
5
|
+
module Do
|
|
6
|
+
# Loads and represents the declarative TOML configuration.
|
|
7
|
+
#
|
|
8
|
+
# The TOML file is the source of truth. Everything else `do` produces is a
|
|
9
|
+
# derived artifact. This class only parses the file; validation is performed
|
|
10
|
+
# by {Validator}.
|
|
11
|
+
class Config
|
|
12
|
+
KNOWN_TASK_FIELDS = %w[
|
|
13
|
+
command schedule time day working_directory environment enabled
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Default location when no path is given.
|
|
17
|
+
def self.default_path
|
|
18
|
+
dir = ENV.fetch('XDG_CONFIG_HOME', nil)
|
|
19
|
+
dir = File.join(Dir.home, '.config') if dir.nil? || dir.empty?
|
|
20
|
+
File.join(dir, 'do', 'config.toml')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Array<String>] validation errors found while parsing.
|
|
24
|
+
def self.parse(raw_text, source: 'config.toml')
|
|
25
|
+
parsed = TomlRB.parse(raw_text)
|
|
26
|
+
new(parsed, source: source)
|
|
27
|
+
rescue TomlRB::ParseError, Psych::SyntaxError => e
|
|
28
|
+
raise ValidationError, ["Malformed TOML in #{source}: #{e.message}"]
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
raise ValidationError, ["Failed to parse #{source}: #{e.message}"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.load(path = default_path)
|
|
34
|
+
raise Error, "Configuration file does not exist: #{path}" unless File.exist?(path)
|
|
35
|
+
|
|
36
|
+
raw = File.read(path)
|
|
37
|
+
config = parse(raw, source: path)
|
|
38
|
+
config.path = path
|
|
39
|
+
config
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :raw, :tasks
|
|
43
|
+
attr_accessor :path
|
|
44
|
+
|
|
45
|
+
def initialize(raw, source: nil)
|
|
46
|
+
@raw = raw || {}
|
|
47
|
+
@source = source
|
|
48
|
+
@tasks = build_tasks(@raw['tasks'])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def task(name)
|
|
52
|
+
@tasks.find { |t| t.name == name }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def [](key)
|
|
56
|
+
@raw[key]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Edit a scalar field inside a task's table without re-serializing the
|
|
60
|
+
# whole file (preserving user comments/formatting elsewhere). Used by
|
|
61
|
+
# `enable`/`disable`.
|
|
62
|
+
def set_task_field(task_name, field, toml_value)
|
|
63
|
+
text = File.read(@path)
|
|
64
|
+
idx = block_start_index(text, task_name)
|
|
65
|
+
return edit_field_anywhere(text, task_name, field, toml_value) if idx.nil?
|
|
66
|
+
|
|
67
|
+
lines = text.lines
|
|
68
|
+
block_end = block_end_index(lines, idx)
|
|
69
|
+
match = (idx...block_end).find { |i| lines[i] =~ /\A#{Regexp.escape(field)}\s*=/ }
|
|
70
|
+
if match
|
|
71
|
+
lines[match] = "#{field} = #{toml_value}\n"
|
|
72
|
+
else
|
|
73
|
+
lines.insert(idx + 1, "#{field} = #{toml_value}\n")
|
|
74
|
+
end
|
|
75
|
+
File.write(@path, lines.join)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Remove a task's table from the file. Used by `do remove`. Also removes
|
|
79
|
+
# any nested tables under the same task (e.g. `[tasks.name.environment]`).
|
|
80
|
+
def remove_task(task_name)
|
|
81
|
+
text = File.read(@path)
|
|
82
|
+
idx = block_start_index(text, task_name)
|
|
83
|
+
raise Error, "task '#{task_name}' not present in configuration" if idx.nil?
|
|
84
|
+
|
|
85
|
+
lines = text.lines
|
|
86
|
+
block_end = task_block_end(lines, idx, task_name)
|
|
87
|
+
removed = lines[0...idx] + lines[block_end..]
|
|
88
|
+
# Collapse leftover blank lines around the removed block (best effort).
|
|
89
|
+
File.write(@path, removed.join.gsub(/\n{3,}/, "\n\n"))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Continue past nested tables that belong to the same task.
|
|
95
|
+
def task_block_end(lines, idx, task_name)
|
|
96
|
+
i = idx + 1
|
|
97
|
+
while i < lines.length
|
|
98
|
+
line = lines[i]
|
|
99
|
+
break if line.start_with?('[') && !line.start_with?("[tasks.#{task_name}.") &&
|
|
100
|
+
!line.start_with?("[tasks.\"#{task_name}\".")
|
|
101
|
+
|
|
102
|
+
i += 1
|
|
103
|
+
end
|
|
104
|
+
i
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def block_start_index(text, task_name)
|
|
108
|
+
re = /\[tasks\.#{Regexp.escape(task_name)}\]\s*\z|\[tasks\."#{Regexp.escape(task_name)}"\]\s*\z/
|
|
109
|
+
text.lines.index { |l| l.start_with?('[tasks.') && l =~ re }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def block_end_index(lines, idx)
|
|
113
|
+
i = idx + 1
|
|
114
|
+
i += 1 while i < lines.length && !lines[i].start_with?('[')
|
|
115
|
+
i
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Fallback: file may have been edited/scrambled; attempt a field swap
|
|
119
|
+
# outside a knowing block. Keeps commands non-destructive toward comments.
|
|
120
|
+
def edit_field_anywhere(text, task_name, field, toml_value)
|
|
121
|
+
header = "[tasks.#{task_name}]"
|
|
122
|
+
return unless text.include?(header)
|
|
123
|
+
|
|
124
|
+
_ = text
|
|
125
|
+
hmm = text.gsub(/^(\s*)#{Regexp.escape(field)}\s*=.*/, " #{field} = #{toml_value}")
|
|
126
|
+
File.write(@path, hmm) if hmm != text
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_tasks(raw_tasks)
|
|
130
|
+
return [] if raw_tasks.nil?
|
|
131
|
+
|
|
132
|
+
unless raw_tasks.is_a?(Hash)
|
|
133
|
+
raise ValidationError, ["'tasks' must be a table of task definitions in #{@source}"]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
raw_tasks.map do |name, fields|
|
|
137
|
+
fields ||= {}
|
|
138
|
+
unless fields.is_a?(Hash)
|
|
139
|
+
raise ValidationError, ["task '#{name}' definitions must be tables in #{@source}"]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
env = fields['environment']
|
|
143
|
+
unless env.nil? || env.is_a?(Hash)
|
|
144
|
+
raise ValidationError, ["environment for task '#{name}' must be a table in #{@source}"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
Task.new(
|
|
148
|
+
name: name.to_s,
|
|
149
|
+
command: fields['command'],
|
|
150
|
+
schedule: fields['schedule'],
|
|
151
|
+
time: fields['time'],
|
|
152
|
+
day: fields['day'],
|
|
153
|
+
working_directory: fields['working_directory'],
|
|
154
|
+
environment: env,
|
|
155
|
+
enabled: fields['enabled'],
|
|
156
|
+
raw: fields
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
rescue TypeError => e
|
|
160
|
+
raise ValidationError, ["Invalid configuration structure: #{e.message}"]
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
data/lib/do/error.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Do
|
|
2
|
+
# Top-level error for all normal user-facing failures.
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
class ValidationError < Error
|
|
7
|
+
attr_reader :errors
|
|
8
|
+
|
|
9
|
+
def initialize(errors)
|
|
10
|
+
@errors = Array(errors)
|
|
11
|
+
super(@errors.join("\n"))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/do/executor.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require 'shellwords'
|
|
2
|
+
require_relative 'error'
|
|
3
|
+
|
|
4
|
+
module Do
|
|
5
|
+
# Executes a task's command as an argument list, without a shell.
|
|
6
|
+
#
|
|
7
|
+
# The `command` string is split into argv tokens lexically (respecting
|
|
8
|
+
# quoting but performing no variable expansion, `~` expansion, or globbing).
|
|
9
|
+
# This is the safe, declarative default described in the spec. Tasks that
|
|
10
|
+
# genuinely need shell features must request them explicitly, e.g. by
|
|
11
|
+
# prefixing with `bash -lc '...'`.
|
|
12
|
+
class Executor
|
|
13
|
+
attr_reader :systemd
|
|
14
|
+
|
|
15
|
+
def initialize(systemd: Systemd.new)
|
|
16
|
+
@systemd = systemd
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Run the task immediately and return its exit status. The timer schedule
|
|
20
|
+
# is never altered by manual execution.
|
|
21
|
+
#
|
|
22
|
+
# @return [Integer] process exit status
|
|
23
|
+
def run(task)
|
|
24
|
+
argv = Shellwords.split(task.command)
|
|
25
|
+
raise Error, "task '#{task.name}' has an empty command" if argv.empty?
|
|
26
|
+
|
|
27
|
+
env_overrides = task.environment || {}
|
|
28
|
+
options = {}
|
|
29
|
+
options[:chdir] = File.expand_path(task.working_directory) if task.working_directory
|
|
30
|
+
|
|
31
|
+
pid = Process.spawn(env_overrides, *argv, options)
|
|
32
|
+
_pid, status = Process.wait2(pid)
|
|
33
|
+
status.exitstatus || 0
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/do/manager.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require_relative 'systemd'
|
|
3
|
+
require_relative 'validator'
|
|
4
|
+
require_relative 'error'
|
|
5
|
+
|
|
6
|
+
module Do
|
|
7
|
+
# Orchestrates the lifecycle of generated units against the systemd user
|
|
8
|
+
# manager. This is the layer that turns config into reality: writing unit
|
|
9
|
+
# files, detecting and removing stale `do`-managed units, reloading systemd,
|
|
10
|
+
# and enabling/disabling schedulers.
|
|
11
|
+
class Manager
|
|
12
|
+
attr_reader :systemd
|
|
13
|
+
|
|
14
|
+
def initialize(systemd: Systemd.new, config: nil)
|
|
15
|
+
@systemd = systemd
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_writer :config
|
|
20
|
+
|
|
21
|
+
# Apply the configuration to the user systemd manager.
|
|
22
|
+
#
|
|
23
|
+
# Never overwrites units without the `do` marker. Refuses to run when the
|
|
24
|
+
# configuration is invalid.
|
|
25
|
+
def reload(config = @config)
|
|
26
|
+
Validator.validate!(config)
|
|
27
|
+
dir = @systemd.unit_dir
|
|
28
|
+
FileUtils.mkdir_p(dir)
|
|
29
|
+
|
|
30
|
+
expected = {}
|
|
31
|
+
config.tasks.each do |task|
|
|
32
|
+
next unless task.scheduled?
|
|
33
|
+
|
|
34
|
+
expected[task.service_unit] =
|
|
35
|
+
UnitGenerator.service_content(task, source_path: config.path)
|
|
36
|
+
expected[task.timer_unit] = UnitGenerator.timer_content(task, source_path: config.path)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
write_units(dir, expected)
|
|
40
|
+
remove_stale_units(dir, expected.keys)
|
|
41
|
+
@systemd.daemon_reload
|
|
42
|
+
apply_enabled(config)
|
|
43
|
+
:ok
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Remove a task's generated units (if any) and reload. Does not touch the
|
|
47
|
+
# TOML configuration.
|
|
48
|
+
def unschedule(task, _config = @config)
|
|
49
|
+
remove_units_for(task)
|
|
50
|
+
@systemd.daemon_reload
|
|
51
|
+
:ok
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Remove all units a task owns, regardless of whether the task is
|
|
55
|
+
# scheduled. Used by `do remove`.
|
|
56
|
+
def remove_units_for(task)
|
|
57
|
+
[task.service_unit, task.timer_unit].each do |unit|
|
|
58
|
+
path = File.join(@systemd.unit_dir, unit)
|
|
59
|
+
FileUtils.rm_f(path) if File.exist?(path) && managed?(path)
|
|
60
|
+
@systemd.disable(unit) if @systemd.exists?(unit)
|
|
61
|
+
end
|
|
62
|
+
@systemd.daemon_reload
|
|
63
|
+
:ok
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def write_units(dir, expected)
|
|
69
|
+
expected.each do |unit, content|
|
|
70
|
+
path = File.join(dir, unit)
|
|
71
|
+
existing = File.exist?(path) ? File.read(path) : nil
|
|
72
|
+
File.write(path, content) if existing != content
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Remove `do`-managed units that are no longer expected (deleted task or
|
|
77
|
+
# a schedule was removed). Only files carrying the managed marker are
|
|
78
|
+
# considered; user-created units are never touched.
|
|
79
|
+
def remove_stale_units(dir, expected)
|
|
80
|
+
stale = Dir.glob(File.join(dir, 'do-*.{service,timer}')).select do |path|
|
|
81
|
+
basename = File.basename(path)
|
|
82
|
+
!expected.include?(basename) && managed?(path)
|
|
83
|
+
end
|
|
84
|
+
stale.each do |path|
|
|
85
|
+
FileUtils.rm_f(path)
|
|
86
|
+
disable_if_known(File.basename(path))
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def disable_if_known(unit)
|
|
91
|
+
@systemd.disable(unit) if @systemd.exists?(unit)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def managed?(path)
|
|
95
|
+
File.read(path).lines.any? { |l| l.include?('Managed by do') }
|
|
96
|
+
rescue Errno::ENOENT
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_enabled(config)
|
|
101
|
+
config.tasks.each do |task|
|
|
102
|
+
next unless task.scheduled?
|
|
103
|
+
|
|
104
|
+
if task.enabled?
|
|
105
|
+
@systemd.enable(task.timer_unit)
|
|
106
|
+
@systemd.start(task.timer_unit)
|
|
107
|
+
else
|
|
108
|
+
@systemd.disable(task.timer_unit)
|
|
109
|
+
@systemd.stop(task.timer_unit) if @systemd.active?(task.timer_unit)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/do/scheduler.rb
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
require_relative 'error'
|
|
2
|
+
|
|
3
|
+
module Do
|
|
4
|
+
# Translates a task's declarative schedule into a systemd calendar
|
|
5
|
+
# expression ({#on_calendar}) suitable for use in an `OnCalendar=` directive.
|
|
6
|
+
#
|
|
7
|
+
# `do` does not implement its own timer loop; it only maps a small, fixed set
|
|
8
|
+
# of schedule shapes onto systemd's native calendar syntax.
|
|
9
|
+
class Scheduler
|
|
10
|
+
TIME_RE = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
|
|
11
|
+
|
|
12
|
+
# @return [String, nil] the systemd OnCalendar expression, or nil when the
|
|
13
|
+
# task is manual-only.
|
|
14
|
+
def self.on_calendar(task)
|
|
15
|
+
case task.schedule
|
|
16
|
+
when nil, '' then nil
|
|
17
|
+
when 'once' then once_calendar
|
|
18
|
+
when 'hourly' then hourly_calendar(task)
|
|
19
|
+
when 'daily' then daily_calendar(task)
|
|
20
|
+
when 'weekly' then weekly_calendar(task)
|
|
21
|
+
when 'monthly' then monthly_calendar(task)
|
|
22
|
+
else
|
|
23
|
+
raise Error, "unsupported schedule '#{task.schedule}'"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.once_calendar
|
|
28
|
+
# OnActiveSec=0 fires the service exactly once, when the timer is
|
|
29
|
+
# activated. The reload/enable workflow activates the timer, so `once`
|
|
30
|
+
# means "run once now when scheduled", without repeating.
|
|
31
|
+
'OnActiveSec=0'
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.hourly_calendar(task)
|
|
35
|
+
_, min = split_time(task, fallback: [0, 0])
|
|
36
|
+
"*-*-* *:#{pad(min)}:00"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.daily_calendar(task)
|
|
40
|
+
hour, min = split_time(task, fallback: [0, 0])
|
|
41
|
+
"*-*-* #{pad(hour)}:#{pad(min)}:00"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.weekly_calendar(task)
|
|
45
|
+
hour, min = split_time(task, fallback: [0, 0])
|
|
46
|
+
dow = weekday_short(task)
|
|
47
|
+
"#{dow} *-*-* #{pad(hour)}:#{pad(min)}:00"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.monthly_calendar(task)
|
|
51
|
+
hour, min = split_time(task, fallback: [0, 0])
|
|
52
|
+
dom = monthly_day(task)
|
|
53
|
+
"*-*-#{dom} #{pad(hour)}:#{pad(min)}:00"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.weekday_short(task)
|
|
57
|
+
day = task.day&.to_s&.downcase
|
|
58
|
+
Task::WEEKDAY_NUMBERS[day] or
|
|
59
|
+
raise Error, "invalid day '#{task.day}'; expected a day name like 'monday'"
|
|
60
|
+
day[0, 3].capitalize
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.weekday_number(task)
|
|
64
|
+
day = task.day&.to_s&.downcase
|
|
65
|
+
Task::WEEKDAY_NUMBERS[day] or
|
|
66
|
+
raise Error, "invalid day '#{task.day}'; expected a day name like 'monday'"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.monthly_day(task)
|
|
70
|
+
day = task.day&.to_s
|
|
71
|
+
if day.nil? || day.empty?
|
|
72
|
+
'1'
|
|
73
|
+
elsif day =~ /\A([1-9]|[12]\d|3[01])\z/
|
|
74
|
+
day
|
|
75
|
+
else
|
|
76
|
+
raise Error, "invalid monthly day '#{task.day}'; expected a day-of-month 1-31"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.split_time(task, fallback:)
|
|
81
|
+
return fallback if task.time.nil? || task.time.empty?
|
|
82
|
+
|
|
83
|
+
m = TIME_RE.match(task.time.to_s)
|
|
84
|
+
raise Error, "invalid time '#{task.time}'; expected HH:MM" unless m
|
|
85
|
+
|
|
86
|
+
[m[1].to_i, m[2].to_i]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.pad(value)
|
|
90
|
+
value.to_s.rjust(2, '0')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/do/systemd.rb
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
require_relative 'error'
|
|
2
|
+
require_relative 'scheduler'
|
|
3
|
+
|
|
4
|
+
module Do
|
|
5
|
+
# Generates the systemd user unit files that `do` manages, and wraps the
|
|
6
|
+
# `systemctl --user` / `journalctl --user` commands used to control them.
|
|
7
|
+
#
|
|
8
|
+
# Generated unit files carry a clear "Managed by do" marker so they can be
|
|
9
|
+
# safely detected, updated, and removed later. Units not carrying this marker
|
|
10
|
+
# are never touched by `do`.
|
|
11
|
+
module UnitGenerator
|
|
12
|
+
MANAGED_MARKER = '# Managed by do. Do not edit by hand.'.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def service_content(task, source_path: nil)
|
|
17
|
+
env_lines = task.environment.map { |k, v| "Environment=#{k}=#{v}" }
|
|
18
|
+
[
|
|
19
|
+
managed_header(source_path),
|
|
20
|
+
'',
|
|
21
|
+
'[Unit]',
|
|
22
|
+
"Description=do task: #{task.name}",
|
|
23
|
+
'',
|
|
24
|
+
'[Service]',
|
|
25
|
+
'Type=oneshot',
|
|
26
|
+
"ExecStart=#{task.command}"
|
|
27
|
+
].concat(
|
|
28
|
+
if task.working_directory
|
|
29
|
+
["WorkingDirectory=#{task.working_directory}"]
|
|
30
|
+
else
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
).concat(
|
|
34
|
+
env_lines,
|
|
35
|
+
['', '[Install]', 'WantedBy=default.target']
|
|
36
|
+
).join("\n") + "\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def timer_content(task, source_path: nil)
|
|
40
|
+
on_calendar = Scheduler.on_calendar(task)
|
|
41
|
+
calendar_line =
|
|
42
|
+
if on_calendar&.start_with?('OnActiveSec=')
|
|
43
|
+
on_calendar
|
|
44
|
+
else
|
|
45
|
+
"OnCalendar=#{on_calendar}"
|
|
46
|
+
end
|
|
47
|
+
[
|
|
48
|
+
managed_header(source_path),
|
|
49
|
+
'',
|
|
50
|
+
'[Unit]',
|
|
51
|
+
"Description=do task: #{task.name} (timer)",
|
|
52
|
+
'',
|
|
53
|
+
'[Timer]',
|
|
54
|
+
calendar_line
|
|
55
|
+
].concat(
|
|
56
|
+
if task.schedule == 'once'
|
|
57
|
+
[]
|
|
58
|
+
else
|
|
59
|
+
['Persistent=true']
|
|
60
|
+
end
|
|
61
|
+
).push(
|
|
62
|
+
'', '[Install]', 'WantedBy=timers.target'
|
|
63
|
+
).join("\n") + "\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def managed_header(source_path)
|
|
67
|
+
lines = [MANAGED_MARKER]
|
|
68
|
+
lines << "# Source: #{source_path}" if source_path
|
|
69
|
+
lines.join("\n")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Wraps systemd user session commands. All calls run through the current
|
|
74
|
+
# user's systemd user manager; `do` never touches system-wide units.
|
|
75
|
+
class Systemd
|
|
76
|
+
attr_reader :systemctl_bin, :journalctl_bin
|
|
77
|
+
|
|
78
|
+
def initialize(systemctl_bin: ENV.fetch('SYSTEMCTL', 'systemctl'),
|
|
79
|
+
journalctl_bin: ENV.fetch('JOURNALCTL', 'journalctl'))
|
|
80
|
+
@systemctl_bin = systemctl_bin
|
|
81
|
+
@journalctl_bin = journalctl_bin
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def user_available?
|
|
85
|
+
shell_system([@systemctl_bin, '--user', 'is-system-running'],
|
|
86
|
+
out: File::NULL, err: File::NULL)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def unit_dir
|
|
90
|
+
dir = ENV.fetch('XDG_CONFIG_HOME', nil)
|
|
91
|
+
dir = File.join(Dir.home, '.config') if dir.nil? || dir.empty?
|
|
92
|
+
File.join(dir, 'systemd', 'user')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def exists?(unit)
|
|
96
|
+
run(['show', '-p', 'Id', '--no-pager', unit]).success?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def enabled?(unit)
|
|
100
|
+
run(['is-enabled', '--no-pager', unit]).success?
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def active?(unit)
|
|
104
|
+
run(['is-active', '--no-pager', unit]).success?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def enable(unit)
|
|
108
|
+
run(['enable', '--now', unit])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def disable(unit)
|
|
112
|
+
run(['disable', '--now', unit])
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def start(unit)
|
|
116
|
+
run(['start', unit])
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def stop(unit)
|
|
120
|
+
run(['stop', unit])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def daemon_reload
|
|
124
|
+
run(['daemon-reload'])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def status(unit, extra: '--no-pager')
|
|
128
|
+
run(['status', unit, extra])
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Time, nil] formatted next-elapsed time from a timer unit.
|
|
132
|
+
def next_time(unit)
|
|
133
|
+
require 'time'
|
|
134
|
+
value = show_prop(unit, 'NextElapseUSecRealtime')
|
|
135
|
+
return nil if value.nil? || value.empty?
|
|
136
|
+
|
|
137
|
+
Time.parse(value).localtime
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [Time, nil] when the service last transitioned to active.
|
|
143
|
+
def last_active(unit)
|
|
144
|
+
value = show_prop(unit, 'ActiveEnterTimestamp')
|
|
145
|
+
value.nil? || value.empty? ? nil : Time.parse(value).localtime
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @return [Integer, nil] exit status of the last run of a service.
|
|
149
|
+
def last_exit_status(unit)
|
|
150
|
+
value = show_prop(unit, 'ExecMainStatus')
|
|
151
|
+
value.nil? || value.empty? ? nil : value.to_i
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def active_state(unit)
|
|
155
|
+
show_prop(unit, 'ActiveState')
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [String, nil] a whitespace-trimmed value for +prop+.
|
|
159
|
+
def show_prop(unit, prop)
|
|
160
|
+
require 'time'
|
|
161
|
+
out = run(['show', '-p', prop, '--no-pager', unit]).out
|
|
162
|
+
m = out.match(/^#{Regexp.escape(prop)}=(.+)$/)
|
|
163
|
+
m ? m[1].strip : nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def logs(unit, follow: false, tail: nil)
|
|
167
|
+
args = ['-u', unit, '--no-pager']
|
|
168
|
+
args << '-f' if follow
|
|
169
|
+
args << "-n #{tail}" if tail
|
|
170
|
+
system(*[@journalctl_bin, '--user', *args].flatten)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Run a systemctl --user command, returning an Open3 result-like struct
|
|
174
|
+
# exposing #success?, #out, #err, #exitstatus.
|
|
175
|
+
def run(args)
|
|
176
|
+
require 'open3'
|
|
177
|
+
@out, @err, @status = Open3.capture3(@systemctl_bin, '--user', *args)
|
|
178
|
+
OpenStructish.new(@status.exitstatus, @out, @err, @status.success?)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def shell_system(args, **opts)
|
|
184
|
+
SystemRaw.new(args, opts)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Minimal result holder so tests and callers share one shape.
|
|
188
|
+
class OpenStructish
|
|
189
|
+
attr_reader :exitstatus, :out, :err
|
|
190
|
+
|
|
191
|
+
def initialize(exitstatus, out, err, success)
|
|
192
|
+
@exitstatus = exitstatus
|
|
193
|
+
@out = out
|
|
194
|
+
@err = err
|
|
195
|
+
@success = success
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def success?
|
|
199
|
+
@success
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
class SystemRaw
|
|
204
|
+
def initialize(args, opts)
|
|
205
|
+
@args = args
|
|
206
|
+
@ok = system(*args, **opts)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def success?
|
|
210
|
+
@ok
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
data/lib/do/task.rb
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Do
|
|
2
|
+
# A single declared task parsed from the TOML configuration.
|
|
3
|
+
#
|
|
4
|
+
# Tasks are plain Ruby objects; the {Validator} is responsible for deciding
|
|
5
|
+
# whether their field values are acceptable. This class only carries data
|
|
6
|
+
# and exposes a few derived helpers.
|
|
7
|
+
class Task
|
|
8
|
+
SCHEDULES = %w[once hourly daily weekly monthly].freeze
|
|
9
|
+
WEEKDAY_NAMES = %w[monday tuesday wednesday thursday friday saturday sunday].freeze
|
|
10
|
+
WEEKDAY_NUMBERS = {
|
|
11
|
+
'monday' => 1, 'tuesday' => 2, 'wednesday' => 3, 'thursday' => 4,
|
|
12
|
+
'friday' => 5, 'saturday' => 6, 'sunday' => 7
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :name, :command, :schedule, :time, :day,
|
|
16
|
+
:working_directory, :environment, :raw
|
|
17
|
+
|
|
18
|
+
def initialize(name:, command:, schedule: nil, time: nil, day: nil,
|
|
19
|
+
working_directory: nil, environment: {}, enabled: nil, raw: {})
|
|
20
|
+
@name = name
|
|
21
|
+
@command = command
|
|
22
|
+
@schedule = schedule
|
|
23
|
+
@time = time
|
|
24
|
+
@day = day
|
|
25
|
+
@working_directory = working_directory
|
|
26
|
+
@environment = environment || {}
|
|
27
|
+
@enabled = enabled
|
|
28
|
+
@raw = raw
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# `enabled` is true by default for scheduled tasks and false for
|
|
32
|
+
# manual-only (unscheduled) tasks.
|
|
33
|
+
def enabled?
|
|
34
|
+
return scheduled? if @enabled.nil?
|
|
35
|
+
return @enabled == 'true' || @enabled == true if @enabled.is_a?(String)
|
|
36
|
+
|
|
37
|
+
!!@enabled
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def scheduled?
|
|
41
|
+
!@schedule.nil? && @schedule != ''
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Return the service unit name systemd will use for this task.
|
|
45
|
+
def service_unit
|
|
46
|
+
"do-#{name}.service"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def timer_unit
|
|
50
|
+
"do-#{name}.timer"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|