Merge remote-tracking branch 'knu/scheduler_agent'

Conflicts:
db/schema.rb
spec/lib/huginn_scheduler_spec.rb

Akinori MUSHA 10 anni fa
parent
commit
10ce9d598b

+ 6 - 0
.env.example

@@ -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.

+ 19 - 3
app/assets/javascripts/application.js.coffee.erb

@@ -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()

+ 4 - 0
app/assets/stylesheets/application.css.scss.erb

@@ -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;

+ 51 - 0
app/concerns/agent_controller_concern.rb

@@ -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

+ 1 - 0
app/controllers/agents_controller.rb

@@ -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')

+ 22 - 0
app/helpers/agent_helper.rb

@@ -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

+ 11 - 2
app/helpers/dot_helper.rb

@@ -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
         }

+ 29 - 4
app/models/agent.rb

@@ -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,

+ 113 - 0
app/models/agents/scheduler_agent.rb

@@ -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

+ 7 - 0
app/models/control_link.rb

@@ -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

+ 26 - 1
app/views/agents/_form.html.erb

@@ -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" %>

+ 1 - 1
app/views/agents/_table.html.erb

@@ -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 %>

+ 12 - 1
app/views/agents/show.html.erb

@@ -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 %>

+ 13 - 0
db/migrate/20140901143732_add_control_links.rb

@@ -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

+ 12 - 2
db/schema.rb

@@ -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

+ 98 - 0
lib/huginn_scheduler.rb

@@ -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
 

+ 2 - 0
spec/helpers/dot_helper_spec.rb

@@ -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"\];

+ 57 - 1
spec/lib/huginn_scheduler_spec.rb

@@ -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

+ 23 - 1
spec/models/agent_spec.rb

@@ -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)

+ 140 - 0
spec/models/agents/scheduler_agent_spec.rb

@@ -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