Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions lib/foreman/cron.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require 'rake'

module Foreman
module Cron
SUPPORTED_CADENCES = [:hourly, :daily, :weekly, :monthly].freeze

class << self
def register(cadence, task_name)
cadence = cadence.to_sym

unless SUPPORTED_CADENCES.include?(cadence)
logger.warn(
"Foreman::Cron[#{cadence}]: unknown cadence when registering #{task_name}, ignoring"
)
return
end

cadence_tasks = tasks_for(cadence)
cadence_tasks << task_name unless cadence_tasks.include?(task_name)
end

def run(cadence)
cadence = cadence.to_sym

cadence_tasks = tasks_for(cadence)
if cadence_tasks.empty?
logger.debug("Foreman::Cron[#{cadence}]: no tasks configured for this cadence")
return false
end

logger.info("Foreman::Cron[#{cadence}]: running #{cadence_tasks.size} task(s)")

failed = false

cadence_tasks.each do |task_name|
failed ||= !run_task(cadence, task_name)
end

failed
end

private

def tasks
@tasks ||= Hash.new { |h, k| h[k] = [] }
end

def tasks_for(cadence)
tasks[cadence.to_sym]
end

def run_task(cadence, task_name)
logger.info("Foreman::Cron[#{cadence}]: starting #{task_name}")

task = Rake::Task[task_name]
task.reenable
task.invoke

logger.info("Foreman::Cron[#{cadence}]: finished #{task_name}")
true
rescue StandardError => e
logger.error(
"Foreman::Cron[#{cadence}]: #{task_name} failed: #{e.class}: #{e.message}"
)
logger.debug(e.backtrace.join("\n")) if e.backtrace
false
end

def logger
@logger ||= Rails.logger
end
Comment on lines +69 to +71
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using the default Rails logger results in the logging to happen to /var/log/foreman/production.log, not stdout:

