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.
Files changed (79) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +26 -0
  3. data/.rubocop.yml +41 -0
  4. data/.yardopts +8 -0
  5. data/AGENTS.md +42 -0
  6. data/CHANGELOG.md +28 -0
  7. data/FEATURE_SUMMARY.md +38 -0
  8. data/Gemfile +16 -0
  9. data/Gemfile.lock +129 -0
  10. data/LICENSE +21 -0
  11. data/README.md +204 -0
  12. data/Rakefile +196 -0
  13. data/bin/console +8 -0
  14. data/bin/setup +5 -0
  15. data/exe/wheneverd +9 -0
  16. data/lib/wheneverd/cli/activate.rb +19 -0
  17. data/lib/wheneverd/cli/current.rb +22 -0
  18. data/lib/wheneverd/cli/deactivate.rb +19 -0
  19. data/lib/wheneverd/cli/delete.rb +20 -0
  20. data/lib/wheneverd/cli/help.rb +18 -0
  21. data/lib/wheneverd/cli/init.rb +78 -0
  22. data/lib/wheneverd/cli/reload.rb +40 -0
  23. data/lib/wheneverd/cli/show.rb +23 -0
  24. data/lib/wheneverd/cli/write.rb +32 -0
  25. data/lib/wheneverd/cli.rb +87 -0
  26. data/lib/wheneverd/core_ext/numeric_duration.rb +56 -0
  27. data/lib/wheneverd/dsl/at_normalizer.rb +48 -0
  28. data/lib/wheneverd/dsl/calendar_symbol_period_list.rb +42 -0
  29. data/lib/wheneverd/dsl/context.rb +72 -0
  30. data/lib/wheneverd/dsl/errors.rb +29 -0
  31. data/lib/wheneverd/dsl/loader.rb +49 -0
  32. data/lib/wheneverd/dsl/period_parser.rb +135 -0
  33. data/lib/wheneverd/duration.rb +27 -0
  34. data/lib/wheneverd/entry.rb +31 -0
  35. data/lib/wheneverd/errors.rb +9 -0
  36. data/lib/wheneverd/interval.rb +37 -0
  37. data/lib/wheneverd/job/command.rb +29 -0
  38. data/lib/wheneverd/schedule.rb +25 -0
  39. data/lib/wheneverd/systemd/calendar_spec.rb +109 -0
  40. data/lib/wheneverd/systemd/cron_parser.rb +352 -0
  41. data/lib/wheneverd/systemd/errors.rb +23 -0
  42. data/lib/wheneverd/systemd/renderer.rb +153 -0
  43. data/lib/wheneverd/systemd/systemctl.rb +38 -0
  44. data/lib/wheneverd/systemd/time_parser.rb +75 -0
  45. data/lib/wheneverd/systemd/unit_deleter.rb +64 -0
  46. data/lib/wheneverd/systemd/unit_lister.rb +59 -0
  47. data/lib/wheneverd/systemd/unit_namer.rb +69 -0
  48. data/lib/wheneverd/systemd/unit_writer.rb +132 -0
  49. data/lib/wheneverd/trigger/boot.rb +26 -0
  50. data/lib/wheneverd/trigger/calendar.rb +26 -0
  51. data/lib/wheneverd/trigger/interval.rb +30 -0
  52. data/lib/wheneverd/version.rb +6 -0
  53. data/lib/wheneverd.rb +41 -0
  54. data/test/cli_activate_test.rb +110 -0
  55. data/test/cli_current_test.rb +94 -0
  56. data/test/cli_deactivate_test.rb +111 -0
  57. data/test/cli_end_to_end_test.rb +98 -0
  58. data/test/cli_reload_test.rb +132 -0
  59. data/test/cli_systemctl_integration_test.rb +76 -0
  60. data/test/cli_systemd_analyze_test.rb +64 -0
  61. data/test/cli_test.rb +332 -0
  62. data/test/domain_model_test.rb +108 -0
  63. data/test/dsl_calendar_symbol_period_list_test.rb +53 -0
  64. data/test/dsl_loader_test.rb +384 -0
  65. data/test/support/cli_subprocess_test_helpers.rb +38 -0
  66. data/test/support/cli_test_helpers.rb +114 -0
  67. data/test/systemd_calendar_spec_test.rb +45 -0
  68. data/test/systemd_cron_parser_test.rb +114 -0
  69. data/test/systemd_renderer_errors_test.rb +85 -0
  70. data/test/systemd_renderer_test.rb +161 -0
  71. data/test/systemd_systemctl_test.rb +46 -0
  72. data/test/systemd_time_parser_test.rb +25 -0
  73. data/test/systemd_unit_deleter_test.rb +83 -0
  74. data/test/systemd_unit_writer_prune_test.rb +85 -0
  75. data/test/systemd_unit_writer_test.rb +71 -0
  76. data/test/test_helper.rb +34 -0
  77. data/test/wheneverd_test.rb +9 -0
  78. data/wheneverd.gemspec +35 -0
  79. 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
@@ -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"
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class WheneverdTest < Minitest::Test
6
+ def test_has_a_version_number
7
+ refute_nil ::Wheneverd::VERSION
8
+ end
9
+ end
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