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/README.md
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# do
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
`do` is a **declarative execution layer for Linux**. It translates a small
|
|
10
|
+
TOML configuration into **systemd user services and timers**, giving you a
|
|
11
|
+
friendly control plane without reinventing scheduling.
|
|
12
|
+
|
|
13
|
+
`do` is *not* a scheduler, service manager, shell, or configuration-management
|
|
14
|
+
system. It is the declarative control plane on top of systemd.
|
|
15
|
+
|
|
16
|
+
## Why it exists
|
|
17
|
+
|
|
18
|
+
Linux already has excellent primitives for running and scheduling work:
|
|
19
|
+
systemd services and timers. What's missing is a small, human-friendly,
|
|
20
|
+
declarative way to describe a task and have that translated into systemd units
|
|
21
|
+
automatically. `do` fills that gap. Say what to run, when to run it, and under
|
|
22
|
+
what environment/who directory; `do` turns that into working units.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
gem install do
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The executable is `do`.
|
|
31
|
+
|
|
32
|
+
For development:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bundle exec ruby bin/do
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Linux with a running systemd **user** session.
|
|
39
|
+
|
|
40
|
+
## Configuration format
|
|
41
|
+
|
|
42
|
+
Configuration lives in a single TOML file:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
~/.config/do/config.toml
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The TOML file is the **source of truth**. Generated systemd units are derived
|
|
49
|
+
artifacts and are never the primary configuration mechanism.
|
|
50
|
+
|
|
51
|
+
### Example
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[tasks.backup]
|
|
55
|
+
command = "restic backup ~/Documents"
|
|
56
|
+
schedule = "daily"
|
|
57
|
+
time = "02:00"
|
|
58
|
+
enabled = true
|
|
59
|
+
|
|
60
|
+
[tasks.update]
|
|
61
|
+
command = "do update"
|
|
62
|
+
schedule = "weekly"
|
|
63
|
+
day = "sunday"
|
|
64
|
+
time = "10:00"
|
|
65
|
+
enabled = true
|
|
66
|
+
|
|
67
|
+
[tasks.report]
|
|
68
|
+
command = "/home/eugene/bin/generate-report"
|
|
69
|
+
working_directory = "/home/eugene/projects/report"
|
|
70
|
+
schedule = "monthly"
|
|
71
|
+
day = "1"
|
|
72
|
+
time = "18:00"
|
|
73
|
+
|
|
74
|
+
[tasks.report.environment]
|
|
75
|
+
REPORT_FORMAT = "markdown"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Task schema
|
|
79
|
+
|
|
80
|
+
| Field | Required | Notes |
|
|
81
|
+
|---------------------|----------|--------------------------------------------------------------|
|
|
82
|
+
| `command` | yes | Command to execute (an argument list, executed without a shell) |
|
|
83
|
+
| `schedule` | no | `once`, `hourly`, `daily`, `weekly`, `monthly`. Omit for manual-only (default). |
|
|
84
|
+
| `time` | no | Clock time in `HH:MM`. Defaults to `00:00`. |
|
|
85
|
+
| `day` | no | Weekday name (`sunday`) for `weekly`; day-of-month (`1`) for `monthly`. |
|
|
86
|
+
| `working_directory` | no | Directory the command runs in. |
|
|
87
|
+
| `environment` | no | Table mapping `KEY = "value"` to export to the command. |
|
|
88
|
+
| `enabled` | no | `true` for scheduled tasks that should run, `false` otherwise. |
|
|
89
|
+
|
|
90
|
+
**`enabled` default behavior:** defaults to `true` when the task has a
|
|
91
|
+
schedule, and `false` for manual-only (unscheduled) tasks. An explicit value
|
|
92
|
+
always wins.
|
|
93
|
+
|
|
94
|
+
**Commands are not shell strings.** A `command` is split into an argument list
|
|
95
|
+
lexically. No `~` expansion, no globbing, no environment interpolation, no
|
|
96
|
+
shell operators. If you genuinely need shell behavior, ask for it explicitly:
|
|
97
|
+
|
|
98
|
+
```toml
|
|
99
|
+
command = "bash -lc 'commit && push'"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Running a task
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
do run backup # run immediately; returns the command's exit status
|
|
106
|
+
do backup # shorthand for `do run backup`
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Manual execution never alters a scheduled timer.
|
|
110
|
+
|
|
111
|
+
## Scheduling a task
|
|
112
|
+
|
|
113
|
+
Schedules are declared in the TOML file; `do reload` turns them into systemd
|
|
114
|
+
user timers.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
do reload # generate/update units, remove stale ones, reload systemd
|
|
118
|
+
do schedule backup # ensure a scheduled task's timer exists and is enabled
|
|
119
|
+
do unschedule backup # disable and remove the timer (keeps the TOML entry)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Schedule types and their systemd `OnCalendar=` mappings:
|
|
123
|
+
|
|
124
|
+
| Schedule | Meaning | Example |
|
|
125
|
+
|----------|-------------------------------------------|----------------------------------|
|
|
126
|
+
| `once` | Run once when the timer is activated | `OnActiveSec=0` |
|
|
127
|
+
| `hourly` | Every hour at the configured minute | `*-*-* *:30:00` |
|
|
128
|
+
| `daily` | Every day at `time` | `*-*-* 02:00:00` |
|
|
129
|
+
| `weekly` | Every week on `day` at `time` | `Sun *-*-* 10:00:00` |
|
|
130
|
+
| `monthly`| Every month on day-of-month `day` at time | `*-*-1 09:00:00` |
|
|
131
|
+
|
|
132
|
+
`do` never implements its own timer loop; it always maps onto systemd's native
|
|
133
|
+
calendar syntax.
|
|
134
|
+
|
|
135
|
+
## Enabling / disabling tasks
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
do enable backup # activates the timer (writes enabled = true, reloads)
|
|
139
|
+
do disable backup # deactivates the timer (writes enabled = false, reloads)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Disabling never deletes the TOML configuration.
|
|
143
|
+
|
|
144
|
+
## Inspecting status
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
do list # table of tasks, status, schedule, next run
|
|
148
|
+
do status backup # enabled/disabled, running, schedule, next/last run
|
|
149
|
+
do validate # validate the configuration without changing anything
|
|
150
|
+
do doctor # check the environment and configuration
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Viewing logs
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
do logs backup # journalctl --user for the task's service
|
|
157
|
+
do logs backup --follow # follow new lines
|
|
158
|
+
do logs backup --tail 100 # last 100 lines
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Removing tasks
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
do remove backup # asks for confirmation
|
|
165
|
+
do remove backup --yes # skip confirmation; removes TOML entry + units
|
|
166
|
+
do remove backup --yes --keep-config # keep the TOML entry, remove only units
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Removal requires confirmation unless `--yes` is provided.
|
|
170
|
+
|
|
171
|
+
## Other commands
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
do validate # validate the configuration
|
|
175
|
+
do reload # sync units with the configuration
|
|
176
|
+
do doctor # environment/configuration diagnostics
|
|
177
|
+
do version # print the version
|
|
178
|
+
do help # show all commands
|
|
179
|
+
do config path # print the config file path
|
|
180
|
+
do config show # print the parsed/effective configuration
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Architecture
|
|
184
|
+
|
|
185
|
+
```
|
|
186
|
+
do/
|
|
187
|
+
├── bin/do # executable (thin ARGV dispatch + CLI)
|
|
188
|
+
├── lib/do.rb # requires the library
|
|
189
|
+
├── lib/do/
|
|
190
|
+
│ ├── cli.rb # Thor CLI + subcommands
|
|
191
|
+
│ ├── config.rb # TOML parsing, default path, config editing
|
|
192
|
+
│ ├── task.rb # task model + `enabled` defaults
|
|
193
|
+
│ ├── scheduler.rb # declarative schedule -> OnCalendar expressions
|
|
194
|
+
│ ├── systemd.rb # unit generation + systemctl/journalctl wrapper
|
|
195
|
+
│ ├── executor.rb # safe no-shell command execution
|
|
196
|
+
│ ├── validator.rb # stateless configuration validation
|
|
197
|
+
│ └── manager.rb # applies config to the systemd user manager
|
|
198
|
+
├── spec/ # RSpec suite (systemd mocked)
|
|
199
|
+
├── do.gemspec
|
|
200
|
+
├── Gemfile
|
|
201
|
+
├── README.md
|
|
202
|
+
└── CHANGELOG.md
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## systemd integration
|
|
206
|
+
|
|
207
|
+
Each scheduled task generates two user units under
|
|
208
|
+
`~/.config/systemd/user/`:
|
|
209
|
+
|
|
210
|
+
```text
|
|
211
|
+
do-backup.service
|
|
212
|
+
do-backup.timer
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`do` manages units through the current user's systemd user manager
|
|
216
|
+
(`systemctl --user ...`), never system-wide units, never requiring root. All
|
|
217
|
+
generated files carry a `Managed by do` marker. `do reload`:
|
|
218
|
+
|
|
219
|
+
1. Parses and validates the TOML.
|
|
220
|
+
2. Generates/updates the required units.
|
|
221
|
+
3. Removes obsolete `do`-managed units.
|
|
222
|
+
4. Runs `systemctl --user daemon-reload`.
|
|
223
|
+
5. Enables/disables timers according to configuration.
|
|
224
|
+
|
|
225
|
+
Units **not** carrying the `do` marker are never touched.
|
|
226
|
+
|
|
227
|
+
## v1.0 limitations
|
|
228
|
+
|
|
229
|
+
- Linux with a systemd user session is required.
|
|
230
|
+
- Command execution is shell-free by default; shell behavior must be requested
|
|
231
|
+
explicitly.
|
|
232
|
+
- No retries, DAGs, event triggers, secrets, remote/container/distributed
|
|
233
|
+
execution, or a persistent daemon.
|
|
234
|
+
- `once` runs once on timer activation (no calendar persistence).
|
|
235
|
+
- No automatic dependency installation.
|
|
236
|
+
|
|
237
|
+
## Development
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
bundle install
|
|
241
|
+
bundle exec rspec
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The normal test suite mocks systemd and does not require a real user session.
|
data/bin/do
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative '../lib/do'
|
|
5
|
+
|
|
6
|
+
# `do <task>` is shorthand for `do run <task>` when the first argument is not
|
|
7
|
+
# a built-in command.
|
|
8
|
+
ARGV.unshift('run') if ARGV[0] && !ARGV[0].start_with?('-') && !Do::CLI::BUILTINS.include?(ARGV[0])
|
|
9
|
+
|
|
10
|
+
Do::CLI.start
|
data/lib/do/cli.rb
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
require 'thor'
|
|
2
|
+
require 'shellwords'
|
|
3
|
+
require 'time'
|
|
4
|
+
require_relative 'version'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative 'task'
|
|
8
|
+
require_relative 'scheduler'
|
|
9
|
+
require_relative 'systemd'
|
|
10
|
+
require_relative 'validator'
|
|
11
|
+
require_relative 'executor'
|
|
12
|
+
require_relative 'manager'
|
|
13
|
+
|
|
14
|
+
module Do
|
|
15
|
+
# `do config path` / `do config show`.
|
|
16
|
+
class ConfigCommand < Thor
|
|
17
|
+
namespace 'config'
|
|
18
|
+
|
|
19
|
+
desc 'path', 'Print the configuration file path.'
|
|
20
|
+
def path
|
|
21
|
+
say ENV['DO_CONFIG'] || Do::Config.default_path
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc 'show', 'Print the parsed/effective configuration.'
|
|
25
|
+
def show
|
|
26
|
+
cfg_path = ENV['DO_CONFIG'] || Do::Config.default_path
|
|
27
|
+
raise Do::Error, "Configuration file does not exist: #{cfg_path}" unless File.exist?(cfg_path)
|
|
28
|
+
|
|
29
|
+
config = Do::Config.load(cfg_path)
|
|
30
|
+
errors = Do::Validator.validate(config)
|
|
31
|
+
unless errors.empty?
|
|
32
|
+
errors.each { |e| say "Error: #{e}", :red }
|
|
33
|
+
exit(1)
|
|
34
|
+
end
|
|
35
|
+
say "path: #{cfg_path}"
|
|
36
|
+
say 'tasks:'
|
|
37
|
+
config.tasks.each do |task|
|
|
38
|
+
say " #{task.name}:"
|
|
39
|
+
say " command: #{task.command}"
|
|
40
|
+
say " schedule: #{task.schedule || 'manual'}"
|
|
41
|
+
say " time: #{task.time || '-'}"
|
|
42
|
+
say " day: #{task.day || '-'}"
|
|
43
|
+
say " working_directory: #{task.working_directory || '-'}"
|
|
44
|
+
say " enabled: #{task.enabled?}"
|
|
45
|
+
say " environment: #{task.environment.inspect}" unless (task.environment || {}).empty?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Thor-based CLI. Every command follows the Unix-friendly style:
|
|
51
|
+
#
|
|
52
|
+
# do run backup
|
|
53
|
+
# do list
|
|
54
|
+
# do status backup
|
|
55
|
+
#
|
|
56
|
+
# Configuration is loaded lazily per command so that short commands like
|
|
57
|
+
# `do version` don't need a valid config file.
|
|
58
|
+
class CLI < Thor
|
|
59
|
+
def self.exit_on_failure?
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# `run` is a Thor reserved word; we want it as a real command.
|
|
64
|
+
def self.is_thor_reserved_word?(_word, _type)
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Commands that are taken literally rather than treated as task names by
|
|
69
|
+
# the shorthand `do <task>` form.
|
|
70
|
+
BUILTINS = %w[
|
|
71
|
+
run list status logs enable disable remove validate reload
|
|
72
|
+
schedule unschedule doctor version help config
|
|
73
|
+
].freeze
|
|
74
|
+
|
|
75
|
+
class << self
|
|
76
|
+
def dispatch(meth, given_args, given_opts, config)
|
|
77
|
+
super
|
|
78
|
+
rescue Do::Error => e
|
|
79
|
+
warn "Error: #{e.message}"
|
|
80
|
+
exit 1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
desc 'run <task>', 'Execute a task immediately.'
|
|
85
|
+
def run(task = nil)
|
|
86
|
+
task = require_task!(task)
|
|
87
|
+
say "> do run #{task.name}: #{task.command}"
|
|
88
|
+
code = Executor.new.run(task)
|
|
89
|
+
say " finished (exit #{code})"
|
|
90
|
+
exit(code) if code != 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
desc 'list', 'List configured tasks.'
|
|
94
|
+
def list
|
|
95
|
+
config = load_config!(validate: false)
|
|
96
|
+
say header = 'TASK STATUS SCHEDULE NEXT'
|
|
97
|
+
say '-' * header.length
|
|
98
|
+
config.tasks.each do |task|
|
|
99
|
+
say format_list_row(task, systemd)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc 'status <task>', 'Show detail for a single task.'
|
|
104
|
+
def status(task = nil)
|
|
105
|
+
task = require_task!(task)
|
|
106
|
+
show_task_status(task)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
desc 'logs <task>', "Show logs for a task's service."
|
|
110
|
+
option :follow, aliases: '-f', type: :boolean, desc: 'Follow new log lines.'
|
|
111
|
+
option :tail, aliases: '-n', type: :numeric, desc: 'Show last N lines.'
|
|
112
|
+
def logs(task = nil)
|
|
113
|
+
task = require_task!(task)
|
|
114
|
+
units = units_for(task)
|
|
115
|
+
raise Do::Error, "task '#{task.name}' has no generated service unit." if units[:service].nil?
|
|
116
|
+
|
|
117
|
+
say "Journal for #{units[:service]} (#{task.name}):" if $stdout.tty?
|
|
118
|
+
ok = systemd.logs(units[:service], follow: options[:follow], tail: options[:tail])
|
|
119
|
+
exit(1) unless ok
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
desc 'enable <task>', 'Mark a scheduled task enabled and activate its timer.'
|
|
123
|
+
def enable(task = nil)
|
|
124
|
+
change_enabled(task, true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
desc 'disable <task>', 'Mark a scheduled task disabled without removing it.'
|
|
128
|
+
def disable(task = nil)
|
|
129
|
+
change_enabled(task, false)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
desc 'remove <task>', "Remove a task's generated units (confirmation required)."
|
|
133
|
+
option :yes, type: :boolean, aliases: '-y', desc: 'Skip confirmation.'
|
|
134
|
+
option :'keep-config', type: :boolean, desc: 'Only remove generated units, keep the TOML entry.'
|
|
135
|
+
def remove(task = nil)
|
|
136
|
+
config = load_config!(validate: false)
|
|
137
|
+
task = require_task!(task, config)
|
|
138
|
+
manager = Manager.new(systemd: systemd, config: config)
|
|
139
|
+
|
|
140
|
+
confirm!("Remove generated units for task '#{task.name}'?") unless options[:yes]
|
|
141
|
+
manager.remove_units_for(task)
|
|
142
|
+
if options[:'keep-config']
|
|
143
|
+
say "Removed generated units for '#{task.name}' (kept configuration)."
|
|
144
|
+
else
|
|
145
|
+
config.remove_task(task.name)
|
|
146
|
+
say "Removed task '#{task.name}' from configuration and removed its units."
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
desc 'validate', 'Validate the configuration without modifying the system.'
|
|
151
|
+
def validate
|
|
152
|
+
path = config_path
|
|
153
|
+
raise Do::Error, "Configuration file does not exist: #{path}" unless File.exist?(path)
|
|
154
|
+
|
|
155
|
+
config = Config.load(path)
|
|
156
|
+
errors = Validator.validate(config)
|
|
157
|
+
if errors.empty?
|
|
158
|
+
say "OK: configuration is valid (#{config.tasks.size} task(s))."
|
|
159
|
+
else
|
|
160
|
+
errors.each { |e| say "Error: #{e}", :red }
|
|
161
|
+
exit(1)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
desc 'reload', 'Generate/update units, remove stale ones, and reload systemd.'
|
|
166
|
+
def reload
|
|
167
|
+
config = load_config!(validate: true)
|
|
168
|
+
Manager.new(systemd: systemd, config: config).reload
|
|
169
|
+
say "Reloaded units for #{config.tasks.size} task(s)."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
desc 'schedule <task>', 'Generate the timer for a scheduled task and enable it.'
|
|
173
|
+
def schedule(task = nil)
|
|
174
|
+
config = load_config!(validate: true)
|
|
175
|
+
task = require_task!(task, config)
|
|
176
|
+
unless task.scheduled?
|
|
177
|
+
raise Do::Error,
|
|
178
|
+
"task '#{task.name}' has no schedule in the configuration."
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
Manager.new(systemd: systemd, config: config).reload
|
|
182
|
+
say "Scheduled task '#{task.name}' (#{task.schedule})."
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
desc 'unschedule <task>', "Disable and remove a task's timer (keeps the TOML entry)."
|
|
186
|
+
def unschedule(task = nil)
|
|
187
|
+
config = load_config!(validate: false)
|
|
188
|
+
task = require_task!(task, config)
|
|
189
|
+
Manager.new(systemd: systemd, config: config).unschedule(task)
|
|
190
|
+
say "Unscheduled task '#{task.name}'."
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
desc 'doctor', 'Report on the environment and configuration.'
|
|
194
|
+
def doctor
|
|
195
|
+
run_doctor
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
desc 'config', 'Work with the configuration file.'
|
|
199
|
+
subcommand 'config', ConfigCommand
|
|
200
|
+
|
|
201
|
+
desc 'version', 'Show the version.'
|
|
202
|
+
def version
|
|
203
|
+
say Do::VERSION
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
def systemd
|
|
209
|
+
@systemd ||= Systemd.new
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def config_path
|
|
213
|
+
ENV['DO_CONFIG'] || Config.default_path
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def load_config!(validate: true)
|
|
217
|
+
path = config_path
|
|
218
|
+
unless File.exist?(path)
|
|
219
|
+
raise Do::Error,
|
|
220
|
+
"Configuration file does not exist: #{path}\nCreate it or run 'do config path'."
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
config = Config.load(path)
|
|
224
|
+
Validator.validate!(config) if validate
|
|
225
|
+
config
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def require_task!(name, config = nil)
|
|
229
|
+
config ||= load_config!(validate: false)
|
|
230
|
+
t = config.task(name.to_s)
|
|
231
|
+
raise_does_not_exist(name) unless t
|
|
232
|
+
t
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def raise_does_not_exist(name)
|
|
236
|
+
raise Do::Error,
|
|
237
|
+
"task '#{name}' does not exist.\nRun 'do list' to see configured tasks."
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def units_for(task)
|
|
241
|
+
{ service: task.scheduled? ? task.service_unit : nil,
|
|
242
|
+
timer: task.scheduled? ? task.timer_unit : nil }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def format_list_row(task, sd)
|
|
246
|
+
sched = schedule_label(task)
|
|
247
|
+
next_time =
|
|
248
|
+
if task.scheduled? && task.enabled?
|
|
249
|
+
t = sd.next_time(task.timer_unit)
|
|
250
|
+
t ? t.strftime('%a %H:%M') : '-'
|
|
251
|
+
else
|
|
252
|
+
'-'
|
|
253
|
+
end
|
|
254
|
+
format('%-10s %-9s %-18s %s',
|
|
255
|
+
task.name, (task.enabled? ? 'enabled' : 'disabled'), sched, next_time)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def schedule_label(task)
|
|
259
|
+
return 'manual' unless task.scheduled?
|
|
260
|
+
|
|
261
|
+
case task.schedule
|
|
262
|
+
when 'once' then 'once'
|
|
263
|
+
when 'hourly' then 'hourly'
|
|
264
|
+
when 'daily' then "daily #{task.time || '00:00'}"
|
|
265
|
+
when 'weekly' then "weekly #{short_day(task.day)} #{task.time || '00:00'}"
|
|
266
|
+
when 'monthly' then "monthly day #{task.day || '1'} #{task.time || '00:00'}"
|
|
267
|
+
else task.schedule.to_s
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def short_day(day)
|
|
272
|
+
day.to_s[0, 3].capitalize
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def show_task_status(task)
|
|
276
|
+
sd = systemd
|
|
277
|
+
service = task.scheduled? ? task.service_unit : nil
|
|
278
|
+
timer = task.scheduled? ? task.timer_unit : nil
|
|
279
|
+
|
|
280
|
+
state = active_state_label(sd, timer, service)
|
|
281
|
+
say "task: #{task.name}"
|
|
282
|
+
say "status: #{task.enabled? ? 'enabled' : 'disabled'}"
|
|
283
|
+
say "running: #{running_label(sd, service)}"
|
|
284
|
+
say "schedule: #{schedule_label(task)}"
|
|
285
|
+
if timer
|
|
286
|
+
t = sd.next_time(timer)
|
|
287
|
+
say "next: #{t ? t.strftime('%a %Y-%m-%d %H:%M') : 'unknown'}"
|
|
288
|
+
say "active: #{state || '-'}"
|
|
289
|
+
end
|
|
290
|
+
return unless service
|
|
291
|
+
|
|
292
|
+
last = sd.last_active(service)
|
|
293
|
+
code = sd.last_exit_status(service)
|
|
294
|
+
say "last run: #{last ? last.strftime('%Y-%m-%d %H:%M') : 'never'}"
|
|
295
|
+
say "last exit: #{code.nil? ? '-' : code}"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def active_state_label(sd, timer, service)
|
|
299
|
+
if timer && sd.active?(timer)
|
|
300
|
+
'timer active'
|
|
301
|
+
elsif service && sd.active?(service)
|
|
302
|
+
'service active'
|
|
303
|
+
else
|
|
304
|
+
'inactive'
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def running_label(sd, service)
|
|
309
|
+
return 'no unit' if service.nil?
|
|
310
|
+
|
|
311
|
+
sd.active?(service) ? 'yes' : 'no'
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def change_enabled(task_or_name, value)
|
|
315
|
+
config = load_config!(validate: false)
|
|
316
|
+
task = require_task!(task_or_name, config)
|
|
317
|
+
unless task.scheduled?
|
|
318
|
+
raise Do::Error,
|
|
319
|
+
"manual-only task '#{task.name}' has no timer to #{value ? 'enable' : 'disable'}."
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
config.set_task_field(task.name, 'enabled', value.to_s)
|
|
323
|
+
refreshed = Config.load(config.path)
|
|
324
|
+
Validator.validate!(refreshed)
|
|
325
|
+
Manager.new(systemd: systemd, config: refreshed).reload
|
|
326
|
+
say "#{value ? 'Enabled' : 'Disabled'} task '#{task.name}'."
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def confirm!(prompt)
|
|
330
|
+
unless $stdin.tty?
|
|
331
|
+
raise Do::Error, 'confirmation required; pass --yes for non-interactive removal.'
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
print "#{prompt} [y/N] "
|
|
335
|
+
ans = $stdin.gets
|
|
336
|
+
return if ans && ans.strip.downcase == 'y'
|
|
337
|
+
|
|
338
|
+
say 'Aborted.'
|
|
339
|
+
exit(1)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def run_doctor
|
|
343
|
+
check('Ruby', true, Ruby::VERSION)
|
|
344
|
+
systemctl_ok = systemctl_present?
|
|
345
|
+
check('systemctl', systemctl_ok, systemctl_ok ? nil : 'not found in PATH')
|
|
346
|
+
check('systemd user manager', systemd.user_available?, nil) if systemctl_ok
|
|
347
|
+
check('configuration file', File.exist?(config_path), config_path)
|
|
348
|
+
|
|
349
|
+
if File.exist?(config_path)
|
|
350
|
+
begin
|
|
351
|
+
config = Config.load(config_path)
|
|
352
|
+
errors = Validator.validate(config)
|
|
353
|
+
if errors.empty?
|
|
354
|
+
check('configuration validity', true, nil)
|
|
355
|
+
check_generated_units(config)
|
|
356
|
+
check_enabled_timers(config)
|
|
357
|
+
check_commands(config)
|
|
358
|
+
check_working_dirs(config)
|
|
359
|
+
else
|
|
360
|
+
check('configuration validity', false, errors.first)
|
|
361
|
+
end
|
|
362
|
+
rescue Do::Error => e
|
|
363
|
+
check('configuration validity', false, e.message)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
check('journal', journalctl_present?, nil)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def check(name, ok, detail)
|
|
370
|
+
mark = ok ? "\u2713" : "\u2717"
|
|
371
|
+
say "#{mark} #{name}#{" (#{detail})" if detail}"
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def systemctl_present?
|
|
375
|
+
systemd = systemctl_bin
|
|
376
|
+
systemd && !systemd.empty?
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def journalctl_present?
|
|
380
|
+
systemctl_bin # journalctl assumed present alongside systemctl
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def systemctl_bin
|
|
384
|
+
ENV['SYSTEMCTL'] || (system_detect('systemctl') ? 'systemctl' : nil)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def system_detect(bin)
|
|
388
|
+
system("command -v #{bin} >/dev/null 2>&1")
|
|
389
|
+
rescue StandardError
|
|
390
|
+
false
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def check_generated_units(config)
|
|
394
|
+
dir = Systemd.new.unit_dir
|
|
395
|
+
expected = config.tasks.select(&:scheduled?).flat_map do |t|
|
|
396
|
+
[t.service_unit, t.timer_unit]
|
|
397
|
+
end
|
|
398
|
+
stale = Dir.glob(File.join(dir, 'do-*.{service,timer}')).select do |f|
|
|
399
|
+
!expected.include?(File.basename(f)) && managed_file?(f)
|
|
400
|
+
end
|
|
401
|
+
check('generated units (no stale)', stale.empty?,
|
|
402
|
+
stale.empty? ? nil : "stale: #{stale.join(', ')}")
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def managed_file?(path)
|
|
406
|
+
File.read(path).include?('Managed by do')
|
|
407
|
+
rescue StandardError
|
|
408
|
+
false
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def check_enabled_timers(config)
|
|
412
|
+
sd = systemd
|
|
413
|
+
problems = config.tasks.select(&:scheduled?).filter_map do |t|
|
|
414
|
+
next if !t.enabled? && !sd.exists?(t.timer_unit)
|
|
415
|
+
next if t.enabled? && sd.enabled?(t.timer_unit)
|
|
416
|
+
|
|
417
|
+
"timer '#{t.timer_unit}' not #{t.enabled? ? 'enabled' : 'disabled'}"
|
|
418
|
+
end
|
|
419
|
+
check('timers match config', problems.empty?, problems.empty? ? nil : problems.join('; '))
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def check_commands(config)
|
|
423
|
+
missing = config.tasks.filter_map do |t|
|
|
424
|
+
bin = Shellwords.split(t.command).first
|
|
425
|
+
executable_in_path?(bin) ? nil : t.name
|
|
426
|
+
end
|
|
427
|
+
check('commands resolvable', missing.empty?,
|
|
428
|
+
(missing.empty? ? nil : "not found: #{missing.join(', ')}"))
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def executable_in_path?(bin)
|
|
432
|
+
return false if bin.nil? || bin.empty?
|
|
433
|
+
return File.executable?(bin) if bin.include?('/')
|
|
434
|
+
|
|
435
|
+
system("command -v #{bin} >/dev/null 2>&1")
|
|
436
|
+
rescue StandardError
|
|
437
|
+
false
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def check_working_dirs(config)
|
|
441
|
+
bad = config.tasks.select(&:working_directory).reject do |t|
|
|
442
|
+
File.directory?(File.expand_path(t.working_directory))
|
|
443
|
+
end
|
|
444
|
+
check('working directories exist', bad.empty?,
|
|
445
|
+
(bad.empty? ? nil : "missing: #{bad.map(&:name).join(', ')}"))
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
end
|