Importing of Scenarios now works, creating any needed Agents by global guid

This commit is contained in:
Andrew Cantino 2014-06-04 22:26:56 -07:00
parent 5c1fbdb997
commit 64564eb120
31 changed files with 342 additions and 71 deletions

View file

@ -158,6 +158,7 @@ h2 .scenario, a span.label.scenario {
}
// Bootstrappy color styles
.color-danger {
color: #d9534f;
}

View file

@ -0,0 +1,9 @@
.scenario-import {
.danger {
color: red;
font-weight: strong;
border: 1px solid red;
padding: 10px;
margin: 10px 0;
}
}

View file

@ -29,7 +29,7 @@ module AssignableTypes
const_get(:TYPES).include?(type)
end
def build_for_type(type, user, attributes)
def build_for_type(type, user, attributes = {})
attributes.delete(:type)
if valid_type?(type)

13
app/concerns/has_guid.rb Normal file
View file

@ -0,0 +1,13 @@
module HasGuid
extend ActiveSupport::Concern
included do
before_save :make_guid
end
protected
def make_guid
self.guid = SecureRandom.hex unless guid.present?
end
end

View file

@ -12,6 +12,7 @@ class Agent < ActiveRecord::Base
include JSONSerializedField
include RDBMSFunctions
include WorkingHelpers
include HasGuid
markdown_class_attributes :description, :event_description

View file

@ -1,22 +1,18 @@
class Scenario < ActiveRecord::Base
attr_accessible :name, :agent_ids, :description, :public
include HasGuid
attr_accessible :name, :agent_ids, :description, :public, :source_url
belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios
has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario
has_many :agents, :through => :scenario_memberships, :inverse_of => :scenarios
before_save :make_guid
validates_presence_of :name, :user
validate :agents_are_owned
protected
def make_guid
self.guid = SecureRandom.hex unless guid.present?
end
def agents_are_owned
errors.add(:agents, "must be owned by you") unless agents.all? {|s| s.user == user }
end

View file

@ -4,6 +4,7 @@ class ScenarioImport
include ActiveModel::Callbacks
include ActiveModel::Validations::Callbacks
DANGEROUS_AGENT_TYPES = %w[Agents::ShellCommandAgent]
URL_REGEX = /\Ahttps?:\/\//i
attr_accessor :file, :url, :data, :do_import
@ -33,19 +34,54 @@ class ScenarioImport
@existing_scenario ||= user.scenarios.find_by_guid(parsed_data["guid"])
end
def dangerous?
(parsed_data['agents'] || []).any? { |agent| DANGEROUS_AGENT_TYPES.include?(agent['type']) }
end
def parsed_data
@parsed_data
@parsed_data ||= data && JSON.parse(data) rescue {}
end
def do_import?
do_import == "1"
end
def import!
def import!(options = {})
guid = parsed_data['guid']
description = parsed_data['description']
name = parsed_data['name']
agents = parsed_data['agents']
links = parsed_data['links']
source_url = parsed_data['source_url'].presence || nil
@scenario = user.scenarios.where(:guid => guid).first_or_initialize
@scenario.update_attributes!(:name => name, :description => description,
:source_url => source_url, :public => false)
unless options[:skip_agents]
created_agents = agents.map do |agent_data|
agent = @scenario.agents.find_by(:guid => agent_data['guid']) || Agent.build_for_type(agent_data['type'], user)
agent.guid = agent_data['guid']
agent.attributes = { :name => agent_data['name'],
:schedule => agent_data['schedule'],
:keep_events_for => agent_data['keep_events_for'],
:propagate_immediately => agent_data['propagate_immediately'],
:disabled => agent_data['disabled'],
:options => agent_data['options'],
:scenario_ids => [@scenario.id] }
agent.save!
agent
end
links.each do |link|
receiver = created_agents[link['receiver']]
source = created_agents[link['source']]
receiver.sources << source unless receiver.sources.include?(source)
end
end
end
def scenario
existing_scenario
@scenario || @existing_scenario
end
protected
@ -65,10 +101,12 @@ class ScenarioImport
def validate_data
if data.present?
@parsed_data = JSON.parse(data) rescue {}
if (%w[name guid] - @parsed_data.keys).length > 0
if (%w[name guid agents] - @parsed_data.keys).length > 0
errors.add(:base, "The provided data does not appear to be a valid Scenario.")
self.data = nil
end
else
@parsed_data = nil
end
end

