@@ -0,0 +1,89 @@ |
||
1 |
+require 'rufus-scheduler' |
|
2 |
+ |
|
3 |
+module Agents |
|
4 |
+ class SchedulerAgent < Agent |
|
5 |
+ cannot_be_scheduled! |
|
6 |
+ cannot_receive_events! |
|
7 |
+ cannot_create_events! |
|
8 |
+ can_run_other_agents! |
|
9 |
+ |
|
10 |
+ description <<-MD |
|
11 |
+ This agent periodically triggers a run of each target Agent according to a user-defined schedule. |
|
12 |
+ |
|
13 |
+ Select target Agents and set a cron-style schedule to `schedule`. |
|
14 |
+ In the traditional cron format, a schedule part consists of these five columns: `minute hour day-of-month month day-of-week`. |
|
15 |
+ |
|
16 |
+ * `0 22 * * 1-5`: every day of the week at 22:00 (10pm) |
|
17 |
+ |
|
18 |
+ In this variant, you can also specify seconds: |
|
19 |
+ |
|
20 |
+ * `30 0 22 * * 1-5`: every day of the week at 22:00:30 |
|
21 |
+ |
|
22 |
+ And timezones: |
|
23 |
+ |
|
24 |
+ * `0 22 * * 1-5 Europe/Paris`: every day of the week when it's 22:00 in Paris |
|
25 |
+ |
|
26 |
+ * `0 22 * * 1-5 Etc/GMT+2`: every day of the week when it's 22:00 in GMT+2 |
|
27 |
+ |
|
28 |
+ There's also a way to specify "last day of month": |
|
29 |
+ |
|
30 |
+ * `0 22 L * *`: every month on the last day at 22:00 |
|
31 |
+ |
|
32 |
+ And "monthdays": |
|
33 |
+ |
|
34 |
+ * `0 22 * * sun#1,sun#2`: every first and second sunday of the month, at 22:00 |
|
35 |
+ |
|
36 |
+ * `0 22 * * sun#L1`: every last sunday of the month, at 22:00 |
|
37 |
+ MD |
|
38 |
+ |
|
39 |
+ def default_options |
|
40 |
+ { 'schedule' => '0 * * * *' } |
|
41 |
+ end |
|
42 |
+ |
|
43 |
+ def working? |
|
44 |
+ true |
|
45 |
+ end |
|
46 |
+ |
|
47 |
+ def check! |
|
48 |
+ targets.active.each { |target| |
|
49 |
+ log "Agent run queued for '#{target.name}'" |
|
50 |
+ Agent.async_check(target.id) |
|
51 |
+ } |
|
52 |
+ end |
|
53 |
+ |
|
54 |
+ def validate_options |
|
55 |
+ if (spec = options['schedule']).present? |
|
56 |
+ begin |
|
57 |
+ Rufus::Scheduler::CronLine.new(spec) |
|
58 |
+ rescue ArgumentError |
|
59 |
+ errors.add(:base, "invalid schedule") |
|
60 |
+ end |
|
61 |
+ else |
|
62 |
+ errors.add(:base, "schedule is missing") |
|
63 |
+ end |
|
64 |
+ end |
|
65 |
+ |
|
66 |
+ before_save do |
|
67 |
+ self.memory.delete('scheduled_at') if self.options_changed? |
|
68 |
+ end |
|
69 |
+ |
|
70 |
+ def scheduler_tag |
|
71 |
+ '%s#%d' % [self.class.name, id] |
|
72 |
+ end |
|
73 |
+ |
|
74 |
+ class << self |
|
75 |
+ def scheduler_tag_to_id(tag) |
|
76 |
+ case tag |
|
77 |
+ when /\A#{Regexp.quote(self.name)}\#(\d+)\z/o |
|
78 |
+ $1.to_i |
|
79 |
+ end |
|
80 |
+ end |
|
81 |
+ |
|
82 |
+ def from_scheduler_tag(tag) |
|
83 |
+ if id = scheduler_tag_to_id |
|
84 |
+ find_by(id: id) |
|
85 |
+ end |
|
86 |
+ end |
|
87 |
+ end |
|
88 |
+ end |
|
89 |
+end |
@@ -40,6 +40,20 @@ |
||
40 | 40 |
</div> |
41 | 41 |
</div> |
42 | 42 |
|
43 |
+ <div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>"> |
|
44 |
+ <div class="can-be-scheduled"> |
|
45 |
+ <div class="form-group"> |
|
46 |
+ <%= f.label :runners %> |
|
47 |
+ <span class="glyphicon glyphicon-question-sign hover-help" data-content="Other than the system-defined schedule above, this agent may be run by user-defined ScheduleAgents."></span> |
|
48 |
+ <% eventRunners = current_user.agents.select(&:can_run_other_agents?) %> |
|
49 |
+ <%= f.select(:runner_ids, |
|
50 |
+ options_for_select(eventRunners.map {|s| [s.name, s.id] }, |
|
51 |
+ @agent.runner_ids), |
|
52 |
+ {}, { multiple: true, size: 5, class: 'select2 form-control' }) %> |
|
53 |
+ </div> |
|
54 |
+ </div> |
|
55 |
+ </div> |
|
56 |
+ |
|
43 | 57 |
<div class="chain-region" data-can-run-other-agents="<%= @agent.can_run_other_agents? %>"> |
44 | 58 |
<div class="can-run-other-agents"> |
45 | 59 |
<div class="form-group"> |
@@ -25,7 +25,7 @@ |
||
25 | 25 |
</td> |
26 | 26 |
<td class='<%= "agent-disabled" if agent.disabled? %>'> |
27 | 27 |
<% if agent.can_be_scheduled? %> |
28 |
- <%= agent.schedule.to_s.humanize.titleize %> |
|
28 |
+ <%= agent_schedule(agent, ',<br/>') %> |
|
29 | 29 |
<% else %> |
30 | 30 |
<span class='not-applicable'></span> |
31 | 31 |
<% end %> |
@@ -72,7 +72,7 @@ |
||
72 | 72 |
<% if @agent.can_be_scheduled? %> |
73 | 73 |
<p> |
74 | 74 |
<b>Schedule:</b> |
75 |
- <%= (@agent.schedule || "n/a").humanize.titleize %> |
|
75 |
+ <%= agent_schedule(@agent) %> |
|
76 | 76 |
</p> |
77 | 77 |
|
78 | 78 |
<p> |
@@ -1,5 +1,87 @@ |
||
1 | 1 |
require 'rufus/scheduler' |
2 | 2 |
|
3 |
+class Rufus::Scheduler |
|
4 |
+ SCHEDULER_AGENT_TAG = Agents::SchedulerAgent.name |
|
5 |
+ |
|
6 |
+ class Job |
|
7 |
+ # Extract an ID of SchedulerAgent if a matching tag is found. |
|
8 |
+ def scheduler_agent_id |
|
9 |
+ tags.each { |tag| |
|
10 |
+ if agent_id = Agents::SchedulerAgent.scheduler_tag_to_id(tag) |
|
11 |
+ return agent_id |
|
12 |
+ end |
|
13 |
+ } |
|
14 |
+ end |
|
15 |
+ |
|
16 |
+ # Return a SchedulerAgent tied to this job. Return nil if it is |
|
17 |
+ # not found or disabled. |
|
18 |
+ def scheduler_agent |
|
19 |
+ agent_id = scheduler_agent_id or return nil |
|
20 |
+ |
|
21 |
+ Agent.of_type(Agents::SchedulerAgent).active.find_by(id: agent_id) |
|
22 |
+ end |
|
23 |
+ end |
|
24 |
+ |
|
25 |
+ # Get all jobs tied to any SchedulerAgent |
|
26 |
+ def scheduler_agent_jobs |
|
27 |
+ jobs(tag: SCHEDULER_AGENT_TAG) |
|
28 |
+ end |
|
29 |
+ |
|
30 |
+ # Get a job tied to a given SchedulerAgent |
|
31 |
+ def scheduler_agent_job(agent) |
|
32 |
+ jobs(tags: [SCHEDULER_AGENT_TAG, agent.scheduler_tag]).first |
|
33 |
+ end |
|
34 |
+ |
|
35 |
+ # Schedule or reschedule a job for a given SchedulerAgent and return |
|
36 |
+ # the running job. Return nil if unscheduled. |
|
37 |
+ def schedule_scheduler_agent(agent) |
|
38 |
+ job = scheduler_agent_job(agent) |
|
39 |
+ |
|
40 |
+ if agent.disabled? |
|
41 |
+ if job |
|
42 |
+ puts "Unscheduling SchedulerAgent##{agent.id} (disabled)" |
|
43 |
+ job.unschedule |
|
44 |
+ end |
|
45 |
+ nil |
|
46 |
+ else |
|
47 |
+ if job |
|
48 |
+ return job if agent.memory['scheduled_at'] == job.scheduled_at.to_i |
|
49 |
+ puts "Rescheduling SchedulerAgent##{agent.id}" |
|
50 |
+ job.unschedule |
|
51 |
+ else |
|
52 |
+ puts "Scheduling SchedulerAgent##{agent.id}" |
|
53 |
+ end |
|
54 |
+ |
|
55 |
+ job = schedule_cron agent.options['schedule'], tags: [SCHEDULER_AGENT_TAG, agent.scheduler_tag] do |job| |
|
56 |
+ if scheduler_agent = job.scheduler_agent |
|
57 |
+ scheduler_agent.check! |
|
58 |
+ else |
|
59 |
+ puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (disabled or deleted)" |
|
60 |
+ job.unschedule |
|
61 |
+ end |
|
62 |
+ end |
|
63 |
+ |
|
64 |
+ agent.memory['scheduled_at'] = job.scheduled_at.to_i |
|
65 |
+ agent.save |
|
66 |
+ |
|
67 |
+ job |
|
68 |
+ end |
|
69 |
+ end |
|
70 |
+ |
|
71 |
+ # Schedule or reschedule jobs for all SchedulerAgents and unschedule |
|
72 |
+ # orphaned jobs if any. |
|
73 |
+ def schedule_scheduler_agents |
|
74 |
+ scheduled_jobs = Agent.of_type(Agents::SchedulerAgent).map { |scheduler_agent| |
|
75 |
+ schedule_scheduler_agent(scheduler_agent) |
|
76 |
+ }.compact |
|
77 |
+ |
|
78 |
+ (scheduler_agent_jobs - scheduled_jobs).each { |job| |
|
79 |
+ puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (orphaned)" |
|
80 |
+ job.unschedule |
|
81 |
+ } |
|
82 |
+ end |
|
83 |
+end |
|
84 |
+ |
|
3 | 85 |
class HuginnScheduler |
4 | 86 |
attr_accessor :mutex |
5 | 87 |
|
@@ -82,6 +164,12 @@ class HuginnScheduler |
||
82 | 164 |
end |
83 | 165 |
end |
84 | 166 |
|
167 |
+ # Schedule Scheduler Agents |
|
168 |
+ |
|
169 |
+ @rufus_scheduler.every '1m' do |
|
170 |
+ @rufus_scheduler.schedule_scheduler_agents |
|
171 |
+ end |
|
172 |
+ |
|
85 | 173 |
@rufus_scheduler.join |
86 | 174 |
end |
87 | 175 |
end |
@@ -0,0 +1,54 @@ |
||
1 |
+require 'spec_helper' |
|
2 |
+ |
|
3 |
+describe Agents::SchedulerAgent do |
|
4 |
+ before do |
|
5 |
+ @agent = Agents::SchedulerAgent.new(name: 'Example', options: { 'schedule' => '0 * * * *' }) |
|
6 |
+ @agent.user = users(:bob) |
|
7 |
+ end |
|
8 |
+ |
|
9 |
+ describe "validation" do |
|
10 |
+ it "should validate schedule" do |
|
11 |
+ @agent.should be_valid |
|
12 |
+ |
|
13 |
+ @agent.options.delete('schedule') |
|
14 |
+ @agent.should_not be_valid |
|
15 |
+ |
|
16 |
+ @agent.options['schedule'] = nil |
|
17 |
+ @agent.should_not be_valid |
|
18 |
+ |
|
19 |
+ @agent.options['schedule'] = '' |
|
20 |
+ @agent.should_not be_valid |
|
21 |
+ |
|
22 |
+ @agent.options['schedule'] = '0' |
|
23 |
+ @agent.should_not be_valid |
|
24 |
+ |
|
25 |
+ @agent.options['schedule'] = '*/15 * * * * * *' |
|
26 |
+ @agent.should_not be_valid |
|
27 |
+ |
|
28 |
+ @agent.options['schedule'] = '*/15 * * * * *' |
|
29 |
+ @agent.should be_valid |
|
30 |
+ |
|
31 |
+ @agent.options['schedule'] = '*/1 * * * *' |
|
32 |
+ @agent.should be_valid |
|
33 |
+ |
|
34 |
+ @agent.options['schedule'] = '*/1 * * *' |
|
35 |
+ @agent.should_not be_valid |
|
36 |
+ end |
|
37 |
+ end |
|
38 |
+ |
|
39 |
+ describe "check!" do |
|
40 |
+ it "should run targets" do |
|
41 |
+ targets = [agents(:bob_website_agent), agents(:bob_weather_agent)] |
|
42 |
+ @agent.targets = targets |
|
43 |
+ @agent.save! |
|
44 |
+ |
|
45 |
+ target_ids = targets.map(&:id) |
|
46 |
+ stub(Agent).async_check(anything) { |id| |
|
47 |
+ target_ids.delete(id) |
|
48 |
+ } |
|
49 |
+ |
|
50 |
+ @agent.check! |
|
51 |
+ target_ids.should be_empty |
|
52 |
+ end |
|
53 |
+ end |
|
54 |
+end |