[root@ip-10-0-167-37 ~]# foreman-rake cron:hourly
W, [2025-12-19T02:10:18.445410 #202382]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:10:18.775945 #202382]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:10:18.992318 #202382]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:10:19.055307 #202382]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:10:19.271063 #202382]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:10:19.523901 #202382]  WARN -- : Scoped order is ignored, it's forced to be batch order.

[root@ip-10-0-167-37 ~]# grep Foreman::Cron /var/log/foreman/production.log
2025-12-19T02:10:20 [I|app|] Foreman::Cron[hourly]: running 1 task(s)
2025-12-19T02:10:20 [I|app|] Foreman::Cron[hourly]: starting ldap:refresh_usergroups
2025-12-19T02:10:20 [I|app|] Foreman::Cron[hourly]: finished ldap:refresh_usergroups

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using Logger.new($stdout) (or Logger.new($stderr) - but I think stdout is right) instead works:

[root@ip-10-0-167-37 ~]# foreman-rake cron:hourly
W, [2025-12-19T02:22:16.740554 #202577]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:22:17.077076 #202577]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:22:17.298995 #202577]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:22:17.361471 #202577]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:22:17.582867 #202577]  WARN -- : Scoped order is ignored, it's forced to be batch order.
W, [2025-12-19T02:22:17.837891 #202577]  WARN -- : Scoped order is ignored, it's forced to be batch order.
I, [2025-12-19T02:22:18.655119 #202577]  INFO -- : Foreman::Cron[hourly]: running 1 task(s)
I, [2025-12-19T02:22:18.655201 #202577]  INFO -- : Foreman::Cron[hourly]: starting ldap:refresh_usergroups
I, [2025-12-19T02:22:18.658580 #202577]  INFO -- : Foreman::Cron[hourly]: finished ldap:refresh_usergroups

end
end
end
41 changes: 41 additions & 0 deletions lib/tasks/cron.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require 'foreman/cron'

# Register built-in recurring tasks for each cadence.
# Plugins can also call Foreman::Cron.register(:daily, 'my_plugin:task').
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add this to https://github.com/theforeman/foreman/blob/develop/developer_docs/how_to_create_a_plugin.asciidoc

Also, where should they do this? I did that in the register_plugin initializer (in engine.rb of the plugin), and it worked, but is that the recommended place?

Foreman::Cron.register(:hourly, 'ldap:refresh_usergroups')

Foreman::Cron.register(:daily, 'reports:daily')
Foreman::Cron.register(:daily, 'db:sessions:clear')
Foreman::Cron.register(:daily, 'reports:expire')
Foreman::Cron.register(:daily, 'audits:expire')

Foreman::Cron.register(:weekly, 'reports:weekly')
Foreman::Cron.register(:weekly, 'notifications:clean')

Foreman::Cron.register(:monthly, 'reports:monthly')

namespace :cron do
desc 'Run hourly Foreman cron jobs'
task hourly: :environment do
failed = Foreman::Cron.run(:hourly)
raise "One or more hourly cron tasks failed" if failed
end

desc 'Run daily Foreman cron jobs'
task daily: :environment do
failed = Foreman::Cron.run(:daily)
raise "One or more daily cron tasks failed" if failed
end

desc 'Run weekly Foreman cron jobs'
task weekly: :environment do
failed = Foreman::Cron.run(:weekly)
raise "One or more weekly cron tasks failed" if failed
end

desc 'Run monthly Foreman cron jobs'
task monthly: :environment do
failed = Foreman::Cron.run(:monthly)
raise "One or more monthly cron tasks failed" if failed
end
end
72 changes: 72 additions & 0 deletions test/unit/foreman/cron_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
require 'test_helper'
require 'foreman/cron'
require 'logger'

class Foreman::CronTest < ActiveSupport::TestCase
setup do
Foreman::Cron.instance_variable_set(:@tasks, nil)
Foreman::Cron.instance_variable_set(:@logger, Logger.new(nil))
end

test 'register adds task for valid cadence' do
Foreman::Cron.register(:daily, 'test:task')

tasks = Foreman::Cron.send(:tasks_for, :daily)
assert_includes tasks, 'test:task'
end

test 'register does not add duplicate tasks' do
Foreman::Cron.register(:daily, 'test:task')
Foreman::Cron.register(:daily, 'test:task')

tasks = Foreman::Cron.send(:tasks_for, :daily)
assert_equal 1, tasks.count('test:task')
end

test 'register ignores invalid cadence' do
Foreman::Cron.register(:invalid, 'test:task')

tasks = Foreman::Cron.send(:tasks_for, :invalid)
assert_empty(tasks)
end

test 'run returns false when all tasks succeed' do
Foreman::Cron.instance_variable_set(:@tasks, { daily: ['test:task'] })

task = mock('rake_task')
task.expects(:reenable).once
task.expects(:invoke).once
Rake::Task.stubs(:[]).with('test:task').returns(task)

result = Foreman::Cron.run(:daily)

assert_equal false, result
end

test 'run returns true when a task fails but continues executing others' do
Foreman::Cron.instance_variable_set(:@tasks, { daily: %w[first second] })

failing = mock('failing_task')
failing.expects(:reenable).once
failing.expects(:invoke).raises(StandardError.new('boom'))

succeeding = mock('succeeding_task')
succeeding.expects(:reenable).once
succeeding.expects(:invoke).once

Check failure on line 55 in test/unit/foreman/cron_test.rb

View workflow job for this annotation

GitHub Actions / test:units - Ruby 3.0 and Node 18 on PostgreSQL 13

Failure: test_0005_run returns true when a task fails but continues executing others not all expectations were satisfied unsatisfied expectations: - expected exactly once, invoked never: #<Mock:succeeding_task>.invoke(any_parameters) - expected exactly once, invoked never: #<Mock:succeeding_task>.reenable(any_parameters) satisfied expectations: - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getaddresses(any_parameters) - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getaddress(any_parameters) - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getnames(any_parameters) - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getname(any_parameters) - expected exactly once, invoked once: #<Mock:failing_task>.invoke(any_parameters) - expected exactly once, invoked once: #<Mock:failing_task>.reenable(any_parameters) - allowed any number of times, invoked never: Rake::Task.[]("second") - allowed any number of times, invoked once: Rake::Task.[]("first")

Check failure on line 55 in test/unit/foreman/cron_test.rb

View workflow job for this annotation

GitHub Actions / test:units - Ruby 3.0 and Node 22 on PostgreSQL 13

Failure: test_0005_run returns true when a task fails but continues executing others not all expectations were satisfied unsatisfied expectations: - expected exactly once, invoked never: #<Mock:succeeding_task>.invoke(any_parameters) - expected exactly once, invoked never: #<Mock:succeeding_task>.reenable(any_parameters) satisfied expectations: - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getaddresses(any_parameters) - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getaddress(any_parameters) - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getnames(any_parameters) - allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getname(any_parameters) - expected exactly once, invoked once: #<Mock:failing_task>.invoke(any_parameters) - expected exactly once, invoked once: #<Mock:failing_task>.reenable(any_parameters) - allowed any number of times, invoked never: Rake::Task.[]("second") - allowed any number of times, invoked once: Rake::Task.[]("first")
Comment on lines +53 to +55
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The failure is related

Failure: test_0005_run returns true when a task fails but continues executing others

not all expectations were satisfied
unsatisfied expectations:
- expected exactly once, invoked never: #<Mock:succeeding_task>.invoke(any_parameters)
- expected exactly once, invoked never: #<Mock:succeeding_task>.reenable(any_parameters)
satisfied expectations:
- allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getaddresses(any_parameters)
- allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getaddress(any_parameters)
- allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getnames(any_parameters)
- allowed any number of times, invoked never: #<AnyInstance:Resolv::DNS>.getname(any_parameters)
- expected exactly once, invoked once: #<Mock:failing_task>.invoke(any_parameters)
- expected exactly once, invoked once: #<Mock:failing_task>.reenable(any_parameters)
- allowed any number of times, invoked never: Rake::Task.[]("second")
- allowed any number of times, invoked once: Rake::Task.[]("first")

Seems that the succeeding task is never executed?


Rake::Task.stubs(:[]).with('first').returns(failing)
Rake::Task.stubs(:[]).with('second').returns(succeeding)

result = Foreman::Cron.run(:daily)

assert_equal true, result
end

test 'run returns false when no tasks are configured' do
Foreman::Cron.instance_variable_set(:@tasks, { hourly: [] })

result = Foreman::Cron.run(:hourly)

assert_equal false, result
end
end
Loading