View file

@ -32,11 +32,20 @@
});
</script>
<% if @scenario_import.dangerous? %>
<div class="danger">
This Scenario contains one or more potentially dangerous Agents.
These may be able to run local commands or execute code.
Please be sure that you understand the above Agent configurations before importing!
</div>
<% end %>
<% if @scenario_import.existing_scenario.present? %>
<strong>
This Scenario already exists on your Huginn.
If you continue, the import will overwrite your existing <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario.
</strong>
<div class="danger">
This Scenario already exists in your system.
If you continue, the import will overwrite your existing
<span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario and the Agents in it.
</div>
<% end %>
<div class="checkbox">

View file

@ -1,4 +1,4 @@
<div class='container'>
<div class='container scenario-import'>
<div class='row'>
<div class='col-md-12'>
<% if @scenario_import.errors.any? %>

View file

@ -13,6 +13,7 @@
<tr>
<th>Name</th>
<th>Agents</th>
<th>Public</th>
<th></th>
</tr>
@ -22,6 +23,7 @@
<%= link_to(scenario.name, scenario, class: "label label-info") %>
</td>
<td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td>
<td><%= scenario.public? ? "yes" : "no" %></td>
<td>
<div class="btn-group btn-group-xs" style="float: right">
<%= link_to 'Show', scenario, class: "btn btn-default" %>

View file

@ -1,6 +1,6 @@
class AddIndicesToScenarios < ActiveRecord::Migration
def change
add_index :scenarios, [:user_id, :guid]
add_index :scenarios, [:user_id, :guid], :unique => true
add_index :scenario_memberships, :agent_id
add_index :scenario_memberships, :scenario_id
end

View file

@ -0,0 +1,15 @@
class AddGuidToAgents < ActiveRecord::Migration
class Agent < ActiveRecord::Base; end
def change
add_column :agents, :guid, :string
Agent.find_each do |agent|
agent.update_attribute :guid, SecureRandom.hex
end
change_column_null :agents, :guid, false
add_index :agents, :guid
end
end

View file

@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20140602014917) do
ActiveRecord::Schema.define(version: 20140605032822) do
create_table "agent_logs", force: true do |t|
t.integer "agent_id", null: false
@ -42,8 +42,10 @@ ActiveRecord::Schema.define(version: 20140602014917) do
t.integer "keep_events_for", default: 0, null: false
t.boolean "propagate_immediately", default: false, null: false
t.boolean "disabled", default: false, null: false
t.string "guid", null: false
end
add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree
add_index "agents", ["schedule"], name: "index_agents_on_schedule", using: :btree
add_index "agents", ["type"], name: "index_agents_on_type", using: :btree
add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree
@ -111,7 +113,7 @@ ActiveRecord::Schema.define(version: 20140602014917) do
t.string "source_url"
end
add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", using: :btree
add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree
create_table "user_credentials", force: true do |t|
t.integer "user_id", null: false

View file

@ -46,7 +46,7 @@ class AgentsExporter
:keep_events_for => agent.keep_events_for,
:propagate_immediately => agent.propagate_immediately,
:disabled => agent.disabled,
:source_system_agent_id => agent.id,
:guid => agent.guid,
:options => agent.options
}
end

View file

