Permalink
Browse files

Merge branch 'next'

This marks the end of our first agile iteration.
  • Loading branch information...
2 parents 071acf4 + 69c3043 commit af8e4d67f6f120910f262971a062899cee6b8ce9 @jes5199 jes5199 committed Nov 4, 2010
View
29 README.markdown
@@ -363,6 +363,34 @@ For example, to delete reports older than 1 month:
If you run 'rake reports:prune' without any arguments, or incorrect arguments, it will display further usage instructions.
+Generating certs and connecting to the puppet master
+----------------------------------------------------
+
+In order to connect to the puppet master (to retrieve node facts), the Dashboard must be configured with the correct SSL certificates. To do this, run the following commands:
+
+ rake create_key_pair
+
+ rake cert_request
+
+Then instruct the master to sign the certificate request (using "puppet cert"), and then run the command:
+
+ rake cert_retrieve
+
+You will also need to configure auth.conf on the master to allow Dashboard to connect to the facts terminus:
+
+ path /facts
+ method find
+ allow dashboard
+
+Using the Inventory Service Custom Queries
+----------------------------------------------------
+
+In order to connect to the inventory service you will need to configure auth.conf on the puppet master running the inventory service to allow Dashboard to connect to the inventory terminus:
+
+ path /inventory
+ method search
+ allow dashboard
+
Contributors
------------
@@ -375,3 +403,4 @@ Contributors
* Matt Robinson <matt@puppetlabs.com>
* Nick Lewis <nick@puppetlabs.com>
* Jacob Helwig <jacob@puppetlabs.com>
+* Paul Berry <paul@puppetlabs.com>
View
24 app/controllers/nodes_controller.rb
@@ -25,6 +25,18 @@ def no_longer_reporting
scoped_index :no_longer_reporting
end
+ def search
+ index! do |format|
+ format.html {
+ @search_params = params['search_params'] || []
+ @search_params.delete_if {|param| param.values.any?(&:blank?)}
+ nodes = @search_params.empty? ? [] : Node.find_from_inventory_search(@search_params)
+ set_collection_ivar(nodes)
+ render :inventory_search
+ }
+ end
+ end
+
def show
begin
show!
@@ -38,6 +50,18 @@ def show
end
end
+ def facts
+ respond_to do |format|
+ format.html {
+ begin
+ render :partial => 'nodes/facts', :locals => {:node => resource, :facts => resource.facts}
+ rescue => e
+ render :text => "Could not retrieve facts from inventory service: #{e.message}"
+ end
+ }
+ end
+ end
+
# TODO: routing currently can't handle nested resources due to node's id
# requirements
def reports
View
8 app/controllers/reports_controller.rb
@@ -10,7 +10,13 @@ def create
return
end
- create!
+ create! do |success,failure|
+ failure.html do
+ Rails.logger.debug "WARNING! ReportsController#create failed:"
+ @report.errors.full_messages.each { |msg| Rails.logger.debug msg }
+ render :status => 406
+ end
+ end
end
private
View
11 app/helpers/application_helper.rb
@@ -51,9 +51,9 @@ def inspector_table(collection, key=nil, value=nil, options={})
(value.respond_to?(:call) ? value.call(c) : c.send(value)) :
false
]
- }.flatten
+ }
- collection = Hash[*collection_hash_values]
+ collection = collection_hash_values
end
key ||= :key; value ||= :value
@@ -226,4 +226,11 @@ def tokenize_input_class(*inputs)
javascript << "});"
return javascript
end
+
+ # Asynchronously loads data from a URL and injects it into the element specified. The
+ # element must be in the DOM before the query, or it may fail. Element should be
+ # specified in CSS selector form (eg. "#element" for the object with id="element").
+ def load_asynchronously(element, url)
+ javascript = "jQuery.get('#{url}', function(data) { jQuery('#{element}').html(data) })"
+ end
end
View
23 app/models/node.rb
@@ -1,3 +1,5 @@
+require 'puppet_https'
+
class Node < ActiveRecord::Base
def self.per_page; 20 end # Pagination
@@ -86,6 +88,18 @@ def self.count_no_longer_reporting
no_longer_reporting.count
end
+ def self.find_from_inventory_search(search_params)
+ query_string = search_params.
+ map {|param| "facts.#{CGI::escape param["fact"]}.#{param["comparator"]}=#{CGI::escape param["value"]}" }.
+ join("&")
+
+ url = "https://#{SETTINGS.inventory_server}:#{SETTINGS.inventory_port}/production/inventory/search?#{query_string}"
+ matches = JSON.parse(PuppetHttps.get(url, 'pson'))
+ nodes = Node.find_all_by_name(matches)
+ found = nodes.map(&:name).map(&:downcase)
+ nodes.concat matches.reject {|match| found.include? match.downcase}.map {|match| Node.create!(:name => match)}
+ end
+
def to_param
name.to_s
end
@@ -207,4 +221,13 @@ def assign_last_report(report=nil)
def find_last_report
return Report.find_last_for(self)
end
+
+ def facts
+ return @facts if @facts
+ pson_data = PuppetHttps.get("https://#{SETTINGS.inventory_server}:#{SETTINGS.inventory_port}/production/facts/#{CGI.escape(self.name)}", 'pson')
+ data = JSON.parse(pson_data)
+ @facts = { :timestamp => Time.parse(data['timestamp']),
+ :values => data['values']
+ }
+ end
end
View
16 app/models/report.rb
@@ -2,11 +2,12 @@ class Report < ActiveRecord::Base
def self.per_page; 20 end # Pagination
belongs_to :node
+ before_validation :ensure_valid_format
+ before_validation :process_report
validate :report_contains_metrics
validates_presence_of :host
validates_presence_of :time
validates_uniqueness_of :host, :scope => :time, :allow_nil => true
- before_validation :process_report
after_save :update_node
after_destroy :replace_last_report
@@ -54,6 +55,15 @@ def config_retrieval_time
private
+ def ensure_valid_format
+ begin
+ report
+ rescue ActiveRecord::SerializationTypeMismatch
+ errors.add_to_base("The report is in an invalid format")
+ false
+ end
+ end
+
def process_report
set_attributes
assign_to_node
@@ -81,6 +91,8 @@ def replace_last_report
end
def report_contains_metrics
- report.metrics.present?
+ has_metrics = report.metrics.present?
+ errors.add_to_base("The report contains no metrics") unless has_metrics
+ has_metrics
end
end
View
1 app/views/nodes/_facts.html.haml
@@ -0,0 +1 @@
+= inspector_table facts[:values].sort, :first, :last, :link => false, :key_title => :fact, :value_title => :value, :caption => "Current inventory for #{node.name} as of #{facts[:timestamp]}"
View
5 app/views/nodes/_inventory_parameter.html.haml
@@ -0,0 +1,5 @@
+- inventory_parameter ||= {}
+%br.clear
+= text_field_tag 'search_params[][fact]', inventory_parameter['fact']
+= select_tag 'search_params[][comparator]', options_for_select([['is', 'eq'], ['is not', 'ne'], ['>', 'gt'], ['>=', 'ge'], ['<', 'lt'], ['<=', 'le']], inventory_parameter['comparator'])
+= text_field_tag 'search_params[][value]', inventory_parameter['value']
View
23 app/views/nodes/inventory_search.html.haml
@@ -0,0 +1,23 @@
+- @index = 0
+#sidebar= render 'shared/node_manager_sidebar'
+#main
+
+ .header
+ %h2.half Search nodes
+ %br.clear
+
+ .item
+ .section
+ - form_tag url_for(:action => :search), :method => :get, :id => "search_params" do
+ = render :partial => 'nodes/inventory_parameter', :collection => @search_params
+ = render 'nodes/inventory_parameter'
+ = link_to_function("+", :id => "add_button", :class => "add button") { |page| page.insert_html :before, 'add_button', :partial => 'nodes/inventory_parameter' }
+ %br
+ %button#search_button Search
+
+ - if @nodes
+ .section
+ = render 'statuses/run_failure', :nodes => @nodes
+ .section
+ %h3 Nodes
+ = render 'nodes/nodes', :nodes => @nodes
View
15 app/views/nodes/show.html.haml
@@ -19,7 +19,8 @@
%strong Warning:
The following parameters have multiple conflicting values:
= [*@node.errors.on(:parameters)].join(", ")
- = inspector_table @node.compiled_parameters(true), :key, :value, :link => false, :caption => 'Parameters'
+ %h3 Parameters
+ = inspector_table @node.compiled_parameters(true), :key, :value, :link => false
.section.half
@@ -35,10 +36,18 @@
= inspector_table @node.node_classes, :name, false, :link => true
- else
= describe_no_matches_for :classes
+ - unless @node.inherited_classes.empty?
+ %h3 Inherited Classes
+ = inspector_table @node.inherited_classes, :name, false, :link => true
- - unless @node.inherited_classes.empty?
- = inspector_table @node.inherited_classes, :name, false, :link => true, :caption => 'Inherited Classes'
+ %br.clear
+ .section
+ %h3 Inventory
+ %div#inventory
+ = image_tag "loading.gif"
+ = "Loading node inventory"
+ %script{:type => 'text/javascript'}= load_asynchronously("div#inventory", facts_node_path(@node))
%br.clear
View
4 app/views/shared/_inspector.html.haml
@@ -3,9 +3,9 @@
%caption= options[:caption]
%thead
%tr
- %th.key= key.titleize
+ %th.key= (options[:key_title] || key).to_s.titleize
- unless options[:key_only]
- %th.value{:colspan => 2}= value.titleize
+ %th.value{:colspan => 2}= (options[:value_title] || value).to_s.titleize
%tbody
- inspector.each do |key, value|
%tr
View
2 app/views/shared/_node_manager_sidebar.html.haml
@@ -17,6 +17,8 @@
= link_to "Not currently reporting", no_longer_reporting_nodes_path
- count = Node.count_no_longer_reporting
%span.count{:class => counter_class(count, true)}= count
+ %li
+ = link_to "Custom query", search_nodes_path
.footer.actionbar
= link_to "Add node", new_node_path, :class => 'button'
View
3 config/initializers/00_settings_reader_init.rb
@@ -0,0 +1,3 @@
+# Read settings
+require 'settings_reader'
+SETTINGS = SettingsReader.read
View
7 config/routes.rb
@@ -8,12 +8,15 @@
end
map.resources :nodes,
- :member => {:reports => :get},
+ :member => {
+ :facts => :get,
+ :reports => :get},
:collection => {
:successful => :get,
:failed => :get,
:unreported => :get,
- :no_longer_reporting => :get},
+ :no_longer_reporting => :get,
+ :search => :get},
:requirements => {:id => /[^\/]+/}
map.resource :user_session
View
36 config/settings-sample.yml
@@ -0,0 +1,36 @@
+#===[ Settings ]=========================================================
+#
+# This file is meant for storing setting information that is never
+# published or committed to a revision control system.
+#
+# Do not modify this "config/settings-sample.yml" file directly -- you
+# should copy it to "config/settings.yml" and customize it there.
+#
+#---[ Values ]----------------------------------------------------------
+
+# Node name to use when contacting the inventory service. This is the
+# CN that is used in Dashboard's certificate.
+cn_name: 'dashboard'
+
+certificate_path: 'certs/dashboard.cert.pem'
+
+private_key_path: 'certs/dashboard.private_key.pem'
+
+public_key_path: 'certs/dashboard.public_key.pem'
+
+# Hostname of the certificate authority.
+ca_server: 'puppet'
+
+# Port for the certificate authority.
+ca_port: 8140
+
+# Key length for SSL certificates
+key_length: 1024
+
+# Hostname of the inventory server.
+inventory_server: 'puppet'
+
+# Port for the inventory server.
+inventory_port: 8140
+
+#===[ fin ]=============================================================
View
44 lib/puppet_https.rb
@@ -0,0 +1,44 @@
+require 'uri'
+require 'net/https'
+
+class PuppetHttps
+ def self.certificate_path
+ SETTINGS.certificate_path
+ end
+
+ def self.private_key_path
+ SETTINGS.private_key_path
+ end
+
+ def self.public_key_path
+ SETTINGS.public_key_path
+ end
+
+ def self.make_ssl_request(url, req)
+ connection = Net::HTTP.new(url.host, url.port)
+ connection.use_ssl = true
+ if File.exists?(certificate_path)
+ connection.cert = OpenSSL::X509::Certificate.new(File.read(certificate_path))
+ end
+ if File.exists?(private_key_path)
+ connection.key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
+ end
+ connection.start { |http| http.request(req) }
+ end
+
+ def self.put(url, content_type, data)
+ url = URI.parse(url)
+ req = Net::HTTP::Put.new(url.path)
+ req.content_type = content_type
+ req.body = data
+ res = make_ssl_request(url, req)
+ res.error! unless res.code_type == Net::HTTPOK
+ end
+
+ def self.get(url, accept)
+ url = URI.parse(url)
+ req = Net::HTTP::Get.new("#{url.path}?#{url.query}", "Accept" => accept)
+ res = make_ssl_request(url, req)
+ res.body
+ end
+end
View
59 lib/settings_reader.rb
@@ -0,0 +1,59 @@
+require 'yaml'
+require 'erb'
+require 'ostruct'
+
+require 'rubygems'
+require 'activesupport'
+
+# = SettingsReader
+#
+# Reads settings from an ERB-parsed YAML file and returns an OpenStruct object.
+#
+# Examples:
+# # Read from default "config/settings.yml" and "config/settings-sample.yml" files:
+# SETTINGS = SettingsReader.read
+#
+# # Read a specific file:
+# SETTINGS = SettingsReader.read("myfile.yml")
+#
+class SettingsReader
+ # Return an OpenStruct object with setting information. The settings are read
+ # from an ERB-parsed YAML file.
+ #
+ # Arguments:
+ # * Filename to read settings from. Optional, if not given will try
+ # "config/setting.yml" and "config/setting-sample.yml".
+ #
+ # Options:
+ # * :verbose => Print status to screen on error. Defaults to true.
+ def self.read(*args)
+ opts = args.extract_options!
+ verbose = opts[:verbose] != false
+ given_file = args.first
+
+ normal_file = "config/settings.yml"
+ sample_file = "config/settings-sample.yml"
+ rails_root = RAILS_ROOT rescue File.dirname(File.dirname(__FILE__))
+
+ message = "** SettingsReader - "
+
+ if object = self.filename_to_ostruct(given_file)
+ message << "loaded '#{given_file}'"
+ elsif object = self.filename_to_ostruct(File.join(rails_root, normal_file))
+ message << "loaded '#{normal_file}'"
+ elsif object = self.filename_to_ostruct(File.join(rails_root, sample_file))
+ message << "loaded '#{sample_file}'"
+ else
+ raise Errno::ENOENT, "Couldn't find '#{normal_file}'"
+ end
+
+ RAILS_DEFAULT_LOGGER.info(message) rescue nil
+
+ return object
+ end
+
+ # Return an OpenStruct object by reading the +filename+ and parsing it with ERB and YAML.
+ def self.filename_to_ostruct(filename)
+ return OpenStruct.new(YAML.load(ERB.new(File.read(filename)).result)) rescue nil
+ end
+end
View
58 lib/tasks/install.rake
@@ -5,3 +5,61 @@ end
desc "Update the Puppet Dashboard"
task :update => ['db:migrate']
+
+desc "Create a public/private key pair for communication with the Puppet Master"
+task :create_key_pair => :environment do
+ require 'openssl'
+ require 'puppet_https'
+
+ if File.exists?(PuppetHttps.private_key_path) or File.exists?(PuppetHttps.public_key_path)
+ raise "Key(s) already exist."
+ end
+
+ key = OpenSSL::PKey::RSA.new(SETTINGS.key_length)
+
+ FileUtils.mkdir_p(File.dirname(PuppetHttps.private_key_path))
+ old_umask = File.umask(0226) # user read and group read only
+ begin
+ File.open(PuppetHttps.private_key_path, 'w') do |file|
+ file.print key
+ end
+ ensure
+ File.umask(old_umask)
+ end
+ FileUtils.mkdir_p(File.dirname(PuppetHttps.public_key_path))
+ File.open(PuppetHttps.public_key_path, 'w') do |file|
+ file.print key.public_key
+ end
+end
+
+desc "Submit a certificate request to the Puppet Master"
+task :cert_request => :environment do
+ require 'openssl'
+ require 'puppet_https'
+ require 'cgi'
+ key = OpenSSL::PKey::RSA.new(File.read(PuppetHttps.private_key_path))
+
+ cert_req = OpenSSL::X509::Request.new
+ cert_req.version = 0
+ cert_req.subject = OpenSSL::X509::Name.new([["CN", SETTINGS.cn_name]])
+ cert_req.public_key = key.public_key
+ cert_req.sign(key, OpenSSL::Digest::MD5.new)
+
+ PuppetHttps.put("https://#{SETTINGS.ca_server}:#{SETTINGS.ca_port}/production/certificate_request/#{CGI::escape(SETTINGS.cn_name)}",
+ 'text/plain', cert_req.to_s)
+end
+
+desc "Retrieve a certificate from the Puppet Master"
+task :cert_retrieve => :environment do
+ require 'openssl'
+ require 'puppet_https'
+ require 'cgi'
+ cert_s = PuppetHttps.get("https://#{SETTINGS.ca_server}:#{SETTINGS.ca_port}/production/certificate/#{CGI::escape(SETTINGS.cn_name)}", 's')
+ cert = OpenSSL::X509::Certificate.new(cert_s)
+ key = OpenSSL::PKey::RSA.new(File.read(PuppetHttps.public_key_path))
+ raise "Certificate doesn't match key" unless cert.public_key.to_s == key.to_s
+ FileUtils.mkdir_p(File.dirname(PuppetHttps.certificate_path))
+ File.open(PuppetHttps.certificate_path, 'w') do |file|
+ file.print cert_s
+ end
+end
View
63 spec/controllers/nodes_controller_spec.rb
@@ -191,6 +191,69 @@ def do_put
end
end
+ describe "#search" do
+ before :each do
+ @params = {}
+ end
+
+ it "should strip empty search parameters" do
+ expected_param = {'facts' => 'foo', 'comparator' => 'eq', 'value' => 'bar'}
+ @params['search_params'] = [
+ {'facts' => '', 'comparator' => '', 'values' => ''},
+ {'facts' => 'foo', 'comparator' => '', 'values' => ''},
+ {'facts' => '', 'comparator' => 'eq', 'values' => ''},
+ {'facts' => '', 'comparator' => '', 'values' => 'bar'},
+ expected_param,
+ ]
+
+ Node.expects(:find_from_inventory_search).with([expected_param])
+ get :search, @params
+ end
+
+ it "should not search with no parameters" do
+ @params['search_params'] = []
+
+ Node.expects(:find_from_inventory_search).never
+ get :search, @params
+ end
+ end
+
+ describe "#facts" do
+ before :each do
+ @time = Time.now
+ @node = Node.generate! :name => "testnode"
+ Node.any_instance.stubs(:facts).returns({:timestamp => @time, :values => {"foo" => "1", "bar" => "2"}})
+ end
+
+ def do_get
+ get :facts, :id => @node.name
+ end
+
+ it "should fail gracefully when connections are refused" do
+ Node.any_instance.stubs(:facts).raises(Errno::ECONNREFUSED)
+
+ do_get
+ response.body.should =~ /Could not retrieve facts from inventory service: Connection refused/
+ end
+
+ it "should fail gracefully when other errors occur" do
+ Node.any_instance.stubs(:facts).raises("some error")
+
+ do_get
+ response.body.should =~ /Could not retrieve facts from inventory service: some error/
+ end
+
+ it "should render a table when facts are fetched" do
+ do_get
+ response.body.should =~ /<table.*>/
+ end
+
+ it "should include the inventory timestamp in the rendered table" do
+ do_get
+ response.body.should =~ /Current inventory for testnode as of #{@time}/
+ end
+ end
+
describe "#reports" do
shared_examples_for "a successful reports rendering" do
specify { response.should be_success }
View
10 spec/controllers/reports_controller_spec.rb
@@ -51,6 +51,16 @@ def model; Report end
it { should == "406" }
end
+
+ describe "with a POST with invalid report data, the response code" do
+ before :each do
+ post(:create, :report => "foo bar baz bad data invalid")
+ end
+
+ subject { response.code }
+
+ it { should == "406" }
+ end
end
def post_with_body(action, body, headers)
View
46 spec/models/node_spec.rb
@@ -74,6 +74,29 @@
end
end
+ describe "::find_from_inventory_search" do
+ before :each do
+ @foo = Node.generate :name => "foo"
+ @bar = Node.generate :name => "bar"
+ end
+
+ it "should find the nodes that match the list of names given" do
+ PuppetHttps.stubs(:get).returns('["foo", "bar"]')
+ Node.find_from_inventory_search('').should =~ [@foo, @bar]
+ end
+
+ it "should create nodes that don't exist" do
+ PuppetHttps.stubs(:get).returns('["foo", "bar", "baz"]')
+ Node.find_from_inventory_search('').map(&:name).should =~ ['foo', 'bar', 'baz']
+ end
+
+ it "should look-up nodes case-insensitively" do
+ baz = Node.generate :name => "BAZ"
+ PuppetHttps.stubs(:get).returns('["foo", "BAR", "baz"]')
+ Node.find_from_inventory_search('').should =~ [@foo, @bar, baz]
+ end
+ end
+
# describe ".successful" do
# include DescribeReports
@@ -512,4 +535,27 @@
node_group.node_group_memberships.should be_empty
end
end
+
+ describe "facts" do
+ before :each do
+ @node = Node.generate!(:name => 'gonaddynode')
+ @sample_pson = '{"name":"foo","timestamp":"Fri Oct 29 10:33:53 -0700 2010","expiration":"Fri Oct 29 11:03:53 -0700 2010","values":{"a":"1","b":"2"}}'
+ SETTINGS.stubs(:inventory_server).returns('fred')
+ SETTINGS.stubs(:inventory_port).returns(12345)
+ end
+
+ it "should return facts from an external REST call" do
+ PuppetHttps.stubs(:get).with("https://fred:12345/production/facts/gonaddynode", 'pson').returns(
+ @sample_pson)
+ timestamp = Time.parse("Fri Oct 29 10:33:53 -0700 2010")
+ @node.facts.should == { :timestamp => timestamp, :values => { "a" => "1", "b" => "2" }}
+ end
+
+ it "should properly CGI escape the node name in the REST call" do
+ @node.name = '&/='
+ PuppetHttps.expects(:get).with("https://fred:12345/production/facts/%26%2F%3D", 'pson').returns(
+ @sample_pson)
+ @node.facts
+ end
+ end
end
View
19 spec/models/report_spec.rb
@@ -32,8 +32,25 @@
Report.find(report.id).should_not be_success
end
- it "is not created if a report for the same host exists with the same time" do
+ it "should properly create a valid report" do
+ report = report_model_from_yaml('success.yml')
+ report.save!
+ Report.find(report.id).should be_success
+ end
+
+ it "should consider a blank report to be invalid" do
+ Report.create(:report => '').should_not be_valid
+ end
+ it "should consider a report in incorrect format to be invalid" do
+ Report.create(:report => 'foo bar baz bad data invalid').should_not be_valid
+ end
+
+ it "should consider a report in correct format to be valid" do
+ report_from_yaml.should be_valid
+ end
+
+ it "is not created if a report for the same host exists with the same time" do
Report.create(:report => @report_yaml)
lambda {
Report.create(:report => @report_yaml)

0 comments on commit af8e4d6

Please sign in to comment.