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,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_test_helpers"
5
+
6
+ class CLIDeactivateSuccessTest < Minitest::Test
7
+ include CLITestHelpers
8
+
9
+ def test_exits_zero
10
+ with_inited_project_dir do
11
+ status, _out, _err, _calls = run_deactivate_with_capture3_stub
12
+ assert_equal 0, status
13
+ end
14
+ end
15
+
16
+ def test_prints_timer_units
17
+ with_inited_project_dir do
18
+ status, out, err, _calls = run_deactivate_with_capture3_stub
19
+ assert_cli_success(status, err)
20
+ assert_includes out, expected_timer_basenames.fetch(0)
21
+ end
22
+ end
23
+
24
+ def test_runs_stop
25
+ with_inited_project_dir do
26
+ status, _out, err, calls = run_deactivate_with_capture3_stub
27
+ assert_cli_success(status, err)
28
+ assert_systemctl_call_starts_with(calls, 0, SYSTEMCTL_USER_PREFIX + ["stop"],
29
+ includes: expected_timer_basenames)
30
+ end
31
+ end
32
+
33
+ def test_runs_disable
34
+ with_inited_project_dir do
35
+ status, _out, err, calls = run_deactivate_with_capture3_stub
36
+ assert_cli_success(status, err)
37
+ assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["disable"],
38
+ includes: expected_timer_basenames)
39
+ end
40
+ end
41
+ end
42
+
43
+ class CLIDeactivateScheduleMissingTest < Minitest::Test
44
+ include CLITestHelpers
45
+
46
+ def test_exits_one
47
+ with_project_dir do
48
+ status, _out, _err = run_cli(["deactivate"])
49
+ assert_equal 1, status
50
+ end
51
+ end
52
+
53
+ def test_prints_error_message
54
+ with_project_dir do
55
+ _status, out, err = run_cli(["deactivate"])
56
+ assert_equal "", out
57
+ assert_includes err, "Schedule file not found"
58
+ end
59
+ end
60
+ end
61
+
62
+ class CLIDeactivateEmptyScheduleTest < Minitest::Test
63
+ include CLITestHelpers
64
+
65
+ def test_makes_no_systemctl_calls
66
+ with_project_dir do
67
+ write_empty_schedule
68
+ status, _out, err, calls = run_deactivate_with_capture3_stub
69
+ assert_cli_success(status, err)
70
+ assert_equal [], calls
71
+ end
72
+ end
73
+
74
+ def test_prints_nothing
75
+ with_project_dir do
76
+ write_empty_schedule
77
+ status, out, err, _calls = run_deactivate_with_capture3_stub
78
+ assert_cli_success(status, err)
79
+ assert_equal "", out
80
+ end
81
+ end
82
+ end
83
+
84
+ class CLIDeactivateSystemctlFailureTest < Minitest::Test
85
+ include CLITestHelpers
86
+
87
+ def test_exits_one
88
+ with_inited_project_dir do
89
+ status, _out, _err, _calls = run_deactivate_with_capture3_stub(exitstatus: 1,
90
+ stderr: "no bus\n")
91
+ assert_equal 1, status
92
+ end
93
+ end
94
+
95
+ def test_prints_systemctl_failed
96
+ with_inited_project_dir do
97
+ _status, _out, err, _calls = run_deactivate_with_capture3_stub(exitstatus: 1,
98
+ stderr: "no bus\n")
99
+ assert_includes err, "systemctl failed"
100
+ end
101
+ end
102
+
103
+ def test_only_calls_stop
104
+ with_inited_project_dir do
105
+ _status, _out, _err, calls = run_deactivate_with_capture3_stub(exitstatus: 1,
106
+ stderr: "no bus\n")
107
+ assert_equal 1, calls.length
108
+ assert_systemctl_call_starts_with(calls, 0, SYSTEMCTL_USER_PREFIX + ["stop"])
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_subprocess_test_helpers"
5
+
6
+ class CLIEndToEndTest < Minitest::Test
7
+ include CLISubprocessTestHelpers
8
+
9
+ def test_init_show_write_current_delete_workflow_via_exe
10
+ with_temp_project_dir do |project_dir|
11
+ init_schedule(project_dir)
12
+ show_units(project_dir)
13
+ unit_dir = File.join(project_dir, "tmp_units")
14
+ write_units(project_dir, unit_dir)
15
+ current_units(project_dir, unit_dir)
16
+ delete_units(project_dir, unit_dir)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def init_schedule(project_dir)
23
+ status, out, err = run_exe(["init"], chdir: project_dir)
24
+ assert_equal 0, status
25
+ assert_equal "", err
26
+ assert_includes out, "Wrote schedule template"
27
+ assert File.file?(File.join(project_dir, "config", "schedule.rb"))
28
+ end
29
+
30
+ def show_units(project_dir)
31
+ status, out, err = run_exe(["show", "--identifier", "demo"], chdir: project_dir)
32
+ assert_equal 0, status
33
+ assert_equal "", err
34
+ assert_includes out, "OnActiveSec=300"
35
+ assert_includes out, "OnUnitActiveSec=300"
36
+ assert_includes out, "ExecStart=echo hello"
37
+ end
38
+
39
+ def write_units(project_dir, unit_dir)
40
+ status, out, err = run_exe(
41
+ ["write", "--identifier", "demo", "--unit-dir", unit_dir],
42
+ chdir: project_dir
43
+ )
44
+ assert_equal 0, status
45
+ assert_equal "", err
46
+ assert_timer_unit_written(unit_dir, out)
47
+ assert_timer_unit_contents(unit_dir)
48
+ end
49
+
50
+ def current_units(project_dir, unit_dir)
51
+ status, out, err = run_exe(
52
+ ["current", "--identifier", "demo", "--unit-dir", unit_dir],
53
+ chdir: project_dir
54
+ )
55
+ assert_equal 0, status
56
+ assert_equal "", err
57
+ assert_match(/wheneverd-demo-[0-9a-f]{12}\.timer/, out)
58
+ assert_includes out, Wheneverd::Systemd::Renderer::MARKER_PREFIX
59
+ end
60
+
61
+ def delete_units(project_dir, unit_dir)
62
+ status, out, err = run_exe(
63
+ ["delete", "--identifier", "demo", "--unit-dir", unit_dir],
64
+ chdir: project_dir
65
+ )
66
+ assert_equal 0, status
67
+ assert_equal "", err
68
+ timer_path = out.lines.map(&:strip).find { |line| line.end_with?(".timer") }
69
+ assert timer_path, "expected delete to print at least one *.timer path"
70
+ assert_includes timer_path, unit_dir
71
+ refute File.exist?(timer_path)
72
+ end
73
+
74
+ def assert_timer_unit_written(unit_dir, out)
75
+ service_path = out.lines.map(&:strip).find { |line| line.end_with?(".service") }
76
+ timer_path = out.lines.map(&:strip).find { |line| line.end_with?(".timer") }
77
+ assert service_path, "expected write to print at least one *.service path"
78
+ assert timer_path, "expected write to print at least one *.timer path"
79
+ assert_includes service_path, unit_dir
80
+ assert_includes timer_path, unit_dir
81
+ assert File.exist?(timer_path)
82
+ end
83
+
84
+ def assert_timer_unit_contents(unit_dir)
85
+ timer_basenames = Dir.children(unit_dir).select { |b| b.end_with?(".timer") }
86
+ assert timer_basenames.any?, "expected unit_dir to contain at least one *.timer file"
87
+
88
+ timer_contents = timer_basenames.filter_map do |basename|
89
+ contents = File.read(File.join(unit_dir, basename))
90
+ contents if contents.include?("OnActiveSec=300")
91
+ end.first
92
+
93
+ assert timer_contents, "expected at least one interval timer with OnActiveSec=300"
94
+ assert_includes timer_contents, Wheneverd::Systemd::Renderer::MARKER_PREFIX
95
+ assert_includes timer_contents, "OnActiveSec=300"
96
+ assert_includes timer_contents, "OnUnitActiveSec=300"
97
+ end
98
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_test_helpers"
5
+
6
+ class CLIReloadSuccessTest < Minitest::Test
7
+ include CLITestHelpers
8
+
9
+ def test_exits_zero
10
+ with_inited_project_dir do |project_dir|
11
+ unit_dir = File.join(project_dir, "tmp_units")
12
+ status, _out, err, _calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
13
+ assert_cli_success(status, err)
14
+ end
15
+ end
16
+
17
+ def test_writes_unit_files
18
+ with_inited_project_dir do |project_dir|
19
+ unit_dir = File.join(project_dir, "tmp_units")
20
+ status, _out, err, _calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
21
+ assert_cli_success(status, err)
22
+ assert File.exist?(File.join(unit_dir, expected_timer_basenames.fetch(0)))
23
+ end
24
+ end
25
+
26
+ def test_runs_daemon_reload
27
+ with_inited_project_dir do |project_dir|
28
+ unit_dir = File.join(project_dir, "tmp_units")
29
+ status, _out, err, calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
30
+ assert_cli_success(status, err)
31
+ assert_systemctl_call(calls, 0, SYSTEMCTL_USER_PREFIX + ["daemon-reload"])
32
+ end
33
+ end
34
+
35
+ def test_runs_restart
36
+ with_inited_project_dir do |project_dir|
37
+ unit_dir = File.join(project_dir, "tmp_units")
38
+ status, _out, err, calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
39
+ assert_cli_success(status, err)
40
+ assert_systemctl_call_starts_with(calls, 1, SYSTEMCTL_USER_PREFIX + ["restart"],
41
+ includes: expected_timer_basenames)
42
+ end
43
+ end
44
+ end
45
+
46
+ class CLIReloadScheduleMissingTest < Minitest::Test
47
+ include CLITestHelpers
48
+
49
+ def test_exits_one
50
+ with_project_dir do |project_dir|
51
+ unit_dir = File.join(project_dir, "tmp_units")
52
+ status, _out, _err = run_cli(["reload", "--unit-dir", unit_dir])
53
+ assert_equal 1, status
54
+ end
55
+ end
56
+
57
+ def test_prints_error_message
58
+ with_project_dir do |project_dir|
59
+ unit_dir = File.join(project_dir, "tmp_units")
60
+ _status, out, err = run_cli(["reload", "--unit-dir", unit_dir])
61
+ assert_equal "", out
62
+ assert_includes err, "Schedule file not found"
63
+ end
64
+ end
65
+ end
66
+
67
+ class CLIReloadEmptyScheduleTest < Minitest::Test
68
+ include CLITestHelpers
69
+
70
+ def test_makes_no_systemctl_calls
71
+ with_project_dir do |project_dir|
72
+ unit_dir = File.join(project_dir, "tmp_units")
73
+ write_empty_schedule
74
+ status, _out, err, calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
75
+ assert_cli_success(status, err)
76
+ assert_equal [], calls
77
+ end
78
+ end
79
+
80
+ def test_prints_nothing
81
+ with_project_dir do |project_dir|
82
+ unit_dir = File.join(project_dir, "tmp_units")
83
+ write_empty_schedule
84
+ status, out, err, _calls = run_reload_with_capture3_stub(unit_dir: unit_dir)
85
+ assert_cli_success(status, err)
86
+ assert_equal "", out
87
+ end
88
+ end
89
+ end
90
+
91
+ class CLIReloadSystemctlFailureTest < Minitest::Test
92
+ include CLITestHelpers
93
+
94
+ def test_exits_one
95
+ with_inited_project_dir do |project_dir|
96
+ unit_dir = File.join(project_dir, "tmp_units")
97
+ status, _out, _err, _calls = run_reload_with_capture3_stub(unit_dir: unit_dir,
98
+ exitstatus: 1,
99
+ stderr: "no bus\n")
100
+ assert_equal 1, status
101
+ end
102
+ end
103
+
104
+ def test_prints_systemctl_failed
105
+ with_inited_project_dir do |project_dir|
106
+ unit_dir = File.join(project_dir, "tmp_units")
107
+ _status, _out, err, _calls = run_reload_with_capture3_stub(unit_dir: unit_dir,
108
+ exitstatus: 1,
109
+ stderr: "no bus\n")
110
+ assert_includes err, "systemctl failed"
111
+ end
112
+ end
113
+
114
+ def test_only_calls_daemon_reload
115
+ with_inited_project_dir do |project_dir|
116
+ unit_dir = File.join(project_dir, "tmp_units")
117
+ _status, _out, _err, calls = run_reload_with_capture3_stub(unit_dir: unit_dir,
118
+ exitstatus: 1,
119
+ stderr: "no bus\n")
120
+ assert_equal 1, calls.length
121
+ assert_systemctl_call(calls, 0, SYSTEMCTL_USER_PREFIX + ["daemon-reload"])
122
+ end
123
+ end
124
+
125
+ def test_writes_units_before_systemctl_failure
126
+ with_inited_project_dir do |project_dir|
127
+ unit_dir = File.join(project_dir, "tmp_units")
128
+ run_reload_with_capture3_stub(unit_dir: unit_dir, exitstatus: 1, stderr: "no bus\n")
129
+ assert File.exist?(File.join(unit_dir, expected_timer_basenames.fetch(0)))
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_subprocess_test_helpers"
5
+
6
+ class CLISystemctlIntegrationTest < Minitest::Test
7
+ include CLISubprocessTestHelpers
8
+
9
+ def test_reload_invokes_systemctl_via_path_injection
10
+ with_temp_project_dir { |project_dir| assert_reload_invokes_systemctl(project_dir) }
11
+ end
12
+
13
+ private
14
+
15
+ def assert_reload_invokes_systemctl(project_dir)
16
+ init_schedule(project_dir)
17
+ with_fake_systemctl(project_dir) do |env, log_path|
18
+ run_reload(project_dir, env)
19
+ assert_systemctl_log_includes_expected_calls(log_path)
20
+ end
21
+ end
22
+
23
+ def run_reload(project_dir, env)
24
+ unit_dir = File.join(project_dir, "tmp_units")
25
+ status, _out, err = run_exe(
26
+ ["reload", "--identifier", "demo", "--unit-dir", unit_dir],
27
+ chdir: project_dir,
28
+ env: env
29
+ )
30
+ assert_equal 0, status
31
+ assert_equal "", err
32
+ end
33
+
34
+ def init_schedule(project_dir)
35
+ status, out, err = run_exe(["init"], chdir: project_dir)
36
+ assert_equal 0, status
37
+ assert_equal "", err
38
+ assert_includes out, "Wrote schedule template"
39
+ end
40
+
41
+ def with_fake_systemctl(project_dir)
42
+ bin_dir = File.join(project_dir, "tmp_bin")
43
+ FileUtils.mkdir_p(bin_dir)
44
+ log_path = File.join(project_dir, "systemctl.log")
45
+
46
+ write_fake_systemctl(File.join(bin_dir, "systemctl"))
47
+
48
+ env = {
49
+ "PATH" => [bin_dir, ENV.fetch("PATH", "")].join(File::PATH_SEPARATOR),
50
+ "SYSTEMCTL_LOG" => log_path
51
+ }
52
+ yield env, log_path
53
+ end
54
+
55
+ def write_fake_systemctl(path)
56
+ File.write(
57
+ path,
58
+ <<~RUBY
59
+ #!/usr/bin/env ruby
60
+ # frozen_string_literal: true
61
+
62
+ log_path = ENV.fetch("SYSTEMCTL_LOG")
63
+ File.open(log_path, "a") { |f| f.puts(ARGV.join(" ")) }
64
+ exit 0
65
+ RUBY
66
+ )
67
+ FileUtils.chmod(0o755, path)
68
+ end
69
+
70
+ def assert_systemctl_log_includes_expected_calls(log_path)
71
+ log = File.read(log_path)
72
+ assert_includes log, "--user --no-pager daemon-reload"
73
+ assert_includes log, "--user --no-pager restart"
74
+ assert_match(/wheneverd-demo-[0-9a-f]{12}\.timer/, log)
75
+ end
76
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+ require_relative "support/cli_subprocess_test_helpers"
5
+
6
+ class CLISystemdAnalyzeTest < Minitest::Test
7
+ include CLISubprocessTestHelpers
8
+
9
+ def test_rendered_on_calendar_values_parse_with_systemd_analyze_calendar
10
+ analyze = require_executable!("systemd-analyze")
11
+ true_bin = absolute_true_path || "true"
12
+
13
+ with_temp_project_dir { |project_dir| assert_calendars_ok(analyze, project_dir, true_bin) }
14
+ end
15
+
16
+ private
17
+
18
+ def assert_calendars_ok(analyze, project_dir, true_bin)
19
+ write_schedule(project_dir, schedule_contents(true_bin))
20
+ status, out, err = run_exe(["show", "--identifier", "demo"], chdir: project_dir)
21
+ assert_equal 0, status
22
+ assert_equal "", err
23
+
24
+ values = on_calendar_values(out)
25
+ refute_empty values
26
+ values.each { |value| assert_systemd_analyze_calendar_ok(analyze, value) }
27
+ end
28
+
29
+ def require_executable!(name)
30
+ path = find_executable(name)
31
+ skip "#{name} not available" unless path
32
+ path
33
+ end
34
+
35
+ def schedule_contents(true_bin)
36
+ <<~RUBY
37
+ # frozen_string_literal: true
38
+
39
+ every :hour do
40
+ command "#{true_bin}"
41
+ end
42
+
43
+ every 1.day, at: "4:30 am" do
44
+ command "#{true_bin}"
45
+ end
46
+ RUBY
47
+ end
48
+
49
+ def absolute_true_path
50
+ %w[/usr/bin/true /bin/true].find { |p| File.file?(p) && File.executable?(p) }
51
+ end
52
+
53
+ def on_calendar_values(output)
54
+ output.lines
55
+ .grep(/\AOnCalendar=/)
56
+ .map { |line| line.delete_prefix("OnCalendar=").strip }
57
+ .uniq
58
+ end
59
+
60
+ def assert_systemd_analyze_calendar_ok(analyze, value)
61
+ _stdout, stderr, status = Open3.capture3(analyze, "calendar", value)
62
+ assert_equal 0, status.exitstatus, stderr
63
+ end
64
+ end