@ -4,6 +4,7 @@ jane_website_agent:
events_count: 1
schedule: "5pm"
name: "ZKCD"
guid: <%= SecureRandom.hex %>
options: <%= {
:url => "http://trailers.apple.com/trailers/home/rss/newtrailers.rss",
:expected_update_period_in_days => 2,
@ -20,6 +21,7 @@ bob_website_agent:
events_count: 1
schedule: "midnight"
name: "ZKCD"
guid: <%= SecureRandom.hex %>
options: <%= {
:url => "http://xkcd.com",
:expected_update_period_in_days => 2,
@ -35,6 +37,7 @@ bob_weather_agent:
user: bob
schedule: "midnight"
name: "SF Weather"
guid: <%= SecureRandom.hex %>
keep_events_for: 45
options: <%= {
:location => 94102,
@ -48,6 +51,7 @@ jane_weather_agent:
user: jane
schedule: "midnight"
name: "SF Weather"
guid: <%= SecureRandom.hex %>
keep_events_for: 30
options: <%= {
:location => 94103,
@ -60,6 +64,7 @@ jane_rain_notifier_agent:
type: Agents::TriggerAgent
user: jane
name: "Jane's Rain Watcher"
guid: <%= SecureRandom.hex %>
options: <%= {
:expected_receive_period_in_days => "2",
:rules => [{
@ -74,6 +79,7 @@ bob_rain_notifier_agent:
type: Agents::TriggerAgent
user: bob
name: "Bob's Rain Watcher"
guid: <%= SecureRandom.hex %>
options: <%= {
:expected_receive_period_in_days => "2",
:rules => [{
@ -88,6 +94,7 @@ bob_twitter_user_agent:
type: Agents::TwitterUserAgent
user: bob
name: "Bob's Twitter User Watcher"
guid: <%= SecureRandom.hex %>
options: <%= {
:username => "tectonic",
:expected_update_period_in_days => "2",
@ -101,3 +108,4 @@ bob_manual_event_agent:
type: Agents::ManualEventAgent
user: bob
name: "Bob's event testing agent"
guid: <%= SecureRandom.hex %>

View file

@ -20,7 +20,7 @@ describe AgentsExporter do
Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc)
data[:links].should == [{ :source => 0, :receiver => 1 }]
data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) }
data[:agents].all? { |agent_json| agent_json[:source_system_agent_id] && agent_json[:type] && agent_json[:name] }.should be_true
data[:agents].all? { |agent_json| agent_json[:guid].present? && agent_json[:type].present? && agent_json[:name].present? }.should be_true
end
it "does not output links to other agents" do

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/working_helpers'
describe Agent do
it_behaves_like WorkingHelpers
@ -122,6 +121,14 @@ describe Agent do
stub(Agents::CannotBeScheduled).valid_type?("Agents::CannotBeScheduled") { true }
end
let(:new_instance) do
agent = Agents::SomethingSource.new(:name => "some agent")
agent.user = users(:bob)
agent
end
it_behaves_like HasGuid
describe ".default_schedule" do
it "stores the default on the class" do
Agents::SomethingSource.default_schedule.should == "2pm"

View file

@ -1,7 +1,6 @@
# encoding: utf-8
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::DataOutputAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::HipchatAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::HumanTaskAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::JabberAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::PeakDetectorAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::PushbulletAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::SlackAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,6 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::TranslationAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,5 +1,4 @@
require 'spec_helper'
require 'models/concerns/liquid_interpolatable'
describe Agents::TriggerAgent do
it_behaves_like LiquidInterpolatable

View file

@ -1,6 +1,64 @@
require 'spec_helper'
describe ScenarioImport do
let(:guid) { "somescenarioguid" }
let(:description) { "This is a cool Huginn Scenario that does something useful!" }
let(:name) { "A useful Scenario" }
let(:source_url) { "http://example.com/scenarios/2/export.json" }
let(:weather_agent_options) {
{
'api_key' => 'some-api-key',
'location' => '12345'
}
}
let(:trigger_agent_options) {
{
'expected_receive_period_in_days' => 2,
'rules' => [{
'type' => "regex",
'value' => "rain|storm",
'path' => "conditions",
}],
'message' => "Looks like rain!"
}
}
let(:valid_parsed_data) do
{
:name => name,
:description => description,
:guid => guid,
:source_url => source_url,
:exported_at => 2.days.ago.utc.iso8601,
:agents => [
{
:type => "Agents::WeatherAgent",
:name => "a weather agent",
:schedule => "5pm",
:keep_events_for => 14,
:propagate_immediately => false,
:disabled => false,
:guid => "a-weather-agent",
:options => weather_agent_options
},
{
:type => "Agents::TriggerAgent",
:name => "listen for weather",
:schedule => nil,
:keep_events_for => 0,
:propagate_immediately => true,
:disabled => true,
:guid => "a-trigger-agent",
:options => trigger_agent_options
}
],
:links => [
{ :source => 0, :receiver => 1 }
]
}
end
let(:valid_data) { valid_parsed_data.to_json }
let(:invalid_data) { { :name => "some scenario missing a guid" }.to_json }
describe "initialization" do
it "is initialized with an attributes hash" do
ScenarioImport.new(:url => "http://google.com").url.should == "http://google.com"
@ -9,8 +67,6 @@ describe ScenarioImport do
describe "validations" do
subject { ScenarioImport.new }
let(:valid_json) { { :name => "some scenario", :guid => "someguid" }.to_json }
let(:invalid_json) { { :name => "some scenario missing a guid" }.to_json }
it "is not valid when none of file, url, or data are present" do
subject.should_not be_valid
@ -20,7 +76,7 @@ describe ScenarioImport do
describe "data" do
it "should be invalid with invalid data" do
subject.data = invalid_json
subject.data = invalid_data
subject.should_not be_valid
subject.should have(1).error_on(:base)
@ -33,7 +89,7 @@ describe ScenarioImport do
end
it "should be valid with valid data" do
subject.data = valid_json
subject.data = valid_data
subject.should be_valid
end
end
@ -47,14 +103,14 @@ describe ScenarioImport do
end
it "should be invalid when the referenced url doesn't contain a scenario" do
stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => invalid_json)
stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => invalid_data)
subject.url = "http://example.com/scenarios/1/export.json"
subject.should_not be_valid
subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
end
it "should be valid when the url points to a valid scenario" do
stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => valid_json)
stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => valid_data)
subject.url = "http://example.com/scenarios/1/export.json"
subject.should be_valid
end
@ -66,15 +122,139 @@ describe ScenarioImport do
subject.should_not be_valid
subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
subject.file = StringIO.new(invalid_json)
subject.file = StringIO.new(invalid_data)
subject.should_not be_valid
subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
end
it "should be valid with a valid uploaded scenario" do
subject.file = StringIO.new(valid_json)
subject.file = StringIO.new(valid_data)
subject.should be_valid
end
end
end
describe "#dangerous?" do
it "returns false on most Agents" do
ScenarioImport.new(:data => valid_data).should_not be_dangerous
end
it "returns true if a ShellCommandAgent is present" do
valid_parsed_data[:agents][0][:type] = "Agents::ShellCommandAgent"
ScenarioImport.new(:data => valid_parsed_data.to_json).should be_dangerous
end
end
describe "#import!" do
let(:scenario_import) do
_import = ScenarioImport.new(:data => valid_data)
_import.set_user users(:bob)
_import
end
context "when this scenario has never been seen before" do
it "makes a new scenario" do
lambda {
scenario_import.import!(:skip_agents => true)
}.should change { users(:bob).scenarios.count }.by(1)
scenario_import.scenario.name.should == name
scenario_import.scenario.description.should == description
scenario_import.scenario.guid.should == guid
scenario_import.scenario.source_url.should == source_url
scenario_import.scenario.public.should be_false
end
it "creates the Agents" do
lambda {
scenario_import.import!
}.should change { users(:bob).agents.count }.by(2)
weather_agent = scenario_import.scenario.agents.find_by(:guid => "a-weather-agent")
trigger_agent = scenario_import.scenario.agents.find_by(:guid => "a-trigger-agent")
weather_agent.name.should == "a weather agent"
weather_agent.schedule.should == "5pm"
weather_agent.keep_events_for.should == 14
weather_agent.propagate_immediately.should be_false
weather_agent.should_not be_disabled
weather_agent.memory.should be_empty
weather_agent.options.should == weather_agent_options
trigger_agent.name.should == "listen for weather"
trigger_agent.sources.should == [weather_agent]
trigger_agent.schedule.should be_nil
trigger_agent.keep_events_for.should == 0
trigger_agent.propagate_immediately.should be_true
trigger_agent.should be_disabled
trigger_agent.memory.should be_empty
trigger_agent.options.should == trigger_agent_options
end
it "creates new Agents, even if one already exists with the given guid (so that we don't overwrite a user's work outside of the scenario)" do
agents(:bob_weather_agent).update_attribute :guid, "a-weather-agent"
lambda {
scenario_import.import!
}.should change { users(:bob).agents.count }.by(2)
end
end
context "when an a scenario already exists with the given guid" do
let!(:existing_scenario) {
_existing_scenerio = users(:bob).scenarios.build(:name => "an existing scenario")
_existing_scenerio.guid = guid
_existing_scenerio.save!
_existing_scenerio
}
it "uses the existing scenario, updating it's data" do
lambda {
scenario_import.import!(:skip_agents => true)
scenario_import.scenario.should == existing_scenario
}.should_not change { users(:bob).scenarios.count }
existing_scenario.reload
existing_scenario.guid.should == guid
existing_scenario.description.should == description
existing_scenario.name.should == name
existing_scenario.source_url.should == source_url
existing_scenario.public.should be_false
end
it "updates any existing agents in the scenario, and makes new ones as needed" do
agents(:bob_weather_agent).update_attribute :guid, "a-weather-agent"
agents(:bob_weather_agent).scenarios << existing_scenario
lambda {
# Shouldn't matter how many times we do it!
scenario_import.import!
scenario_import.import!
scenario_import.import!
}.should change { users(:bob).agents.count }.by(1)
weather_agent = existing_scenario.agents.find_by(:guid => "a-weather-agent")
trigger_agent = existing_scenario.agents.find_by(:guid => "a-trigger-agent")
weather_agent.should == agents(:bob_weather_agent)
weather_agent.name.should == "a weather agent"
weather_agent.schedule.should == "5pm"
weather_agent.keep_events_for.should == 14
weather_agent.propagate_immediately.should be_false
weather_agent.should_not be_disabled
weather_agent.memory.should be_empty
weather_agent.options.should == weather_agent_options
trigger_agent.name.should == "listen for weather"
trigger_agent.sources.should == [weather_agent]
trigger_agent.schedule.should be_nil
trigger_agent.keep_events_for.should == 0
trigger_agent.propagate_immediately.should be_true
trigger_agent.should be_disabled
trigger_agent.memory.should be_empty
trigger_agent.options.should == trigger_agent_options
end
end
end
end

View file

@ -1,54 +1,42 @@
require 'spec_helper'
describe Scenario do
let(:new_instance) { users(:bob).scenarios.build(:name => "some scenario") }
it_behaves_like HasGuid
describe "validations" do
before do
@scenario = users(:bob).scenarios.new(:name => "some scenario")
@scenario.should be_valid
new_instance.should be_valid
end
it "validates the presence of name" do
@scenario.name = ''
@scenario.should_not be_valid
new_instance.name = ''
new_instance.should_not be_valid
end
it "validates the presence of user" do
@scenario.user = nil
@scenario.should_not be_valid
new_instance.user = nil
new_instance.should_not be_valid
end
it "only allows Agents owned by user" do
@scenario.agent_ids = [agents(:bob_website_agent).id]
@scenario.should be_valid
new_instance.agent_ids = [agents(:bob_website_agent).id]
new_instance.should be_valid
@scenario.agent_ids = [agents(:jane_website_agent).id]
@scenario.should_not be_valid
end
end
describe "guid" do
it "gets created before_save, but only if it's not present" do
scenario = users(:bob).scenarios.new(:name => "some scenario")
scenario.guid.should be_nil
scenario.save!
scenario.guid.should_not be_nil
lambda { scenario.save! }.should_not change { scenario.reload.guid }
new_instance.agent_ids = [agents(:jane_website_agent).id]
new_instance.should_not be_valid
end
end
describe "counters" do
before do
@scenario = users(:bob).scenarios.new(:name => "some scenario")
end
it "maintains a counter cache on user" do
lambda {
@scenario.save!
new_instance.save!
}.should change { users(:bob).reload.scenario_count }.by(1)
lambda {
@scenario.destroy
new_instance.destroy
}.should change { users(:bob).reload.scenario_count }.by(-1)
end
end

View file

@ -0,0 +1,12 @@
require 'spec_helper'
shared_examples_for HasGuid do
it "gets created before_save, but only if it's not present" do
instance = new_instance
instance.guid.should be_nil
instance.save!
instance.guid.should_not be_nil
lambda { instance.save! }.should_not change { instance.reload.guid }
end
end

View file

@ -3,7 +3,7 @@ require 'spec_helper'
shared_examples_for WorkingHelpers do
describe "recent_error_logs?" do
it "returns true if last_error_log_at is near last_event_at" do
agent = Agent.new
agent = described_class.new
agent.last_error_log_at = 10.minutes.ago
agent.last_event_at = 10.minutes.ago
@ -26,9 +26,10 @@ shared_examples_for WorkingHelpers do
agent.recent_error_logs?.should be_false
end
end
describe "received_event_without_error?" do
before do
@agent = Agent.new
@agent = described_class.new
end
it "should return false until the first event was received" do
@ -49,5 +50,4 @@ shared_examples_for WorkingHelpers do
@agent.received_event_without_error?.should == true
end
end
end