@@ -118,6 +118,12 @@ ALLOW_JSONPATH_EVAL=false |
||
118 | 118 |
# when you trust everyone using your Huginn installation. |
119 | 119 |
ENABLE_INSECURE_AGENTS=false |
120 | 120 |
|
121 |
+# Enable this setting to allow second precision schedule in |
|
122 |
+# SchedulerAgent. By default, the use of the "second" field is |
|
123 |
+# restricted so that any value other than a single zero (which means |
|
124 |
+# "on the minute") is disallowed to prevent abuse of service. |
|
125 |
+ENABLE_SECOND_PRECISION_SCHEDULE=false |
|
126 |
+ |
|
121 | 127 |
# Use Graphviz for generating diagrams instead of using Google Chart |
122 | 128 |
# Tools. Specify a dot(1) command path built with SVG support |
123 | 129 |
# enabled. |
@@ -23,14 +23,13 @@ window.setupJsonEditor = ($editors = $(".live-json-editor")) -> |
||
23 | 23 |
return editors |
24 | 24 |
|
25 | 25 |
hideSchedule = -> |
26 |
- $(".schedule-region select").hide() |
|
26 |
+ $(".schedule-region .can-be-scheduled").hide() |
|
27 | 27 |
$(".schedule-region .cannot-be-scheduled").show() |
28 | 28 |
|
29 | 29 |
showSchedule = (defaultSchedule = null) -> |
30 |
- $(".schedule-region select").show() |
|
31 | 30 |
if defaultSchedule? |
32 | 31 |
$(".schedule-region select").val(defaultSchedule).change() |
33 |
- $(".schedule-region select").show() |
|
32 |
+ $(".schedule-region .can-be-scheduled").show() |
|
34 | 33 |
$(".schedule-region .cannot-be-scheduled").hide() |
35 | 34 |
|
36 | 35 |
hideLinks = -> |
@@ -44,6 +43,12 @@ showLinks = -> |
||
44 | 43 |
$(".link-region .cannot-receive-events").hide() |
45 | 44 |
showEventDescriptions() |
46 | 45 |
|
46 |
+hideControlLinks = -> |
|
47 |
+ $(".control-link-region").hide() |
|
48 |
+ |
|
49 |
+showControlLinks = -> |
|
50 |
+ $(".control-link-region").show() |
|
51 |
+ |
|
47 | 52 |
hideEventCreation = -> |
48 | 53 |
$(".event-related-region").hide() |
49 | 54 |
|
@@ -162,6 +167,11 @@ $(document).ready -> |
||
162 | 167 |
else |
163 | 168 |
hideLinks() |
164 | 169 |
|
170 |
+ if json.can_control_other_agents |
|
171 |
+ showControlLinks() |
|
172 |
+ else |
|
173 |
+ hideControlLinks() |
|
174 |
+ |
|
165 | 175 |
if json.can_create_events |
166 | 176 |
showEventCreation() |
167 | 177 |
else |
@@ -194,6 +204,12 @@ $(document).ready -> |
||
194 | 204 |
else |
195 | 205 |
hideLinks() |
196 | 206 |
|
207 |
+ if $(".control-link-region") |
|
208 |
+ if $(".control-link-region").data("can-control-other-agents") == true |
|
209 |
+ showControlLinks() |
|
210 |
+ else |
|
211 |
+ hideControlLinks() |
|
212 |
+ |
|
197 | 213 |
if $(".event-related-region") |
198 | 214 |
if $(".event-related-region").data("can-create-events") == true |
199 | 215 |
showEventCreation() |
@@ -60,6 +60,10 @@ img.odin { |
||
60 | 60 |
display: none; |
61 | 61 |
} |
62 | 62 |
|
63 |
+.controller-region[data-has-controllers=false] { |
|
64 |
+ display: none; |
|
65 |
+} |
|
66 |
+ |
|
63 | 67 |
img.spinner { |
64 | 68 |
display: none; |
65 | 69 |
vertical-align: bottom; |
@@ -0,0 +1,51 @@ |
||
1 |
+module AgentControllerConcern |
|
2 |
+ extend ActiveSupport::Concern |
|
3 |
+ |
|
4 |
+ included do |
|
5 |
+ validate :validate_control_action |
|
6 |
+ end |
|
7 |
+ |
|
8 |
+ def default_options |
|
9 |
+ { |
|
10 |
+ 'action' => 'run', |
|
11 |
+ } |
|
12 |
+ end |
|
13 |
+ |
|
14 |
+ def control_action |
|
15 |
+ options['action'].presence || 'run' |
|
16 |
+ end |
|
17 |
+ |
|
18 |
+ def validate_control_action |
|
19 |
+ case control_action |
|
20 |
+ when 'run' |
|
21 |
+ control_targets.each { |target| |
|
22 |
+ if target.cannot_be_scheduled? |
|
23 |
+ errors.add(:base, "#{target.name} cannot be scheduled") |
|
24 |
+ end |
|
25 |
+ } |
|
26 |
+ when 'enable', 'disable' |
|
27 |
+ else |
|
28 |
+ errors.add(:base, 'invalid action') |
|
29 |
+ end |
|
30 |
+ end |
|
31 |
+ |
|
32 |
+ def control! |
|
33 |
+ control_targets.active.each { |target| |
|
34 |
+ begin |
|
35 |
+ case control_action |
|
36 |
+ when 'run' |
|
37 |
+ log "Agent run queued for '#{target.name}'" |
|
38 |
+ Agent.async_check(target.id) |
|
39 |
+ when 'enable' |
|
40 |
+ log "Enabling the Agent '#{target.name}'" |
|
41 |
+ target.update!(disable: false) if target.disabled? |
|
42 |
+ when 'disable' |
|
43 |
+ log "Disabling the Agent '#{target.name}'" |
|
44 |
+ target.update!(disable: true) unless target.disabled? |
|
45 |
+ end |
|
46 |
+ rescue => e |
|
47 |
+ error "Failed to #{control_action} '#{target.name}': #{e.message}" |
|
48 |
+ end |
|
49 |
+ } |
|
50 |
+ end |
|
51 |
+end |
@@ -37,6 +37,7 @@ class AgentsController < ApplicationController |
||
37 | 37 |
:default_schedule => @agent.default_schedule, |
38 | 38 |
:can_receive_events => @agent.can_receive_events?, |
39 | 39 |
:can_create_events => @agent.can_create_events?, |
40 |
+ :can_control_other_agents => @agent.can_control_other_agents?, |
|
40 | 41 |
:options => @agent.default_options, |
41 | 42 |
:description_html => @agent.html_description, |
42 | 43 |
:form => render_to_string(partial: 'oauth_dropdown') |
@@ -15,4 +15,26 @@ module AgentHelper |
||
15 | 15 |
def agent_show_class(agent) |
16 | 16 |
agent.short_type.underscore.dasherize |
17 | 17 |
end |
18 |
+ |
|
19 |
+ def agent_schedule(agent, delimiter = ', ') |
|
20 |
+ return 'n/a' unless agent.can_be_scheduled? |
|
21 |
+ |
|
22 |
+ case agent.schedule |
|
23 |
+ when nil, 'never' |
|
24 |
+ agent_controllers(agent, delimiter) || 'Never' |
|
25 |
+ else |
|
26 |
+ [ |
|
27 |
+ agent.schedule.humanize.titleize, |
|
28 |
+ *(agent_controllers(agent, delimiter)) |
|
29 |
+ ].join(delimiter).html_safe |
|
30 |
+ end |
|
31 |
+ end |
|
32 |
+ |
|
33 |
+ def agent_controllers(agent, delimiter = ', ') |
|
34 |
+ unless agent.controllers.empty? |
|
35 |
+ agent.controllers.map { |agent| |
|
36 |
+ link_to(agent.name, agent_path(agent)) |
|
37 |
+ }.join(delimiter).html_safe |
|
38 |
+ end |
|
39 |
+ end |
|
18 | 40 |
end |
@@ -138,7 +138,9 @@ module DotHelper |
||
138 | 138 |
def agent_edge(agent, receiver) |
139 | 139 |
edge(agent_id[agent], |
140 | 140 |
agent_id[receiver], |
141 |
- style: ('dashed' unless receiver.propagate_immediately), |
|
141 |
+ style: ('dashed' unless receiver.propagate_immediately?), |
|
142 |
+ label: (" #{agent.control_action}s " if agent.can_control_other_agents?), |
|
143 |
+ arrowhead: ('empty' if agent.can_control_other_agents?), |
|
142 | 144 |
color: (@disabled if agent.disabled? || receiver.disabled?)) |
143 | 145 |
end |
144 | 146 |
|
@@ -151,10 +153,17 @@ module DotHelper |
||
151 | 153 |
fontsize: 10, |
152 | 154 |
fontname: ('Helvetica' if rich) |
153 | 155 |
|
156 |
+ statement 'edge', |
|
157 |
+ fontsize: 10, |
|
158 |
+ fontname: ('Helvetica' if rich) |
|
159 |
+ |
|
154 | 160 |
agents.each.with_index { |agent, index| |
155 | 161 |
agent_node(agent) |
156 | 162 |
|
157 |
- agent.receivers.each { |receiver| |
|
163 |
+ [ |
|
164 |
+ *agent.receivers, |
|
165 |
+ *(agent.control_targets if agent.can_control_other_agents?) |
|
166 |
+ ].each { |receiver| |
|
158 | 167 |
agent_edge(agent, receiver) if agents.include?(receiver) |
159 | 168 |
} |
160 | 169 |
} |
@@ -25,13 +25,15 @@ class Agent < ActiveRecord::Base |
||
25 | 25 |
|
26 | 26 |
EVENT_RETENTION_SCHEDULES = [["Forever", 0], ["1 day", 1], *([2, 3, 4, 5, 7, 14, 21, 30, 45, 90, 180, 365].map {|n| ["#{n} days", n] })] |
27 | 27 |
|
28 |
- attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately |
|
28 |
+ attr_accessible :options, :memory, :name, :type, :schedule, :controller_ids, :control_target_ids, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately |
|
29 | 29 |
|
30 | 30 |
json_serialize :options, :memory |
31 | 31 |
|
32 | 32 |
validates_presence_of :name, :user |
33 | 33 |
validates_inclusion_of :keep_events_for, :in => EVENT_RETENTION_SCHEDULES.map(&:last) |
34 | 34 |
validate :sources_are_owned |
35 |
+ validate :controllers_are_owned |
|
36 |
+ validate :control_targets_are_owned |
|
35 | 37 |
validate :scenarios_are_owned |
36 | 38 |
validate :validate_schedule |
37 | 39 |
validate :validate_options |
@@ -53,6 +55,10 @@ class Agent < ActiveRecord::Base |
||
53 | 55 |
has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver |
54 | 56 |
has_many :sources, :through => :links_as_receiver, :class_name => "Agent", :inverse_of => :receivers |
55 | 57 |
has_many :receivers, :through => :links_as_source, :class_name => "Agent", :inverse_of => :sources |
58 |
+ has_many :control_links_as_controller, dependent: :delete_all, foreign_key: 'controller_id', class_name: 'ControlLink', inverse_of: :controller |
|
59 |
+ has_many :control_links_as_control_target, dependent: :delete_all, foreign_key: 'control_target_id', class_name: 'ControlLink', inverse_of: :control_target |
|
60 |
+ has_many :controllers, through: :control_links_as_control_target, class_name: "Agent", inverse_of: :control_targets |
|
61 |
+ has_many :control_targets, through: :control_links_as_controller, class_name: "Agent", inverse_of: :controllers |
|
56 | 62 |
has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent |
57 | 63 |
has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents |
58 | 64 |
|
@@ -175,6 +181,10 @@ class Agent < ActiveRecord::Base |
||
175 | 181 |
!cannot_create_events? |
176 | 182 |
end |
177 | 183 |
|
184 |
+ def can_control_other_agents? |
|
185 |
+ self.class.can_control_other_agents? |
|
186 |
+ end |
|
187 |
+ |
|
178 | 188 |
def log(message, options = {}) |
179 | 189 |
puts "Agent##{id}: #{message}" unless Rails.env.test? |
180 | 190 |
AgentLog.log_for_agent(self, message, options) |
@@ -214,11 +224,19 @@ class Agent < ActiveRecord::Base |
||
214 | 224 |
private |
215 | 225 |
|
216 | 226 |
def sources_are_owned |
217 |
- errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user == user } |
|
227 |
+ errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user_id == user_id } |
|
218 | 228 |
end |
219 | 229 |
|
230 |
+ def controllers_are_owned |
|
231 |
+ errors.add(:controllers, "must be owned by you") unless controllers.all? {|s| s.user_id == user_id } |
|
232 |
+ end |
|
233 |
+ |
|
234 |
+ def control_targets_are_owned |
|
235 |
+ errors.add(:control_targets, "must be owned by you") unless control_targets.all? {|s| s.user_id == user_id } |
|
236 |
+ end |
|
237 |
+ |
|
220 | 238 |
def scenarios_are_owned |
221 |
- errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user == user } |
|
239 |
+ errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user_id == user_id } |
|
222 | 240 |
end |
223 | 241 |
|
224 | 242 |
def validate_schedule |
@@ -248,7 +266,8 @@ class Agent < ActiveRecord::Base |
||
248 | 266 |
|
249 | 267 |
class << self |
250 | 268 |
def build_clone(original) |
251 |
- new(original.slice(:type, :options, :schedule, :source_ids, :keep_events_for, :propagate_immediately)) { |clone| |
|
269 |
+ new(original.slice(:type, :options, :schedule, :controller_ids, :control_target_ids, |
|
270 |
+ :source_ids, :keep_events_for, :propagate_immediately)) { |clone| |
|
252 | 271 |
# Give it a unique name |
253 | 272 |
2.upto(count) do |i| |
254 | 273 |
name = '%s (%d)' % [original.name, i] |
@@ -289,6 +308,10 @@ class Agent < ActiveRecord::Base |
||
289 | 308 |
!!@cannot_receive_events |
290 | 309 |
end |
291 | 310 |
|
311 |
+ def can_control_other_agents? |
|
312 |
+ include? AgentControllerConcern |
|
313 |
+ end |
|
314 |
+ |
|
292 | 315 |
# Find all Agents that have received Events since the last execution of this method. Update those Agents with |
293 | 316 |
# their new `last_checked_event_id` and queue each of the Agents to be called with #receive using `async_receive`. |
294 | 317 |
# This is called by bin/schedule.rb periodically. |
@@ -398,6 +421,8 @@ class AgentDrop |
||
398 | 421 |
:sources, |
399 | 422 |
:receivers, |
400 | 423 |
:schedule, |
424 |
+ :controllers, |
|
425 |
+ :control_targets, |
|
401 | 426 |
:disabled, |
402 | 427 |
:keep_events_for, |
403 | 428 |
:propagate_immediately, |
@@ -0,0 +1,113 @@ |
||
1 |
+require 'rufus-scheduler' |
|
2 |
+ |
|
3 |
+module Agents |
|
4 |
+ class SchedulerAgent < Agent |
|
5 |
+ include AgentControllerConcern |
|
6 |
+ |
|
7 |
+ cannot_be_scheduled! |
|
8 |
+ cannot_receive_events! |
|
9 |
+ cannot_create_events! |
|
10 |
+ |
|
11 |
+ @@second_precision_enabled = ENV['ENABLE_SECOND_PRECISION_SCHEDULE'] == 'true' |
|
12 |
+ |
|
13 |
+ cattr_reader :second_precision_enabled |
|
14 |
+ |
|
15 |
+ description <<-MD |
|
16 |
+ This agent periodically takes an action on target Agents according to a user-defined schedule. |
|
17 |
+ |
|
18 |
+ # Action types |
|
19 |
+ |
|
20 |
+ Set `action` to one of the action types below: |
|
21 |
+ |
|
22 |
+ * `run`: This is the default. Target Agents are run at intervals. |
|
23 |
+ |
|
24 |
+ * `disable`: Target Agents are disabled (if not) at intervals. |
|
25 |
+ |
|
26 |
+ * `enable`: Target Agents are enabled (if not) at intervals. |
|
27 |
+ |
|
28 |
+ # Targets |
|
29 |
+ |
|
30 |
+ Select Agents that you want to run periodically by this SchedulerAgent. |
|
31 |
+ |
|
32 |
+ # Schedule |
|
33 |
+ |
|
34 |
+ Set `schedule` to a schedule specification in the [cron](http://en.wikipedia.org/wiki/Cron) format. |
|
35 |
+ For example: |
|
36 |
+ |
|
37 |
+ * `0 22 * * 1-5`: every day of the week at 22:00 (10pm) |
|
38 |
+ |
|
39 |
+ * `*/10 8-11 * * *`: every 10 minutes from 8:00 to and not including 12:00 |
|
40 |
+ |
|
41 |
+ This variant has several extensions as explained below. |
|
42 |
+ |
|
43 |
+ ## Timezones |
|
44 |
+ |
|
45 |
+ You can optionally specify a timezone (default: `#{Time.zone.name}`) after the day-of-week field. |
|
46 |
+ |
|
47 |
+ * `0 22 * * 1-5 Europe/Paris`: every day of the week when it's 22:00 in Paris |
|
48 |
+ |
|
49 |
+ * `0 22 * * 1-5 Etc/GMT+2`: every day of the week when it's 22:00 in GMT+2 |
|
50 |
+ |
|
51 |
+ ## Seconds |
|
52 |
+ |
|
53 |
+ You can optionally specify seconds before the minute field. |
|
54 |
+ |
|
55 |
+ * `*/30 * * * * *`: every 30 seconds |
|
56 |
+ |
|
57 |
+ #{"Only multiples of fifteen are allowed as values for the seconds field, i.e. `*/15`, `*/30`, `15,45` etc." unless second_precision_enabled} |
|
58 |
+ |
|
59 |
+ ## Last day of month |
|
60 |
+ |
|
61 |
+ `L` signifies "last day of month" in `day-of-month`. |
|
62 |
+ |
|
63 |
+ * `0 22 L * *`: every month on the last day at 22:00 |
|
64 |
+ |
|
65 |
+ ## Weekday names |
|
66 |
+ |
|
67 |
+ You can use three letter names instead of numbers in the `weekdays` field. |
|
68 |
+ |
|
69 |
+ * `0 22 * * Sat,Sun`: every Saturday and Sunday, at 22:00 |
|
70 |
+ |
|
71 |
+ ## Nth weekday of the month |
|
72 |
+ |
|
73 |
+ You can specify "nth weekday of the month" like this. |
|
74 |
+ |
|
75 |
+ * `0 22 * * Sun#1,Sun#2`: every first and second Sunday of the month, at 22:00 |
|
76 |
+ |
|
77 |
+ * `0 22 * * Sun#L1`: every last Sunday of the month, at 22:00 |
|
78 |
+ MD |
|
79 |
+ |
|
80 |
+ def default_options |
|
81 |
+ super.update({ |
|
82 |
+ 'schedule' => '0 * * * *', |
|
83 |
+ }) |
|
84 |
+ end |
|
85 |
+ |
|
86 |
+ def working? |
|
87 |
+ true |
|
88 |
+ end |
|
89 |
+ |
|
90 |
+ def check! |
|
91 |
+ control! |
|
92 |
+ end |
|
93 |
+ |
|
94 |
+ def validate_options |
|
95 |
+ if (spec = options['schedule']).present? |
|
96 |
+ begin |
|
97 |
+ cron = Rufus::Scheduler::CronLine.new(spec) |
|
98 |
+ unless second_precision_enabled || (cron.seconds - [0, 15, 30, 45, 60]).empty? |
|
99 |
+ errors.add(:base, "second precision schedule is not allowed in this service") |
|
100 |
+ end |
|
101 |
+ rescue ArgumentError |
|
102 |
+ errors.add(:base, "invalid schedule") |
|
103 |
+ end |
|
104 |
+ else |
|
105 |
+ errors.add(:base, "schedule is missing") |
|
106 |
+ end |
|
107 |
+ end |
|
108 |
+ |
|
109 |
+ before_save do |
|
110 |
+ self.memory.delete('scheduled_at') if self.options_changed? |
|
111 |
+ end |
|
112 |
+ end |
|
113 |
+end |
@@ -0,0 +1,7 @@ |
||
1 |
+# A ControlLink connects Agents in a control flow from the `controller` to the `control_target`. |
|
2 |
+class ControlLink < ActiveRecord::Base |
|
3 |
+ attr_accessible :controller_id, :target_id |
|
4 |
+ |
|
5 |
+ belongs_to :controller, class_name: 'Agent', inverse_of: :control_links_as_controller |
|
6 |
+ belongs_to :control_target, class_name: 'Agent', inverse_of: :control_links_as_control_target |
|
7 |
+end |
@@ -37,11 +37,36 @@ |
||
37 | 37 |
<div class="form-group"> |
38 | 38 |
<%= f.label :schedule, :class => 'control-label' %> |
39 | 39 |
<div class="schedule-region" data-can-be-scheduled="<%= @agent.can_be_scheduled? %>"> |
40 |
- <%= f.select :schedule, options_for_select(Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] }, @agent.schedule), {}, :class => 'form-control' %> |
|
40 |
+ <div class="can-be-scheduled"> |
|
41 |
+ <%= f.select :schedule, options_for_select(Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] }, @agent.schedule), {}, :class => 'form-control' %> |
|
42 |
+ </div> |
|
41 | 43 |
<span class='cannot-be-scheduled text-info'>This type of Agent cannot be scheduled.</span> |
42 | 44 |
</div> |
43 | 45 |
</div> |
44 | 46 |
|
47 |
+ <div class="controller-region" data-has-controllers="<%= !@agent.controllers.empty? %>"> |
|
48 |
+ <div class="form-group"> |
|
49 |
+ <%= f.label :controllers %> |
|
50 |
+ <span class="glyphicon glyphicon-question-sign hover-help" data-content="Other than the system-defined schedule above, this agent may be run or controlled by these user-defined Agents."></span> |
|
51 |
+ <div class="controller-list"> |
|
52 |
+ <%= agent_controllers(@agent) || 'None' %> |
|
53 |
+ </div> |
|
54 |
+ </div> |
|
55 |
+ </div> |
|
56 |
+ |
|
57 |
+ <div class="control-link-region" data-can-control-other-agents="<%= @agent.can_control_other_agents? %>"> |
|
58 |
+ <div class="can-control-other-agents"> |
|
59 |
+ <div class="form-group"> |
|
60 |
+ <%= f.label :control_targets %> |
|
61 |
+ <% eventControlTargets = current_user.agents.select(&:can_be_scheduled?) %> |
|
62 |
+ <%= f.select(:control_target_ids, |
|
63 |
+ options_for_select(eventControlTargets.map {|s| [s.name, s.id] }, |
|
64 |
+ @agent.control_target_ids), |
|
65 |
+ {}, { multiple: true, size: 5, class: 'select2 form-control' }) %> |
|
66 |
+ </div> |
|
67 |
+ </div> |
|
68 |
+ </div> |
|
69 |
+ |
|
45 | 70 |
<div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>"> |
46 | 71 |
<div class="form-group"> |
47 | 72 |
<%= f.label :keep_events_for, "Keep events" %> |
@@ -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> |
@@ -134,6 +134,17 @@ |
||
134 | 134 |
</p> |
135 | 135 |
<% end %> |
136 | 136 |
|
137 |
+ <% if @agent.can_control_other_agents? %> |
|
138 |
+ <p> |
|
139 |
+ <b>Control Targets:</b> |
|
140 |
+ <% if (agents = @agent.control_targets).length > 0 %> |
|
141 |
+ <%= agents.map { |agent| link_to(agent.name, agent_path(agent)) }.to_sentence.html_safe %> |
|
142 |
+ <% else %> |
|
143 |
+ None |
|
144 |
+ <% end %> |
|
145 |
+ </p> |
|
146 |
+ <% end %> |
|
147 |
+ |
|
137 | 148 |
<p> |
138 | 149 |
<b>Working:</b> |
139 | 150 |
<%= working @agent %> |
@@ -0,0 +1,13 @@ |
||
1 |
+class AddControlLinks < ActiveRecord::Migration |
|
2 |
+ def change |
|
3 |
+ create_table :control_links do |t| |
|
4 |
+ t.integer :controller_id, null: false |
|
5 |
+ t.integer :control_target_id, null: false |
|
6 |
+ |
|
7 |
+ t.timestamps |
|
8 |
+ end |
|
9 |
+ |
|
10 |
+ add_index :control_links, [:controller_id, :control_target_id], unique: true |
|
11 |
+ add_index :control_links, :control_target_id |
|
12 |
+ end |
|
13 |
+end |
@@ -11,7 +11,7 @@ |
||
11 | 11 |
# |
12 | 12 |
# It's strongly recommended that you check this file into your version control system. |
13 | 13 |
|
14 |
-ActiveRecord::Schema.define(version: 20140906030139) do |
|
14 |
+ActiveRecord::Schema.define(version: 20140901143732) do |
|
15 | 15 |
|
16 | 16 |
# These are extensions that must be enabled in order to support this database |
17 | 17 |
enable_extension "plpgsql" |
@@ -40,7 +40,7 @@ ActiveRecord::Schema.define(version: 20140906030139) do |
||
40 | 40 |
t.datetime "updated_at", null: false |
41 | 41 |
t.text "memory", limit: 2147483647, charset: "utf8mb4", collation: "utf8mb4_bin" |
42 | 42 |
t.datetime "last_web_request_at" |
43 |
- t.integer "keep_events_for", default: 0, null: false |
|
43 |
+ t.integer "keep_events_for", default: 0, null: false |
|
44 | 44 |
t.datetime "last_event_at" |
45 | 45 |
t.datetime "last_error_log_at" |
46 | 46 |
t.boolean "propagate_immediately", default: false, null: false |
@@ -54,6 +54,16 @@ ActiveRecord::Schema.define(version: 20140906030139) do |
||
54 | 54 |
add_index "agents", ["type"], name: "index_agents_on_type", using: :btree |
55 | 55 |
add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree |
56 | 56 |
|
57 |
+ create_table "control_links", force: true do |t| |
|
58 |
+ t.integer "controller_id", null: false |
|
59 |
+ t.integer "control_target_id", null: false |
|
60 |
+ t.datetime "created_at" |
|
61 |
+ t.datetime "updated_at" |
|
62 |
+ end |
|
63 |
+ |
|
64 |
+ add_index "control_links", ["control_target_id"], name: "index_control_links_on_control_target_id", using: :btree |
|
65 |
+ add_index "control_links", ["controller_id", "control_target_id"], name: "index_control_links_on_controller_id_and_control_target_id", unique: true, using: :btree |
|
66 |
+ |
|
57 | 67 |
create_table "delayed_jobs", force: true do |t| |
58 | 68 |
t.integer "priority", default: 0 |
59 | 69 |
t.integer "attempts", default: 0 |
@@ -1,5 +1,97 @@ |
||
1 | 1 |
require 'rufus/scheduler' |
2 | 2 |
|
3 |
+class Rufus::Scheduler |
|
4 |
+ SCHEDULER_AGENT_TAG = Agents::SchedulerAgent.name |
|
5 |
+ |
|
6 |
+ class Job |
|
7 |
+ # Store an ID of SchedulerAgent in this job. |
|
8 |
+ def scheduler_agent_id=(id) |
|
9 |
+ self[:scheduler_agent_id] = id |
|
10 |
+ end |
|
11 |
+ |
|
12 |
+ # Extract an ID of SchedulerAgent if any. |
|
13 |
+ def scheduler_agent_id |
|
14 |
+ self[:scheduler_agent_id] |
|
15 |
+ end |
|
16 |
+ |
|
17 |
+ # Return a SchedulerAgent tied to this job. Return nil if it is |
|
18 |
+ # not found or disabled. |
|
19 |
+ def scheduler_agent |
|
20 |
+ agent_id = scheduler_agent_id or return nil |
|
21 |
+ |
|
22 |
+ Agent.of_type(Agents::SchedulerAgent).active.find_by(id: agent_id) |
|
23 |
+ end |
|
24 |
+ end |
|
25 |
+ |
|
26 |
+ # Get all jobs tied to any SchedulerAgent |
|
27 |
+ def scheduler_agent_jobs |
|
28 |
+ jobs(tag: SCHEDULER_AGENT_TAG) |
|
29 |
+ end |
|
30 |
+ |
|
31 |
+ # Get a job tied to a given SchedulerAgent |
|
32 |
+ def scheduler_agent_job(agent) |
|
33 |
+ scheduler_agent_jobs.find { |job| |
|
34 |
+ job.scheduler_agent_id == agent.id |
|
35 |
+ } |
|
36 |
+ end |
|
37 |
+ |
|
38 |
+ # Schedule or reschedule a job for a given SchedulerAgent and return |
|
39 |
+ # the running job. Return nil if unscheduled. |
|
40 |
+ def schedule_scheduler_agent(agent) |
|
41 |
+ job = scheduler_agent_job(agent) |
|
42 |
+ |
|
43 |
+ if agent.disabled? |
|
44 |
+ if job |
|
45 |
+ puts "Unscheduling SchedulerAgent##{agent.id} (disabled)" |
|
46 |
+ job.unschedule |
|
47 |
+ end |
|
48 |
+ nil |
|
49 |
+ else |
|
50 |
+ if job |
|
51 |
+ return job if agent.memory['scheduled_at'] == job.scheduled_at.to_i |
|
52 |
+ puts "Rescheduling SchedulerAgent##{agent.id}" |
|
53 |
+ job.unschedule |
|
54 |
+ else |
|
55 |
+ puts "Scheduling SchedulerAgent##{agent.id}" |
|
56 |
+ end |
|
57 |
+ |
|
58 |
+ agent_id = agent.id |
|
59 |
+ |
|
60 |
+ job = schedule_cron agent.options['schedule'], tag: SCHEDULER_AGENT_TAG do |job| |
|
61 |
+ job.scheduler_agent_id = agent_id |
|
62 |
+ |
|
63 |
+ if scheduler_agent = job.scheduler_agent |
|
64 |
+ scheduler_agent.check! |
|
65 |
+ else |
|
66 |
+ puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (disabled or deleted)" |
|
67 |
+ job.unschedule |
|
68 |
+ end |
|
69 |
+ end |
|
70 |
+ # Make sure the job is associated with a SchedulerAgent before |
|
71 |
+ # it is triggered. |
|
72 |
+ job.scheduler_agent_id = agent_id |
|
73 |
+ |
|
74 |
+ agent.memory['scheduled_at'] = job.scheduled_at.to_i |
|
75 |
+ agent.save |
|
76 |
+ |
|
77 |
+ job |
|
78 |
+ end |
|
79 |
+ end |
|
80 |
+ |
|
81 |
+ # Schedule or reschedule jobs for all SchedulerAgents and unschedule |
|
82 |
+ # orphaned jobs if any. |
|
83 |
+ def schedule_scheduler_agents |
|
84 |
+ scheduled_jobs = Agent.of_type(Agents::SchedulerAgent).map { |scheduler_agent| |
|
85 |
+ schedule_scheduler_agent(scheduler_agent) |
|
86 |
+ }.compact |
|
87 |
+ |
|
88 |
+ (scheduler_agent_jobs - scheduled_jobs).each { |job| |
|
89 |
+ puts "Unscheduling SchedulerAgent##{job.scheduler_agent_id} (orphaned)" |
|
90 |
+ job.unschedule |
|
91 |
+ } |
|
92 |
+ end |
|
93 |
+end |
|
94 |
+ |
|
3 | 95 |
class HuginnScheduler |
4 | 96 |
FAILED_JOBS_TO_KEEP = 100 |
5 | 97 |
attr_accessor :mutex |
@@ -45,6 +137,12 @@ class HuginnScheduler |
||
45 | 137 |
end |
46 | 138 |
end |
47 | 139 |
|
140 |
+ # Schedule Scheduler Agents |
|
141 |
+ |
|
142 |
+ @rufus_scheduler.every '1m' do |
|
143 |
+ @rufus_scheduler.schedule_scheduler_agents |
|
144 |
+ end |
|
145 |
+ |
|
48 | 146 |
@rufus_scheduler.join |
49 | 147 |
end |
50 | 148 |
|
@@ -58,6 +58,7 @@ describe DotHelper do |
||
58 | 58 |
\A |
59 | 59 |
digraph \x20 "Agent \x20 Event \x20 Flow" \{ |
60 | 60 |
node \[ [^\]]+ \]; |
61 |
+ edge \[ [^\]]+ \]; |
|
61 | 62 |
(?<foo>\w+) \[label=foo\]; |
62 | 63 |
\k<foo> -> (?<bar1>\w+) \[style=dashed\]; |
63 | 64 |
\k<foo> -> (?<bar2>\w+) \[color="\#999999"\]; |
@@ -75,6 +76,7 @@ describe DotHelper do |
||
75 | 76 |
\A |
76 | 77 |
digraph \x20 "Agent \x20 Event \x20 Flow" \{ |
77 | 78 |
node \[ [^\]]+ \]; |
79 |
+ edge \[ [^\]]+ \]; |
|
78 | 80 |
(?<foo>\w+) \[label=foo,tooltip="Dot \x20 Foo",URL="#{Regexp.quote(agent_path(@foo))}"\]; |
79 | 81 |
\k<foo> -> (?<bar1>\w+) \[style=dashed\]; |
80 | 82 |
\k<foo> -> (?<bar2>\w+) \[color="\#999999"\]; |
@@ -1,4 +1,5 @@ |
||
1 | 1 |
require 'spec_helper' |
2 |
+require 'huginn_scheduler' |
|
2 | 3 |
|
3 | 4 |
describe HuginnScheduler do |
4 | 5 |
before(:each) do |
@@ -74,4 +75,59 @@ describe HuginnScheduler do |
||
74 | 75 |
ENV['FAILED_JOBS_TO_KEEP'] = old |
75 | 76 |
end |
76 | 77 |
end |
77 |
-end |
|
78 |
+end |
|
79 |
+ |
|
80 |
+describe Rufus::Scheduler do |
|
81 |
+ before :each do |
|
82 |
+ @taoe, Thread.abort_on_exception = Thread.abort_on_exception, false |
|
83 |
+ @oso, @ose, $stdout, $stderr = $stdout, $stderr, StringIO.new, StringIO.new |
|
84 |
+ |
|
85 |
+ @scheduler = Rufus::Scheduler.new |
|
86 |
+ |
|
87 |
+ stub.any_instance_of(Agents::SchedulerAgent).second_precision_enabled { true } |
|
88 |
+ |
|
89 |
+ @agent1 = Agents::SchedulerAgent.new(name: 'Scheduler 1', options: { schedule: '*/1 * * * * *' }).tap { |a| |
|
90 |
+ a.user = users(:bob) |
|
91 |
+ a.save! |
|
92 |
+ } |
|
93 |
+ @agent2 = Agents::SchedulerAgent.new(name: 'Scheduler 2', options: { schedule: '*/1 * * * * *' }).tap { |a| |
|
94 |
+ a.user = users(:bob) |
|
95 |
+ a.save! |
|
96 |
+ } |
|
97 |
+ end |
|
98 |
+ |
|
99 |
+ after :each do |
|
100 |
+ @scheduler.shutdown |
|
101 |
+ |
|
102 |
+ Thread.abort_on_exception = @taoe |
|
103 |
+ $stdout, $stderr = @oso, @ose |
|
104 |
+ end |
|
105 |
+ |
|
106 |
+ describe '#schedule_scheduler_agents' do |
|
107 |
+ it 'registers active SchedulerAgents' do |
|
108 |
+ @scheduler.schedule_scheduler_agents |
|
109 |
+ |
|
110 |
+ expect(@scheduler.scheduler_agent_jobs.map(&:scheduler_agent)).to eq([@agent1, @agent2]) |
|
111 |
+ end |
|
112 |
+ |
|
113 |
+ it 'unregisters disabled SchedulerAgents' do |
|
114 |
+ @scheduler.schedule_scheduler_agents |
|
115 |
+ |
|
116 |
+ @agent1.update!(disabled: true) |
|
117 |
+ |
|
118 |
+ @scheduler.schedule_scheduler_agents |
|
119 |
+ |
|
120 |
+ expect(@scheduler.scheduler_agent_jobs.map(&:scheduler_agent)).to eq([@agent2]) |
|
121 |
+ end |
|
122 |
+ |
|
123 |
+ it 'unregisters deleted SchedulerAgents' do |
|
124 |
+ @scheduler.schedule_scheduler_agents |
|
125 |
+ |
|
126 |
+ @agent2.delete |
|
127 |
+ |
|
128 |
+ @scheduler.schedule_scheduler_agents |
|
129 |
+ |
|
130 |
+ expect(@scheduler.scheduler_agent_jobs.map(&:scheduler_agent)).to eq([@agent1]) |
|
131 |
+ end |
|
132 |
+ end |
|
133 |
+end |
@@ -486,7 +486,7 @@ describe Agent do |
||
486 | 486 |
agent.errors_on(:options).should include("cannot be set to an instance of Fixnum") |
487 | 487 |
end |
488 | 488 |
|
489 |
- it "should not allow agents owned by other people" do |
|
489 |
+ it "should not allow source agents owned by other people" do |
|
490 | 490 |
agent = Agents::SomethingSource.new(:name => "something") |
491 | 491 |
agent.user = users(:bob) |
492 | 492 |
agent.source_ids = [agents(:bob_weather_agent).id] |
@@ -497,6 +497,28 @@ describe Agent do |
||
497 | 497 |
agent.should have(0).errors_on(:sources) |
498 | 498 |
end |
499 | 499 |
|
500 |
+ it "should not allow controller agents owned by other people" do |
|
501 |
+ agent = Agents::SomethingSource.new(:name => "something") |
|
502 |
+ agent.user = users(:bob) |
|
503 |
+ agent.controller_ids = [agents(:bob_weather_agent).id] |
|
504 |
+ agent.should have(0).errors_on(:controllers) |
|
505 |
+ agent.controller_ids = [agents(:jane_weather_agent).id] |
|
506 |
+ agent.should have(1).errors_on(:controllers) |
|
507 |
+ agent.user = users(:jane) |
|
508 |
+ agent.should have(0).errors_on(:controllers) |
|
509 |
+ end |
|
510 |
+ |
|
511 |
+ it "should not allow control target agents owned by other people" do |
|
512 |
+ agent = Agents::CannotBeScheduled.new(:name => "something") |
|
513 |
+ agent.user = users(:bob) |
|
514 |
+ agent.control_target_ids = [agents(:bob_weather_agent).id] |
|
515 |
+ agent.should have(0).errors_on(:control_targets) |
|
516 |
+ agent.control_target_ids = [agents(:jane_weather_agent).id] |
|
517 |
+ agent.should have(1).errors_on(:control_targets) |
|
518 |
+ agent.user = users(:jane) |
|
519 |
+ agent.should have(0).errors_on(:control_targets) |
|
520 |
+ end |
|
521 |
+ |
|
500 | 522 |
it "should not allow scenarios owned by other people" do |
501 | 523 |
agent = Agents::SomethingSource.new(:name => "something") |
502 | 524 |
agent.user = users(:bob) |
@@ -0,0 +1,140 @@ |
||
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 |
+ @agent.save |
|
8 |
+ end |
|
9 |
+ |
|
10 |
+ describe "validation" do |
|
11 |
+ it "should validate action" do |
|
12 |
+ ['run', 'enable', 'disable', '', nil].each { |action| |
|
13 |
+ @agent.options['action'] = action |
|
14 |
+ @agent.should be_valid |
|
15 |
+ } |
|
16 |
+ |
|
17 |
+ ['delete', 1, true].each { |action| |
|
18 |
+ @agent.options['action'] = action |
|
19 |
+ @agent.should_not be_valid |
|
20 |
+ } |
|
21 |
+ end |
|
22 |
+ |
|
23 |
+ it "should validate schedule" do |
|
24 |
+ @agent.should be_valid |
|
25 |
+ |
|
26 |
+ @agent.options.delete('schedule') |
|
27 |
+ @agent.should_not be_valid |
|
28 |
+ |
|
29 |
+ @agent.options['schedule'] = nil |
|
30 |
+ @agent.should_not be_valid |
|
31 |
+ |
|
32 |
+ @agent.options['schedule'] = '' |
|
33 |
+ @agent.should_not be_valid |
|
34 |
+ |
|
35 |
+ @agent.options['schedule'] = '0' |
|
36 |
+ @agent.should_not be_valid |
|
37 |
+ |
|
38 |
+ @agent.options['schedule'] = '*/15 * * * * * *' |
|
39 |
+ @agent.should_not be_valid |
|
40 |
+ |
|
41 |
+ @agent.options['schedule'] = '*/1 * * * *' |
|
42 |
+ @agent.should be_valid |
|
43 |
+ |
|
44 |
+ @agent.options['schedule'] = '*/1 * * *' |
|
45 |
+ @agent.should_not be_valid |
|
46 |
+ |
|
47 |
+ stub(@agent).second_precision_enabled { true } |
|
48 |
+ @agent.options['schedule'] = '*/15 * * * * *' |
|
49 |
+ @agent.should be_valid |
|
50 |
+ |
|
51 |
+ stub(@agent).second_precision_enabled { false } |
|
52 |
+ @agent.options['schedule'] = '*/10 * * * * *' |
|
53 |
+ @agent.should_not be_valid |
|
54 |
+ |
|
55 |
+ @agent.options['schedule'] = '5/30 * * * * *' |
|
56 |
+ @agent.should_not be_valid |
|
57 |
+ |
|
58 |
+ @agent.options['schedule'] = '*/15 * * * * *' |
|
59 |
+ @agent.should be_valid |
|
60 |
+ |
|
61 |
+ @agent.options['schedule'] = '15,45 * * * * *' |
|
62 |
+ @agent.should be_valid |
|
63 |
+ |
|
64 |
+ @agent.options['schedule'] = '0 * * * * *' |
|
65 |
+ @agent.should be_valid |
|
66 |
+ end |
|
67 |
+ end |
|
68 |
+ |
|
69 |
+ describe 'control_action' do |
|
70 |
+ it "should be one of the supported values" do |
|
71 |
+ ['run', '', nil].each { |action| |
|
72 |
+ @agent.options['action'] = action |
|
73 |
+ @agent.control_action.should == 'run' |
|
74 |
+ } |
|
75 |
+ |
|
76 |
+ ['enable', 'disable'].each { |action| |
|
77 |
+ @agent.options['action'] = action |
|
78 |
+ @agent.control_action.should == action |
|
79 |
+ } |
|
80 |
+ end |
|
81 |
+ |
|
82 |
+ it "cannot be 'run' if any of the control targets cannot be scheduled" do |
|
83 |
+ @agent.control_action.should == 'run' |
|
84 |
+ @agent.control_targets = [agents(:bob_rain_notifier_agent)] |
|
85 |
+ @agent.should_not be_valid |
|
86 |
+ end |
|
87 |
+ |
|
88 |
+ it "can be 'enable' or 'disable' no matter if control targets can be scheduled or not" do |
|
89 |
+ ['enable', 'disable'].each { |action| |
|
90 |
+ @agent.options['action'] = action |
|
91 |
+ @agent.control_targets = [agents(:bob_rain_notifier_agent)] |
|
92 |
+ @agent.should be_valid |
|
93 |
+ } |
|
94 |
+ end |
|
95 |
+ end |
|
96 |
+ |
|
97 |
+ describe "save" do |
|
98 |
+ it "should delete memory['scheduled_at'] if and only if options is changed" do |
|
99 |
+ time = Time.now.to_i |
|
100 |
+ |
|
101 |
+ @agent.memory['scheduled_at'] = time |
|
102 |
+ @agent.save |
|
103 |
+ @agent.memory['scheduled_at'].should == time |
|
104 |
+ |
|
105 |
+ @agent.memory['scheduled_at'] = time |
|
106 |
+ # Currently @agent.options[]= is not detected |
|
107 |
+ @agent.options = { 'schedule' => '*/5 * * * *' } |
|
108 |
+ @agent.save |
|
109 |
+ @agent.memory['scheduled_at'].should be_nil |
|
110 |
+ end |
|
111 |
+ end |
|
112 |
+ |
|
113 |
+ describe "check!" do |
|
114 |
+ it "should control targets" do |
|
115 |
+ control_targets = [agents(:bob_website_agent), agents(:bob_weather_agent)] |
|
116 |
+ @agent.control_targets = control_targets |
|
117 |
+ @agent.save! |
|
118 |
+ |
|
119 |
+ control_target_ids = control_targets.map(&:id) |
|
120 |
+ stub(Agent).async_check(anything) { |id| |
|
121 |
+ control_target_ids.delete(id) |
|
122 |
+ } |
|
123 |
+ |
|
124 |
+ @agent.check! |
|
125 |
+ control_target_ids.should be_empty |
|
126 |
+ |
|
127 |
+ @agent.options['action'] = 'disable' |
|
128 |
+ @agent.save! |
|
129 |
+ |
|
130 |
+ @agent.check! |
|
131 |
+ control_targets.all? { |control_target| control_target.disabled? } |
|
132 |
+ |
|
133 |
+ @agent.options['action'] = 'enable' |
|
134 |
+ @agent.save! |
|
135 |
+ |
|
136 |
+ @agent.check! |
|
137 |
+ control_targets.all? { |control_target| !control_target.disabled? } |
|
138 |
+ end |
|
139 |
+ end |
|
140 |
+end |