On Apr 19, 2009, at 1:51 PM, Christian Hofstaedtler wrote:
>
> From: Christian Hofstaedtler <[email protected]>
>
> This lays the ground: a wrapper for the REST handler, and an
> application
> confirming to the Rack standard.
>
> Signed-off-by: Christian Hofstaedtler <[email protected]>
> ---
> lib/puppet/feature/base.rb | 3 +
> lib/puppet/network/http/rack.rb | 45 +++++++
> lib/puppet/network/http/rack/rest.rb | 88 ++++++++++++++
> spec/unit/network/http/rack.rb | 69 +++++++++++
> spec/unit/network/http/rack/rest.rb | 216 +++++++++++++++++++++++++
> +++++++++
> 5 files changed, 421 insertions(+), 0 deletions(-)
> create mode 100644 lib/puppet/network/http/rack.rb
> create mode 100644 lib/puppet/network/http/rack/rest.rb
> create mode 100644 spec/unit/network/http/rack.rb
> create mode 100755 spec/unit/network/http/rack/rest.rb
>
> diff --git a/lib/puppet/feature/base.rb b/lib/puppet/feature/base.rb
> index c3fb9a2..7c0f241 100644
> --- a/lib/puppet/feature/base.rb
> +++ b/lib/puppet/feature/base.rb
> @@ -28,3 +28,6 @@ Puppet.features.add(:augeas, :libs => ["augeas"])
>
> # We have RRD available
> Puppet.features.add(:rrd, :libs => ["RRDtool"])
> +
> +# We have rack available, an HTTP Application Stack
> +Puppet.features.add(:rack, :libs => ["rack"])
> diff --git a/lib/puppet/network/http/rack.rb b/lib/puppet/network/
> http/rack.rb
> new file mode 100644
> index 0000000..55e50c4
> --- /dev/null
> +++ b/lib/puppet/network/http/rack.rb
> @@ -0,0 +1,45 @@
> +
> +require 'rack'
> +require 'puppet/network/http'
> +require 'puppet/network/http/rack/rest'
> +
> +# An rack application, for running the Puppet HTTP Server.
> +class Puppet::Network::HTTP::Rack
> +
> + def initialize(args)
> + raise ArgumentError, ":protocols must be specified." if !
> args[:protocols] or args[:protocols].empty?
> + protocols = args[:protocols]
> +
> + # Always prepare a REST handler
> + @rest_http_handler = Puppet::Network::HTTP::RackREST.new()
> + protocols.delete :rest
> +
> + raise ArgumentError, "there were unknown :protocols
> specified." if !protocols.empty?
Shouldn't this fail if the 'rack' feature is missing?
>
> + end
> +
> + # The real rack application (which needs to respond to call).
> + # The work we need to do, roughly is:
> + # * Read request (from env) and prepare a response
> + # * Route the request
> + # * Return the response (in rack-format) to our caller.
> + def call(env)
> + request = Rack::Request.new(env)
> + response = Rack::Response.new()
> + Puppet.debug 'Handling request: %s %s' %
> [request.request_method, request.fullpath]
> +
> + begin
> + @rest_http_handler.process(request, response)
> + rescue => detail
> + # Send a Status 500 Error on unhandled exceptions.
> + response.status = 500
> + response['Content-Type'] = 'text/plain'
> + response.write 'Internal Server Error'
It'd be great if this at least gave the error, too.
>
> + # log what happened
> + Puppet.err "Puppet Server (Rack): Internal Server
> Error: Unhandled Exception: \"%s\"" % detail.message
> + Puppet.err "Backtrace:"
> + detail.backtrace.each { |line| Puppet.err " > %s" %
> line }
> + end
> + response.finish()
> + end
> +end
> +
> diff --git a/lib/puppet/network/http/rack/rest.rb b/lib/puppet/
> network/http/rack/rest.rb
> new file mode 100644
> index 0000000..5679c41
> --- /dev/null
> +++ b/lib/puppet/network/http/rack/rest.rb
> @@ -0,0 +1,88 @@
> +require 'puppet/network/http/handler'
> +
> +class Puppet::Network::HTTP::RackREST
> +
> + include Puppet::Network::HTTP::Handler
> +
> + HEADER_ACCEPT = 'HTTP_ACCEPT'.freeze
> + ContentType = 'Content-Type'.freeze
> +
> + def initialize(args={})
> + super()
> + initialize_for_puppet(args)
> + end
> +
> + def set_content_type(response, format)
> + response[ContentType] = format
> + end
> +
> + # produce the body of the response
> + def set_response(response, result, status = 200)
> + response.status = status
> + response.write result
> + end
> +
> + # Retrieve the accept header from the http request.
> + def accept_header(request)
> + request.env[HEADER_ACCEPT]
> + end
> +
> + # Return which HTTP verb was used in this request.
> + def http_method(request)
> + request.request_method
> + end
> +
> + # Return the query params for this request.
> + def params(request)
> + result = decode_params(request.params)
> + result.merge(extract_client_info(request))
> + end
> +
> + # what path was requested?
> + def path(request)
> + request.fullpath
> + end
> +
> + # return the request body
> + # request.body has some limitiations, so we need to concat it
> back
> + # into a regular string, which is something puppet can use.
> + def body(request)
> + body = ''
> + request.body.each { |part| body += part }
> + body
> + end
> +
> + def extract_client_info(request)
> + ip = request.ip
> + valid = false
> + client = nil
> +
> + # if we find an SSL cert in the headers, use it to get a
> hostname
> + # (for WEBrick, or Apache with ExportCertData)
> + if request.env['SSL_CLIENT_CERT']
Should this environment variable be extracted into a setting?
I would have figured we'd already have such a setting, but I guess not.
>
> + cert =
> OpenSSL::X509::Certificate.new(request.env['SSL_CLIENT_CERT'])
> + nameary = cert.subject.to_a.find { |ary|
> + ary[0] == "CN"
> + }
> + if nameary
> + client = nameary[1]
> + # XXX: certificate validation works by finding the
> supposed
> + # cert the client should be using, and comparing
> that to what
> + # got sent. this *should* be fine, but maybe it's
> not?
> + valid =
> (Puppet::SSL::Certificate.find(client).to_text == cert.to_text)
> + end
This actually won't work. First, this is not the cheapest process,
such that you probably don't want to do it on every single connection,
but most importantly, we can't promise that the server will have every
certificate. SSL should handle this for us, but how Rack does it, I
don't know. I know when Apache is used with Mongrel, we have it set
a couple of variables, one with the client's DN and the other with the
authentication status. I see you're familiar with the header
settings; there's got to be something similar here.
>
> + # now try with :ssl_client_header, which defaults should
> work for
> + # Apache with StdEnvVars.
> + elsif dn = request.env[Puppet[:ssl_client_header]] and
> dn_matchdata = dn.match(/^.*?CN\s*=\s*(.*)/)
> + client = dn_matchdata[1].to_str
> + valid = (request.env[Puppet[:ssl_client_verify_header]]
> == 'SUCCESS')
> + end
> +
> + result = {:ip => ip, :authenticated => valid}
> + if client
> + result[:node] = client
> + end
> + result
> + end
> +end
> diff --git a/spec/unit/network/http/rack.rb b/spec/unit/network/http/
> rack.rb
> new file mode 100644
> index 0000000..2a575e2
> --- /dev/null
> +++ b/spec/unit/network/http/rack.rb
> @@ -0,0 +1,69 @@
> +#!/usr/bin/env ruby
> +
> +require File.dirname(__FILE__) + '/../../../spec_helper'
> +require 'puppet/network/http/rack'
> +
> +describe "Puppet::Network::HTTP::Rack", "while initializing" do
> + confine "Rack is not available" => Puppet.features.rack?
Nice catch.
>
> + it "should require a protocol specification" do
> + Proc.new { Puppet::Network::HTTP::Rack.new({}) }.should
> raise_error(ArgumentError)
> + end
> +
> + it "should only accept the REST protocol" do
> + Proc.new { Puppet::Network::HTTP::Rack.new({:protocols =>
> [:foo]}) }.should raise_error(ArgumentError)
> + end
> +
> + it "should accept the REST protocol" do
> + Proc.new { Puppet::Network::HTTP::Rack.new({:protocols =>
> [:rest]}) }.should_not raise_error(ArgumentError)
> + end
> +
> + it "should create a RackREST instance" do
> + Puppet::Network::HTTP::RackREST.expects(:new)
> + Puppet::Network::HTTP::Rack.new({:protocols => [:rest]})
> + end
> +
> +end
> +
> +describe "Puppet::Network::HTTP::Rack", "when called" do
> + confine "Rack is not available" => Puppet.features.rack?
If you nest all of your contexts, then a single confine in the outer
context will suffice.
>
> + before :all do
> + @app = Puppet::Network::HTTP::Rack.new({:protocols =>
> [:rest]})
> + # let's use Rack::Lint to verify that we're OK with the
> rack specification
> + @linted = Rack::Lint.new(@app)
> + end
> +
> + before :each do
> + @env = Rack::MockRequest.env_for('/')
> + end
> +
> + it "should create a Request object" do
> + request = Rack::Request.new(@env)
> + Rack::Request.expects(:new).returns request
> + @linted.call(@env)
> + end
> +
> + it "should create a Response object" do
> + Rack::Response.expects(:new).returns stub_everything
> + @app.call(@env) # can't lint when Rack::Response is a stub
> + end
> +
> + it "should let RackREST process the request" do
> +
> Puppet::Network::HTTP::RackREST.any_instance.expects(:process).once
> + @linted.call(@env)
> + end
> +
> + it "should catch unhandled exceptions from RackREST" do
> + pending("check why the exception doesn't get rescue'd
> properly") do
> +
> Puppet
> ::Network
> ::HTTP::RackREST.any_instance.expects(:process).raises(Exception,
> 'test error')
> + Proc.new { @linted.call(@env) }.should_not raise_error
> + end
> + end
> +
> + it "should finish() the Response" do
> + Rack::Response.any_instance.expects(:finish).once
> + @app.call(@env) # can't lint when finish is a stub
> + end
> +end
> +
> diff --git a/spec/unit/network/http/rack/rest.rb b/spec/unit/network/
> http/rack/rest.rb
> new file mode 100755
> index 0000000..3179de8
> --- /dev/null
> +++ b/spec/unit/network/http/rack/rest.rb
> @@ -0,0 +1,216 @@
> +#!/usr/bin/env ruby
> +
> +require File.dirname(__FILE__) + '/../../../../spec_helper'
> +require 'puppet/network/http/rack'
> +require 'puppet/network/http/rack/rest'
> +
> +describe "Puppet::Network::HTTP::RackREST" do
> + confine "Rack is not available" => Puppet.features.rack?
> +
> + it "should include the Puppet::Network::HTTP::Handler module" do
> + Puppet::Network::HTTP::RackREST.ancestors.should
> be_include(Puppet::Network::HTTP::Handler)
> + end
> +
> + describe "when initializing" do
> + it "should call the Handler's initialization hook with its
> provided arguments" do
> +
> Puppet
> ::Network
> ::HTTP
> ::RackREST.any_instance.expects(:initialize_for_puppet).with(:server
> => "my", :handler => "arguments")
> + Puppet::Network::HTTP::RackREST.new(:server =>
> "my", :handler => "arguments")
> + end
> + end
> +
> + describe "when serving a request" do
> + before :all do
> + @model_class = stub('indirected model class')
> +
> Puppet
> ::Indirector
> ::Indirection.stubs(:model).with(:foo).returns(@model_class)
> + @handler = Puppet::Network::HTTP::RackREST.new(:handler
> => :foo)
> + end
> +
> + before :each do
> + @response = Rack::Response.new()
> + end
> +
> + def mk_req(uri, opts = {})
> + env = Rack::MockRequest.env_for(uri, opts)
> + Rack::Request.new(env)
> + end
> +
> + describe "and using the HTTP Handler interface" do
> + it "should return the HTTP_ACCEPT parameter as the
> accept header" do
> + req = mk_req('/', 'HTTP_ACCEPT' => 'myaccept')
> + @handler.accept_header(req).should == "myaccept"
> + end
> +
> + it "should use the REQUEST_METHOD as the http method" do
> + req = mk_req('/', :method => 'mymethod')
> + @handler.http_method(req).should == "mymethod"
> + end
> +
> + it "should return the request path as the path" do
> + req = mk_req('/foo/bar')
> + @handler.path(req).should == "/foo/bar"
> + end
> +
> + it "should return the request body as the body" do
> + req = mk_req('/foo/bar', :input => 'mybody')
> + @handler.body(req).should == "mybody"
> + end
> +
> + it "should set the response's content-type header when
> setting the content type" do
> + @header = mock 'header'
> + @response.expects(:header).returns @header
> + @header.expects(:[]=).with('Content-Type', "mytype")
> +
> + @handler.set_content_type(@response, "mytype")
> + end
> +
> + it "should set the status and write the body when
> setting the response for a request" do
> + @response.expects(:status=).with(400)
> + @response.expects(:write).with("mybody")
> +
> + @handler.set_response(@response, "mybody", 400)
> + end
> + end
> +
> + describe "and determining the request parameters" do
> + it "should include the HTTP request parameters, with
> the keys as symbols" do
> + req = mk_req('/?foo=baz&bar=xyzzy')
> + result = @handler.params(req)
> + result[:foo].should == "baz"
> + result[:bar].should == "xyzzy"
> + end
> +
> + it "should URI-decode the HTTP parameters" do
> + encoding = URI.escape("foo bar")
> + req = mk_req("/?foo=#{encoding}")
> + result = @handler.params(req)
> + result[:foo].should == "foo bar"
> + end
> +
> + it "should convert the string 'true' to the boolean" do
> + req = mk_req("/?foo=true")
> + result = @handler.params(req)
> + result[:foo].should be_true
> + end
> +
> + it "should convert the string 'false' to the boolean" do
> + req = mk_req("/?foo=false")
> + result = @handler.params(req)
> + result[:foo].should be_false
> + end
> +
> + it "should convert integer arguments to Integers" do
> + req = mk_req("/?foo=15")
> + result = @handler.params(req)
> + result[:foo].should == 15
> + end
> +
> + it "should convert floating point arguments to Floats" do
> + req = mk_req("/?foo=1.5")
> + result = @handler.params(req)
> + result[:foo].should == 1.5
> + end
> +
> + it "should YAML-load and URI-decode values that are
> YAML-encoded" do
> + escaping = URI.escape(YAML.dump(%w{one two}))
> + req = mk_req("/?foo=#{escaping}")
> + result = @handler.params(req)
> + result[:foo].should == %w{one two}
> + end
> +
> + it "should not allow the client to set the node via the
> query string" do
> + req = mk_req("/?node=foo")
> + @handler.params(req)[:node].should be_nil
> + end
> +
> + it "should not allow the client to set the IP address
> via the query string" do
> + req = mk_req("/?ip=foo")
> + @handler.params(req)[:ip].should be_nil
> + end
> +
> + it "should pass the client's ip address to model find" do
> + req = mk_req("/", 'REMOTE_ADDR' => 'ipaddress')
> + @handler.params(req)[:ip].should == "ipaddress"
> + end
> +
> + it "should set 'authenticated' to false if no
> certificate is present" do
> + req = mk_req('/')
> + @handler.params(req)[:authenticated].should be_false
> + end
> + end
> +
> + describe "with pre-validated certificates" do
> +
> + it "should use the :ssl_client_header to determine the
> parameter when looking for the certificate" do
> + Puppet.settings.stubs(:value).returns "eh"
> +
> Puppet.settings.expects(:value).with(:ssl_client_header).returns
> "myheader"
> + req = mk_req('/', "myheader" => "/
> CN=host.domain.com")
> + @handler.params(req)
> + end
> +
> + it "should retrieve the hostname by matching the
> certificate parameter" do
> + Puppet.settings.stubs(:value).returns "eh"
> +
> Puppet.settings.expects(:value).with(:ssl_client_header).returns
> "myheader"
> + req = mk_req('/', "myheader" => "/
> CN=host.domain.com")
> + @handler.params(req)[:node].should ==
> "host.domain.com"
> + end
> +
> + it "should use the :ssl_client_header to determine the
> parameter for checking whether the host certificate is valid" do
> +
> Puppet.settings.stubs(:value).with(:ssl_client_header).returns
> "certheader"
> +
> Puppet
> .settings.expects(:value).with(:ssl_client_verify_header).returns
> "myheader"
> + req = mk_req('/', "myheader" => "SUCCESS",
> "certheader" => "/CN=host.domain.com")
> + @handler.params(req)
> + end
> +
> + it "should consider the host authenticated if the
> validity parameter contains 'SUCCESS'" do
> +
> Puppet.settings.stubs(:value).with(:ssl_client_header).returns
> "certheader"
> +
> Puppet
> .settings.stubs(:value).with(:ssl_client_verify_header).returns
> "myheader"
> + req = mk_req('/', "myheader" => "SUCCESS",
> "certheader" => "/CN=host.domain.com")
> + @handler.params(req)[:authenticated].should be_true
> + end
> +
> + it "should consider the host unauthenticated if the
> validity parameter does not contain 'SUCCESS'" do
> +
> Puppet.settings.stubs(:value).with(:ssl_client_header).returns
> "certheader"
> +
> Puppet
> .settings.stubs(:value).with(:ssl_client_verify_header).returns
> "myheader"
> + req = mk_req('/', "myheader" => "whatever",
> "certheader" => "/CN=host.domain.com")
> + @handler.params(req)[:authenticated].should be_false
> + end
> +
> + it "should consider the host unauthenticated if no
> certificate information is present" do
> +
> Puppet.settings.stubs(:value).with(:ssl_client_header).returns
> "certheader"
> +
> Puppet
> .settings.stubs(:value).with(:ssl_client_verify_header).returns
> "myheader"
> + req = mk_req('/', "myheader" => nil, "certheader"
> => "/CN=host.domain.com")
> + @handler.params(req)[:authenticated].should be_false
> + end
> +
> + it "should not pass a node name to model method if no
> certificate information is present" do
> + Puppet.settings.stubs(:value).returns "eh"
> +
> Puppet.settings.expects(:value).with(:ssl_client_header).returns
> "myheader"
> + req = mk_req('/', "myheader" => nil)
> + @handler.params(req).should_not be_include(:node)
> + end
> + end
> +
> + describe "with in-header certificates" do
> + it "should set 'authenticated' to true if a certificate
> is present" do
> + cert = stub 'cert', :subject => [%w{CN
> host.domain.com}], :to_text => 'yay'
> +
> OpenSSL::X509::Certificate.expects(:new).returns(cert)
> +
> Puppet
> ::SSL
> ::Certificate.expects(:find).with('host.domain.com').returns(cert)
> + req = mk_req('/', 'SSL_CLIENT_CERT' => cert)
> + @handler.params(req)[:authenticated].should be_true
> + end
> +
> + it "should pass the client's certificate name to model
> method if a certificate is present" do
> + cert = stub 'cert', :subject => [%w{CN
> host.domain.com}], :to_text => 'yay'
> +
> OpenSSL::X509::Certificate.expects(:new).returns(cert)
> +
> Puppet
> ::SSL
> ::Certificate.expects(:find).with('host.domain.com').returns(cert)
> + req = mk_req('/', 'SSL_CLIENT_CERT' => cert)
> + @handler.params(req)[:node].should ==
> "host.domain.com"
> + end
> +
> + it "should not pass a node name to model method if no
> certificate is present" do
> + req = mk_req('/', 'SSL_CLIENT_CERT' => nil)
> + @handler.params(req).should_not be_include(:node)
> + end
> + end
> + end
> +end
> --
> 1.5.6.5
>
>
> >
--
No matter how rich you become, how famous or powerful, when you die
the size of your funeral will still pretty much depend on the
weather. -- Michael Pritchard
---------------------------------------------------------------------
Luke Kanies | http://reductivelabs.com | http://madstop.com
--~--~---------~--~----~------------~-------~--~----~
You received this message because you are subscribed to the Google Groups
"Puppet Developers" group.
To post to this group, send email to [email protected]
To unsubscribe from this group, send email to
[email protected]
For more options, visit this group at
http://groups.google.com/group/puppet-dev?hl=en
-~----------~----~----~----~------~----~------~--~---