wheneverd 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/.gitignore +26 -0
- data/.rubocop.yml +41 -0
- data/.yardopts +8 -0
- data/AGENTS.md +42 -0
- data/CHANGELOG.md +28 -0
- data/FEATURE_SUMMARY.md +38 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +129 -0
- data/LICENSE +21 -0
- data/README.md +204 -0
- data/Rakefile +196 -0
- data/bin/console +8 -0
- data/bin/setup +5 -0
- data/exe/wheneverd +9 -0
- data/lib/wheneverd/cli/activate.rb +19 -0
- data/lib/wheneverd/cli/current.rb +22 -0
- data/lib/wheneverd/cli/deactivate.rb +19 -0
- data/lib/wheneverd/cli/delete.rb +20 -0
- data/lib/wheneverd/cli/help.rb +18 -0
- data/lib/wheneverd/cli/init.rb +78 -0
- data/lib/wheneverd/cli/reload.rb +40 -0
- data/lib/wheneverd/cli/show.rb +23 -0
- data/lib/wheneverd/cli/write.rb +32 -0
- data/lib/wheneverd/cli.rb +87 -0
- data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
- data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
- data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
- data/lib/wheneverd/dsl/context.rb +72 -0
- data/lib/wheneverd/dsl/errors.rb +29 -0
- data/lib/wheneverd/dsl/loader.rb +49 -0
- data/lib/wheneverd/dsl/period_parser.rb +135 -0
- data/lib/wheneverd/duration.rb +27 -0
- data/lib/wheneverd/entry.rb +31 -0
- data/lib/wheneverd/errors.rb +9 -0
- data/lib/wheneverd/interval.rb +37 -0
- data/lib/wheneverd/job/command.rb +29 -0
- data/lib/wheneverd/schedule.rb +25 -0
- data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
- data/lib/wheneverd/systemd/cron_parser.rb +352 -0
- data/lib/wheneverd/systemd/errors.rb +23 -0
- data/lib/wheneverd/systemd/renderer.rb +153 -0
- data/lib/wheneverd/systemd/systemctl.rb +38 -0
- data/lib/wheneverd/systemd/time_parser.rb +75 -0
- data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
- data/lib/wheneverd/systemd/unit_lister.rb +59 -0
- data/lib/wheneverd/systemd/unit_namer.rb +69 -0
- data/lib/wheneverd/systemd/unit_writer.rb +132 -0
- data/lib/wheneverd/trigger/boot.rb +26 -0
- data/lib/wheneverd/trigger/calendar.rb +26 -0
- data/lib/wheneverd/trigger/interval.rb +30 -0
- data/lib/wheneverd/version.rb +6 -0
- data/lib/wheneverd.rb +41 -0
- data/test/cli_activate_test.rb +110 -0
- data/test/cli_current_test.rb +94 -0
- data/test/cli_deactivate_test.rb +111 -0
- data/test/cli_end_to_end_test.rb +98 -0
- data/test/cli_reload_test.rb +132 -0
- data/test/cli_systemctl_integration_test.rb +76 -0
- data/test/cli_systemd_analyze_test.rb +64 -0
- data/test/cli_test.rb +332 -0
- data/test/domain_model_test.rb +108 -0
- data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
- data/test/dsl_loader_test.rb +384 -0
- data/test/support/cli_subprocess_test_helpers.rb +38 -0
- data/test/support/cli_test_helpers.rb +114 -0
- data/test/systemd_calendar_spec_test.rb +45 -0
- data/test/systemd_cron_parser_test.rb +114 -0
- data/test/systemd_renderer_errors_test.rb +85 -0
- data/test/systemd_renderer_test.rb +161 -0
- data/test/systemd_systemctl_test.rb +46 -0
- data/test/systemd_time_parser_test.rb +25 -0
- data/test/systemd_unit_deleter_test.rb +83 -0
- data/test/systemd_unit_writer_prune_test.rb +85 -0
- data/test/systemd_unit_writer_test.rb +71 -0
- data/test/test_helper.rb +34 -0
- data/test/wheneverd_test.rb +9 -0
- data/wheneverd.gemspec +35 -0
- metadata +136 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class SystemdRendererErrorsTest < Minitest::Test
|
|
6
|
+
def test_rejects_empty_identifier
|
|
7
|
+
assert_raises(Wheneverd::Systemd::InvalidIdentifierError) do
|
|
8
|
+
Wheneverd::Systemd::Renderer.render(minimal_schedule, identifier: " ")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_rejects_identifier_without_alphanumeric_chars
|
|
13
|
+
assert_raises(Wheneverd::Systemd::InvalidIdentifierError) do
|
|
14
|
+
Wheneverd::Systemd::Renderer.render(minimal_schedule, identifier: "!!!")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_sanitizes_identifier_into_unit_basename
|
|
19
|
+
units = Wheneverd::Systemd::Renderer.render(minimal_schedule, identifier: "my app!")
|
|
20
|
+
matched = units.map(&:path_basename).any? do |basename|
|
|
21
|
+
/\Awheneverd-my-app-[0-9a-f]{12}\.timer\z/.match?(basename)
|
|
22
|
+
end
|
|
23
|
+
assert matched
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_rejects_invalid_schedule_type
|
|
27
|
+
assert_raises(ArgumentError) do
|
|
28
|
+
Wheneverd::Systemd::Renderer.render(Object.new, identifier: "demo")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_rejects_unsupported_trigger_type
|
|
33
|
+
assert_raises(ArgumentError) do
|
|
34
|
+
Wheneverd::Systemd::Renderer.render(schedule_with_trigger(Object.new), identifier: "demo")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_rejects_unsupported_job_type
|
|
39
|
+
schedule = schedule_with_job(Object.new)
|
|
40
|
+
assert_raises(ArgumentError) do
|
|
41
|
+
Wheneverd::Systemd::Renderer.render(schedule, identifier: "demo")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_timer_lines_for_rejects_unknown_trigger
|
|
46
|
+
assert_raises(ArgumentError) do
|
|
47
|
+
Wheneverd::Systemd::Renderer.send(:timer_lines_for, Object.new)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def minimal_schedule
|
|
54
|
+
Wheneverd::Schedule.new(
|
|
55
|
+
entries: [
|
|
56
|
+
Wheneverd::Entry.new(
|
|
57
|
+
trigger: Wheneverd::Trigger::Interval.new(seconds: 60),
|
|
58
|
+
jobs: [Wheneverd::Job::Command.new(command: "echo hi")]
|
|
59
|
+
)
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def schedule_with_trigger(trigger)
|
|
65
|
+
Wheneverd::Schedule.new(
|
|
66
|
+
entries: [
|
|
67
|
+
Wheneverd::Entry.new(
|
|
68
|
+
trigger: trigger,
|
|
69
|
+
jobs: [Wheneverd::Job::Command.new(command: "echo hi")]
|
|
70
|
+
)
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def schedule_with_job(job)
|
|
76
|
+
Wheneverd::Schedule.new(
|
|
77
|
+
entries: [
|
|
78
|
+
Wheneverd::Entry.new(
|
|
79
|
+
trigger: Wheneverd::Trigger::Interval.new(seconds: 60),
|
|
80
|
+
jobs: [job]
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
module SystemdRendererTestHelpers
|
|
6
|
+
def render_units(entries, identifier: "demo")
|
|
7
|
+
Wheneverd::Systemd::Renderer.render(Wheneverd::Schedule.new(entries: entries),
|
|
8
|
+
identifier: identifier)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def timer_for(entry, identifier: "demo")
|
|
12
|
+
render_units([entry], identifier: identifier).find { |u| u.kind == :timer }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def service_for(entry, identifier: "demo")
|
|
16
|
+
render_units([entry], identifier: identifier).find { |u| u.kind == :service }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def interval_entry(seconds:, command:)
|
|
20
|
+
Wheneverd::Entry.new(
|
|
21
|
+
trigger: Wheneverd::Trigger::Interval.new(seconds: seconds),
|
|
22
|
+
jobs: [Wheneverd::Job::Command.new(command: command)]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def boot_entry(seconds:, command:)
|
|
27
|
+
Wheneverd::Entry.new(
|
|
28
|
+
trigger: Wheneverd::Trigger::Boot.new(seconds: seconds),
|
|
29
|
+
jobs: [Wheneverd::Job::Command.new(command: command)]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def calendar_entry(spec:, command:)
|
|
34
|
+
Wheneverd::Entry.new(
|
|
35
|
+
trigger: Wheneverd::Trigger::Calendar.new(on_calendar: [spec]),
|
|
36
|
+
jobs: [Wheneverd::Job::Command.new(command: command)]
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def marker
|
|
41
|
+
"# Generated by wheneverd (wheneverd) #{Wheneverd::VERSION}; do not edit."
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class SystemdRendererIntervalTest < Minitest::Test
|
|
46
|
+
include SystemdRendererTestHelpers
|
|
47
|
+
|
|
48
|
+
def test_interval_timer_contains_required_fields
|
|
49
|
+
timer = timer_for(interval_entry(seconds: 60, command: "echo hello"))
|
|
50
|
+
refute_nil timer
|
|
51
|
+
assert_includes timer.contents, marker
|
|
52
|
+
assert_includes timer.contents, "OnActiveSec=60"
|
|
53
|
+
assert_includes timer.contents, "OnUnitActiveSec=60"
|
|
54
|
+
assert_includes timer.contents, "Persistent=true"
|
|
55
|
+
assert_includes timer.contents, "WantedBy=timers.target"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_interval_service_contains_execstart
|
|
59
|
+
service = service_for(interval_entry(seconds: 60, command: "echo hello"))
|
|
60
|
+
refute_nil service
|
|
61
|
+
assert_includes service.contents, marker
|
|
62
|
+
assert_includes service.contents, "Type=oneshot"
|
|
63
|
+
assert_includes service.contents, "ExecStart=echo hello"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class SystemdRendererCalendarTest < Minitest::Test
|
|
68
|
+
include SystemdRendererTestHelpers
|
|
69
|
+
|
|
70
|
+
def test_calendar_hour_translates_to_hourly
|
|
71
|
+
timer = timer_for(calendar_entry(spec: "hour", command: "echo hourly"))
|
|
72
|
+
assert_includes timer.contents, "OnCalendar=hourly"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_calendar_day_at_translates_to_date_time
|
|
76
|
+
timer = timer_for(calendar_entry(spec: "day@4:30 am", command: "echo four_thirty"))
|
|
77
|
+
assert_includes timer.contents, "OnCalendar=*-*-* 04:30:00"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_calendar_sunday_at_translates_to_day_of_week
|
|
81
|
+
timer = timer_for(calendar_entry(spec: "sunday@12pm", command: "echo weekly"))
|
|
82
|
+
assert_includes timer.contents, "OnCalendar=Sun *-*-* 12:00:00"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_cron_example_translates_to_expected_on_calendar
|
|
86
|
+
timer = timer_for(calendar_entry(spec: "cron:0 0 27-31 * *", command: "echo raw_cron"))
|
|
87
|
+
assert_includes timer.contents, "OnCalendar=*-*-27..31 00:00:00"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def test_cron_with_day_of_month_and_day_of_week_expands_to_multiple_on_calendar_lines
|
|
91
|
+
timer = timer_for(calendar_entry(spec: "cron:0 0 1 * Mon", command: "echo raw_cron"))
|
|
92
|
+
assert_includes timer.contents, "OnCalendar=Mon *-*-* 00:00:00"
|
|
93
|
+
assert_includes timer.contents, "OnCalendar=*-*-1 00:00:00"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class SystemdRendererNamingTest < Minitest::Test
|
|
98
|
+
include SystemdRendererTestHelpers
|
|
99
|
+
|
|
100
|
+
def test_naming_is_deterministic
|
|
101
|
+
entries = entries_for_determinism
|
|
102
|
+
units1 = render_units(entries, identifier: "my-app")
|
|
103
|
+
units2 = render_units(entries, identifier: "my-app")
|
|
104
|
+
assert_equal units1.map(&:path_basename), units2.map(&:path_basename)
|
|
105
|
+
assert_equal units1.map(&:contents), units2.map(&:contents)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_units_keep_names_across_entry_reordering
|
|
109
|
+
entry_a = interval_entry(seconds: 60, command: "echo a")
|
|
110
|
+
entry_b = boot_entry(seconds: 60, command: "echo b")
|
|
111
|
+
|
|
112
|
+
units1 = render_units([entry_a, entry_b], identifier: "my-app").sort_by(&:path_basename)
|
|
113
|
+
units2 = render_units([entry_b, entry_a], identifier: "my-app").sort_by(&:path_basename)
|
|
114
|
+
|
|
115
|
+
assert_equal units1.map(&:path_basename), units2.map(&:path_basename)
|
|
116
|
+
assert_equal units1.map(&:contents), units2.map(&:contents)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_duplicate_jobs_get_disambiguated_unit_ids
|
|
120
|
+
timer_basenames = timer_basenames_for_entries([duplicate_job_entry], identifier: "my-app")
|
|
121
|
+
assert_equal 2, timer_basenames.length
|
|
122
|
+
assert_disambiguated_timer_basenames(timer_basenames)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def entries_for_determinism
|
|
128
|
+
[
|
|
129
|
+
Wheneverd::Entry.new(trigger: Wheneverd::Trigger::Interval.new(seconds: 60), jobs: jobs_a_b),
|
|
130
|
+
boot_entry(seconds: 60, command: "echo c")
|
|
131
|
+
]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def jobs_a_b
|
|
135
|
+
[
|
|
136
|
+
Wheneverd::Job::Command.new(command: "echo a"),
|
|
137
|
+
Wheneverd::Job::Command.new(command: "echo b")
|
|
138
|
+
]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def duplicate_job_entry
|
|
142
|
+
Wheneverd::Entry.new(
|
|
143
|
+
trigger: Wheneverd::Trigger::Interval.new(seconds: 60),
|
|
144
|
+
jobs: [
|
|
145
|
+
Wheneverd::Job::Command.new(command: "echo a"),
|
|
146
|
+
Wheneverd::Job::Command.new(command: "echo a")
|
|
147
|
+
]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def timer_basenames_for_entries(entries, identifier:)
|
|
152
|
+
render_units(entries, identifier: identifier)
|
|
153
|
+
.map(&:path_basename)
|
|
154
|
+
.select { |b| b.end_with?(".timer") }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def assert_disambiguated_timer_basenames(timer_basenames)
|
|
158
|
+
assert(timer_basenames.any? { |b| /\Awheneverd-my-app-[0-9a-f]{12}-1\.timer\z/.match?(b) })
|
|
159
|
+
assert(timer_basenames.any? { |b| /\Awheneverd-my-app-[0-9a-f]{12}-2\.timer\z/.match?(b) })
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class SystemdSystemctlTest < Minitest::Test
|
|
6
|
+
def with_capture3_stub(exitstatus:, stdout: "", stderr: "")
|
|
7
|
+
calls = []
|
|
8
|
+
Thread.current[:open3_capture3_stub] = {
|
|
9
|
+
calls: calls,
|
|
10
|
+
stdout: stdout,
|
|
11
|
+
stderr: stderr,
|
|
12
|
+
exitstatus: exitstatus
|
|
13
|
+
}
|
|
14
|
+
yield calls
|
|
15
|
+
ensure
|
|
16
|
+
Thread.current[:open3_capture3_stub] = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_run_builds_user_systemctl_command_and_returns_output
|
|
20
|
+
with_capture3_stub(exitstatus: 0, stdout: "ok\n", stderr: "") do |calls|
|
|
21
|
+
out, err = Wheneverd::Systemd::Systemctl.run("daemon-reload")
|
|
22
|
+
assert_equal "ok\n", out
|
|
23
|
+
assert_equal "", err
|
|
24
|
+
assert_equal [["systemctl", "--user", "--no-pager", "daemon-reload"], {}], calls.fetch(0)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def test_run_omits_user_flag_when_user_false
|
|
29
|
+
with_capture3_stub(exitstatus: 0) do |calls|
|
|
30
|
+
Wheneverd::Systemd::Systemctl.run("daemon-reload", user: false)
|
|
31
|
+
refute_includes calls.fetch(0).fetch(0), "--user"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_run_raises_systemctl_error_on_failure_with_details
|
|
36
|
+
with_capture3_stub(exitstatus: 1, stdout: "oops\n", stderr: "nope\n") do
|
|
37
|
+
error = assert_raises(Wheneverd::Systemd::SystemctlError) do
|
|
38
|
+
Wheneverd::Systemd::Systemctl.run("daemon-reload")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
assert_includes error.message, "status: 1"
|
|
42
|
+
assert_includes error.message, "stdout: oops"
|
|
43
|
+
assert_includes error.message, "stderr: nope"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class SystemdTimeParserTest < Minitest::Test
|
|
6
|
+
def test_parses_12h_times
|
|
7
|
+
assert_equal "04:30:00", Wheneverd::Systemd::TimeParser.parse("4:30 am")
|
|
8
|
+
assert_equal "18:00:00", Wheneverd::Systemd::TimeParser.parse("6:00 pm")
|
|
9
|
+
assert_equal "12:00:00", Wheneverd::Systemd::TimeParser.parse("12pm")
|
|
10
|
+
assert_equal "00:00:00", Wheneverd::Systemd::TimeParser.parse("12 am")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def test_parses_24h_times
|
|
14
|
+
assert_equal "00:15:00", Wheneverd::Systemd::TimeParser.parse("00:15")
|
|
15
|
+
assert_equal "23:59:59", Wheneverd::Systemd::TimeParser.parse("23:59:59")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_rejects_invalid_times
|
|
19
|
+
assert_raises(Wheneverd::Systemd::InvalidTimeError) { Wheneverd::Systemd::TimeParser.parse("") }
|
|
20
|
+
assert_raises(Wheneverd::Systemd::InvalidTimeError) { Wheneverd::Systemd::TimeParser.parse("25:00") }
|
|
21
|
+
assert_raises(Wheneverd::Systemd::InvalidTimeError) { Wheneverd::Systemd::TimeParser.parse("12:99") }
|
|
22
|
+
assert_raises(Wheneverd::Systemd::InvalidTimeError) { Wheneverd::Systemd::TimeParser.parse("0pm") }
|
|
23
|
+
assert_raises(Wheneverd::Systemd::InvalidTimeError) { Wheneverd::Systemd::TimeParser.parse("nope") }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
class SystemdUnitDeleterTest < Minitest::Test
|
|
7
|
+
def test_delete_rejects_invalid_identifier
|
|
8
|
+
Dir.mktmpdir("wheneverd-") do |dir|
|
|
9
|
+
assert_raises(Wheneverd::Systemd::InvalidIdentifierError) do
|
|
10
|
+
Wheneverd::Systemd::UnitDeleter.delete(identifier: "!!!", unit_dir: dir)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_delete_deletes_only_generated_files_for_identifier
|
|
16
|
+
with_written_units do |unit_dir, written_paths|
|
|
17
|
+
create_non_generated_match(unit_dir, "demo")
|
|
18
|
+
deleted = Wheneverd::Systemd::UnitDeleter.delete(identifier: "demo", unit_dir: unit_dir)
|
|
19
|
+
assert_equal written_paths.sort, deleted.sort
|
|
20
|
+
written_paths.each { |p| refute File.exist?(p) }
|
|
21
|
+
assert File.exist?(File.join(unit_dir, "wheneverd-demo-000000000000.timer"))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_delete_does_not_delete_other_identifier_or_non_matching_files
|
|
26
|
+
with_written_units do |unit_dir, _written_paths|
|
|
27
|
+
create_generated_different_identifier(unit_dir)
|
|
28
|
+
File.write(File.join(unit_dir, "other.timer"), "#{marker_line}\n")
|
|
29
|
+
Wheneverd::Systemd::UnitDeleter.delete(identifier: "demo", unit_dir: unit_dir)
|
|
30
|
+
assert File.exist?(File.join(unit_dir, "wheneverd-other-000000000000.timer"))
|
|
31
|
+
assert File.exist?(File.join(unit_dir, "other.timer"))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_delete_dry_run_does_not_remove_files
|
|
36
|
+
with_written_units do |unit_dir, written_paths|
|
|
37
|
+
deleted = Wheneverd::Systemd::UnitDeleter.delete(identifier: "demo", unit_dir: unit_dir,
|
|
38
|
+
dry_run: true)
|
|
39
|
+
assert_equal written_paths.sort, deleted.sort
|
|
40
|
+
written_paths.each { |p| assert File.exist?(p) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def with_written_units
|
|
47
|
+
Dir.mktmpdir("wheneverd-") do |dir|
|
|
48
|
+
unit_dir = File.join(dir, "systemd", "user")
|
|
49
|
+
written_paths = Wheneverd::Systemd::UnitWriter.write(demo_units, unit_dir: unit_dir)
|
|
50
|
+
yield unit_dir, written_paths
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def demo_units
|
|
55
|
+
Wheneverd::Systemd::Renderer.render(schedule_with_interval_job("echo hello"),
|
|
56
|
+
identifier: "demo")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def schedule_with_interval_job(command)
|
|
60
|
+
Wheneverd::Schedule.new(
|
|
61
|
+
entries: [
|
|
62
|
+
Wheneverd::Entry.new(
|
|
63
|
+
trigger: Wheneverd::Trigger::Interval.new(seconds: 60),
|
|
64
|
+
jobs: [Wheneverd::Job::Command.new(command: command)]
|
|
65
|
+
)
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def marker_line
|
|
71
|
+
"#{Wheneverd::Systemd::Renderer::MARKER_PREFIX} #{Wheneverd::VERSION}; do not edit."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def create_non_generated_match(unit_dir, identifier)
|
|
75
|
+
path = File.join(unit_dir, "wheneverd-#{identifier}-000000000000.timer")
|
|
76
|
+
File.write(path, "[Timer]\nOnCalendar=daily\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def create_generated_different_identifier(unit_dir)
|
|
80
|
+
path = File.join(unit_dir, "wheneverd-other-000000000000.timer")
|
|
81
|
+
File.write(path, "#{marker_line}\n[Timer]\nOnCalendar=daily\n")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
class SystemdUnitWriterPruneTest < Minitest::Test
|
|
7
|
+
def test_write_prune_rejects_invalid_identifier
|
|
8
|
+
with_unit_dir do |unit_dir|
|
|
9
|
+
assert_raises(Wheneverd::Systemd::InvalidIdentifierError) do
|
|
10
|
+
Wheneverd::Systemd::UnitWriter.write(
|
|
11
|
+
[],
|
|
12
|
+
unit_dir: unit_dir,
|
|
13
|
+
prune: true,
|
|
14
|
+
identifier: "!!!"
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_write_prune_removes_stale_units
|
|
21
|
+
with_unit_dir do |unit_dir|
|
|
22
|
+
units1 = rendered_units_for_commands(["echo a", "echo b"])
|
|
23
|
+
write_units(unit_dir, units1, prune: true)
|
|
24
|
+
|
|
25
|
+
units2 = rendered_units_for_commands(["echo a"])
|
|
26
|
+
write_units(unit_dir, units2, prune: true)
|
|
27
|
+
|
|
28
|
+
assert_unit_files_pruned(unit_dir, units_before: units1, units_after: units2)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_write_no_prune_keeps_stale_units
|
|
33
|
+
with_unit_dir do |unit_dir|
|
|
34
|
+
units1 = rendered_units_for_commands(["echo a", "echo b"])
|
|
35
|
+
Wheneverd::Systemd::UnitWriter.write(units1, unit_dir: unit_dir)
|
|
36
|
+
|
|
37
|
+
units2 = rendered_units_for_commands(["echo a"])
|
|
38
|
+
write_units(unit_dir, units2, prune: false)
|
|
39
|
+
|
|
40
|
+
assert_unit_files_present(unit_dir, units1)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def rendered_units_for_commands(commands)
|
|
47
|
+
schedule = Wheneverd::Schedule.new(
|
|
48
|
+
entries: commands.map do |command|
|
|
49
|
+
Wheneverd::Entry.new(
|
|
50
|
+
trigger: Wheneverd::Trigger::Interval.new(seconds: 60),
|
|
51
|
+
jobs: [Wheneverd::Job::Command.new(command: command)]
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
)
|
|
55
|
+
Wheneverd::Systemd::Renderer.render(schedule, identifier: "demo")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def write_units(unit_dir, units, prune:)
|
|
59
|
+
Wheneverd::Systemd::UnitWriter.write(
|
|
60
|
+
units,
|
|
61
|
+
unit_dir: unit_dir,
|
|
62
|
+
prune: prune,
|
|
63
|
+
identifier: "demo"
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def assert_unit_files_present(unit_dir, units)
|
|
68
|
+
units.each { |unit| assert File.exist?(File.join(unit_dir, unit.path_basename)) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def assert_unit_files_pruned(unit_dir, units_before:, units_after:)
|
|
72
|
+
keep = units_after.map(&:path_basename)
|
|
73
|
+
stale = units_before.map(&:path_basename) - keep
|
|
74
|
+
|
|
75
|
+
keep.each { |basename| assert File.exist?(File.join(unit_dir, basename)) }
|
|
76
|
+
stale.each { |basename| refute File.exist?(File.join(unit_dir, basename)) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def with_unit_dir
|
|
80
|
+
Dir.mktmpdir("wheneverd-") do |dir|
|
|
81
|
+
unit_dir = File.join(dir, "systemd", "user")
|
|
82
|
+
yield unit_dir
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
class SystemdUnitWriterTest < Minitest::Test
|
|
7
|
+
def test_write_creates_directory
|
|
8
|
+
with_unit_dir do |unit_dir|
|
|
9
|
+
refute Dir.exist?(unit_dir)
|
|
10
|
+
Wheneverd::Systemd::UnitWriter.write(demo_units, unit_dir: unit_dir)
|
|
11
|
+
assert Dir.exist?(unit_dir)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def test_write_returns_full_paths_in_unit_order
|
|
16
|
+
with_unit_dir do |unit_dir|
|
|
17
|
+
units = demo_units
|
|
18
|
+
paths = Wheneverd::Systemd::UnitWriter.write(units, unit_dir: unit_dir)
|
|
19
|
+
assert_equal expected_paths(unit_dir, units), paths
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_write_writes_expected_contents
|
|
24
|
+
with_unit_dir do |unit_dir|
|
|
25
|
+
units = demo_units
|
|
26
|
+
Wheneverd::Systemd::UnitWriter.write(units, unit_dir: unit_dir)
|
|
27
|
+
units.each { |u| assert_equal u.contents, File.read(File.join(unit_dir, u.path_basename)) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_write_dry_run_does_not_create_dir_or_files
|
|
32
|
+
with_unit_dir do |unit_dir|
|
|
33
|
+
paths = Wheneverd::Systemd::UnitWriter.write(units, unit_dir: unit_dir, dry_run: true)
|
|
34
|
+
assert_equal expected_paths(unit_dir, units), paths
|
|
35
|
+
refute Dir.exist?(unit_dir)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def units
|
|
42
|
+
@units ||= demo_units
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def demo_units
|
|
46
|
+
Wheneverd::Systemd::Renderer.render(schedule_with_interval_job("echo hello"),
|
|
47
|
+
identifier: "demo")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def schedule_with_interval_job(command)
|
|
51
|
+
Wheneverd::Schedule.new(
|
|
52
|
+
entries: [
|
|
53
|
+
Wheneverd::Entry.new(
|
|
54
|
+
trigger: Wheneverd::Trigger::Interval.new(seconds: 60),
|
|
55
|
+
jobs: [Wheneverd::Job::Command.new(command: command)]
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def with_unit_dir
|
|
62
|
+
Dir.mktmpdir("wheneverd-") do |dir|
|
|
63
|
+
unit_dir = File.join(dir, "systemd", "user")
|
|
64
|
+
yield unit_dir
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def expected_paths(unit_dir, units)
|
|
69
|
+
units.map { |u| File.join(unit_dir, u.path_basename) }
|
|
70
|
+
end
|
|
71
|
+
end
|
data/test/test_helper.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "simplecov"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
SimpleCov.start do
|
|
7
|
+
add_filter "/test/"
|
|
8
|
+
minimum_coverage ENV.fetch("MINIMUM_COVERAGE", "100").to_i
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module Open3Capture3TestStub
|
|
12
|
+
Status = Struct.new(:exitstatus) do
|
|
13
|
+
def success?
|
|
14
|
+
exitstatus.zero?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def capture3(*cmd, **kwargs)
|
|
19
|
+
stub = Thread.current[:open3_capture3_stub]
|
|
20
|
+
return super unless stub
|
|
21
|
+
|
|
22
|
+
stub.fetch(:calls) << [cmd, kwargs]
|
|
23
|
+
[
|
|
24
|
+
stub.fetch(:stdout, ""),
|
|
25
|
+
stub.fetch(:stderr, ""),
|
|
26
|
+
Status.new(stub.fetch(:exitstatus, 0))
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Open3.singleton_class.prepend(Open3Capture3TestStub)
|
|
32
|
+
|
|
33
|
+
require "minitest/autorun"
|
|
34
|
+
require "wheneverd"
|
data/wheneverd.gemspec
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/wheneverd/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "wheneverd"
|
|
7
|
+
spec.version = Wheneverd::VERSION
|
|
8
|
+
spec.authors = ["Dr. Samuel Goebert"]
|
|
9
|
+
spec.email = ["maintainers@example.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Wheneverd is to systemd timers what whenever is to cron."
|
|
12
|
+
spec.description =
|
|
13
|
+
"Generates systemd timer/service units from a Ruby DSL, similar in spirit " \
|
|
14
|
+
"to the whenever gem for cron."
|
|
15
|
+
spec.homepage = "https://github.com/bigcurl/wheneverd"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0")
|
|
18
|
+
|
|
19
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
21
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
|
+
|
|
23
|
+
spec.files =
|
|
24
|
+
begin
|
|
25
|
+
Dir.chdir(__dir__) { `git ls-files -z`.split("\x0").reject(&:empty?) }
|
|
26
|
+
rescue StandardError
|
|
27
|
+
Dir.glob("{bin,exe,lib,test}/**/*", File::FNM_DOTMATCH).reject { |f| File.directory?(f) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
spec.bindir = "exe"
|
|
31
|
+
spec.executables = ["wheneverd"]
|
|
32
|
+
spec.require_paths = ["lib"]
|
|
33
|
+
|
|
34
|
+
spec.add_dependency "clamp", "~> 1.3"
|
|
35
|
+
end
|