logstash-output-amazon_es 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+ require 'aws-sdk-core'
4
+ require 'elasticsearch'
5
+ require 'elasticsearch-transport'
6
+ require 'manticore'
7
+ require 'faraday/adapter/manticore'
8
+
9
+ #
10
+ require 'uri'
11
+
12
+ require_relative "aws_v4_signer"
13
+
14
+
15
+ module Elasticsearch
16
+ module Transport
17
+ module Transport
18
+ module HTTP
19
+
20
+ # Transport implementation, which V4 Signs requests using the [_Faraday_](https://rubygems.org/gems/faraday)
21
+ # library for abstracting the HTTP client.
22
+ #
23
+ # @see Transport::Base
24
+ #
25
+ class AWS
26
+ include Elasticsearch::Transport::Transport::Base
27
+
28
+
29
+ DEFAULT_PORT = 80
30
+ DEFAULT_PROTOCOL = "http"
31
+
32
+ CredentialConfig = Struct.new(
33
+ :access_key_id,
34
+ :secret_access_key,
35
+ :session_token,
36
+ :profile
37
+ )
38
+
39
+ # Performs the request by invoking {Transport::Base#perform_request} with a block.
40
+ #
41
+ # @return [Response]
42
+ # @see Transport::Base#perform_request
43
+ #
44
+ def perform_request(method, path, params={}, body=nil)
45
+ super do |connection, url|
46
+ response = connection.connection.run_request \
47
+ method.downcase.to_sym,
48
+ url,
49
+ ( body ? __convert_to_json(body) : nil ),
50
+ {}
51
+
52
+ Response.new response.status, response.body, response.headers
53
+ end
54
+ end
55
+
56
+ # Builds and returns a collection of connections.
57
+ #
58
+ # @return [Connections::Collection]
59
+ #
60
+ def __build_connections
61
+ region = options[:region]
62
+ access_key_id = options[:aws_access_key_id] || nil
63
+ secret_access_key = options[:aws_secret_access_key] || nil
64
+ session_token = options[:session_token] || nil
65
+ profile = options[:profile] || 'default'
66
+
67
+ credential_config = CredentialConfig.new(access_key_id, secret_access_key, session_token, profile)
68
+ credentials = Aws::CredentialProviderChain.new(credential_config).resolve
69
+
70
+ Connections::Collection.new \
71
+ :connections => hosts.map { |host|
72
+ host[:protocol] = host[:scheme] || DEFAULT_PROTOCOL
73
+ host[:port] ||= DEFAULT_PORT
74
+ url = __full_url(host)
75
+
76
+ aes_connection = ::Faraday::Connection.new(url, (options[:transport_options] || {})) do |faraday|
77
+ faraday.request :aws_v4_signer,
78
+ credentials: credentials,
79
+ service_name: 'es',
80
+ region: region
81
+ faraday.adapter :manticore
82
+ end
83
+
84
+ Connections::Connection.new \
85
+ :host => host,
86
+ :connection => aes_connection
87
+ },
88
+ :selector_class => options[:selector_class],
89
+ :selector => options[:selector]
90
+ end
91
+
92
+ # Returns an array of implementation specific connection errors.
93
+ #
94
+ # @return [Array]
95
+ #
96
+ def host_unreachable_exceptions
97
+ [::Faraday::Error::ConnectionFailed, ::Faraday::Error::TimeoutError]
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,7 @@
1
+ require 'faraday'
2
+ require_relative 'aws_v4_signer_impl'
3
+
4
+ module FaradayMiddleware
5
+ Faraday::Request.register_middleware :aws_v4_signer => lambda { AwsV4Signer }
6
+ end
7
+
@@ -0,0 +1,58 @@
1
+ require 'aws-sdk-core'
2
+ require 'faraday'
3
+
4
+
5
+ class AwsV4Signer < Faraday::Middleware
6
+ class Request
7
+ def initialize(env)
8
+ @env = env
9
+ end
10
+
11
+ def headers
12
+ @env.request_headers
13
+ end
14
+
15
+ def set_header(header)
16
+ @env.request_headers = header
17
+ end
18
+
19
+ def body
20
+ @env.body || ''
21
+ end
22
+
23
+ def endpoint
24
+ @env.url
25
+ end
26
+
27
+ def http_method
28
+ @env.method.to_s.upcase
29
+ end
30
+ end
31
+
32
+ def initialize(app, options = nil)
33
+ super(app)
34
+ credentials = options.fetch(:credentials)
35
+ service_name = options.fetch(:service_name)
36
+ region = options.fetch(:region)
37
+ @signer = Aws::Signers::V4.new(credentials, service_name, region)
38
+ @net_http = app.is_a?(Faraday::Adapter::NetHttp)
39
+ end
40
+
41
+ def call(env)
42
+ normalize_for_net_http!(env)
43
+ req = Request.new(env)
44
+ @signer.sign(req)
45
+ @app.call(env)
46
+ end
47
+
48
+ private
49
+ def normalize_for_net_http!(env)
50
+ return unless @net_http
51
+
52
+ if Net::HTTP::HAVE_ZLIB
53
+ env.request_headers['Accept-Encoding'] ||= 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3'
54
+ end
55
+
56
+ env.request_headers['Accept'] ||= '*/*'
57
+ end
58
+ end
@@ -0,0 +1,41 @@
1
+ {
2
+ "template" : "logstash-*",
3
+ "settings" : {
4
+ "index.refresh_interval" : "5s"
5
+ },
6
+ "mappings" : {
7
+ "_default_" : {
8
+ "_all" : {"enabled" : true, "omit_norms" : true},
9
+ "dynamic_templates" : [ {
10
+ "message_field" : {
11
+ "match" : "message",
12
+ "match_mapping_type" : "string",
13
+ "mapping" : {
14
+ "type" : "string", "index" : "analyzed", "omit_norms" : true
15
+ }
16
+ }
17
+ }, {
18
+ "string_fields" : {
19
+ "match" : "*",
20
+ "match_mapping_type" : "string",
21
+ "mapping" : {
22
+ "type" : "string", "index" : "analyzed", "omit_norms" : true,
23
+ "fields" : {
24
+ "raw" : {"type": "string", "index" : "not_analyzed", "ignore_above" : 256}
25
+ }
26
+ }
27
+ }
28
+ } ],
29
+ "properties" : {
30
+ "@version": { "type": "string", "index": "not_analyzed" },
31
+ "geoip" : {
32
+ "type" : "object",
33
+ "dynamic": true,
34
+ "properties" : {
35
+ "location" : { "type" : "geo_point" }
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ }
@@ -0,0 +1,117 @@
1
+ require "logstash/outputs/amazon_es"
2
+ require "cabin"
3
+ require "base64"
4
+ require "elasticsearch"
5
+
6
+ require_relative "aws_transport"
7
+
8
+ module LogStash::Outputs::AES
9
+ class HttpClient
10
+ attr_reader :client, :options, :client_options
11
+ DEFAULT_OPTIONS = {
12
+ :port => 80
13
+ }
14
+
15
+ def initialize(options={})
16
+ @logger = Cabin::Channel.get
17
+ @options = DEFAULT_OPTIONS.merge(options)
18
+ @client = build_client(@options)
19
+ end
20
+
21
+ def template_install(name, template, force=false)
22
+ if template_exists?(name) && !force
23
+ @logger.debug("Found existing Elasticsearch template. Skipping template management", :name => name)
24
+ return
25
+ end
26
+ template_put(name, template)
27
+ end
28
+
29
+ def bulk(actions)
30
+ bulk_body = actions.collect do |action, args, source|
31
+ if action == 'update'
32
+ if args[:_id]
33
+ source = { 'doc' => source }
34
+ if @options[:doc_as_upsert]
35
+ source['doc_as_upsert'] = true
36
+ else
37
+ source['upsert'] = args[:_upsert] if args[:_upsert]
38
+ end
39
+ else
40
+ raise(LogStash::ConfigurationError, "Specifying action => 'update' without a document '_id' is not supported.")
41
+ end
42
+ end
43
+
44
+ args.delete(:_upsert)
45
+
46
+ if source
47
+ next [ { action => args }, source ]
48
+ else
49
+ next { action => args }
50
+ end
51
+ end.flatten
52
+
53
+ bulk_response = @client.bulk(:body => bulk_body)
54
+
55
+ self.class.normalize_bulk_response(bulk_response)
56
+ end
57
+
58
+ private
59
+ def build_client(options)
60
+ hosts = options[:hosts]
61
+ port = options[:port]
62
+ client_settings = options[:client_settings] || {}
63
+
64
+ uris = hosts.map do |host|
65
+ "http://#{host}:#{port}#{client_settings[:path]}".gsub(/[\/]+$/,'')
66
+ end
67
+
68
+ @client_options = {
69
+ :hosts => uris,
70
+ :region => options[:region],
71
+ :aws_access_key_id => options[:aws_access_key_id],
72
+ :aws_secret_access_key => options[:aws_secret_access_key],
73
+ :transport_options => {
74
+ :request => {:open_timeout => 0, :timeout => 60}, # ELB timeouts are set at 60
75
+ :proxy => client_settings[:proxy],
76
+ },
77
+ :transport_class => Elasticsearch::Transport::Transport::HTTP::AWS
78
+ }
79
+
80
+ if options[:user] && options[:password] then
81
+ token = Base64.strict_encode64(options[:user] + ":" + options[:password])
82
+ @client_options[:headers] = { "Authorization" => "Basic #{token}" }
83
+ end
84
+
85
+ Elasticsearch::Client.new(client_options)
86
+ end
87
+
88
+ def self.normalize_bulk_response(bulk_response)
89
+ if bulk_response["errors"]
90
+ # The structure of the response from the REST Bulk API is follows:
91
+ # {"took"=>74, "errors"=>true, "items"=>[{"create"=>{"_index"=>"logstash-2014.11.17",
92
+ # "_type"=>"logs",
93
+ # "_id"=>"AUxTS2C55Jrgi-hC6rQF",
94
+ # "_version"=>1,
95
+ # "status"=>400,
96
+ # "error"=>"MapperParsingException[failed to parse]..."}}]}
97
+ # where each `item` is a hash of {OPTYPE => Hash[]}. calling first, will retrieve
98
+ # this hash as a single array with two elements, where the value is the second element (i.first[1])
99
+ # then the status of that item is retrieved.
100
+ {"errors" => true, "statuses" => bulk_response["items"].map { |i| i.first[1]['status'] }}
101
+ else
102
+ {"errors" => false}
103
+ end
104
+ end
105
+
106
+ def template_exists?(name)
107
+ @client.indices.get_template(:name => name)
108
+ return true
109
+ rescue Elasticsearch::Transport::Transport::Errors::NotFound
110
+ return false
111
+ end
112
+
113
+ def template_put(name, template)
114
+ @client.indices.put_template(:name => name, :body => template)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,42 @@
1
+ Gem::Specification.new do |s|
2
+
3
+ s.name = 'logstash-output-amazon_es'
4
+ s.version = '0.1.0'
5
+ s.licenses = ['apache-2.0']
6
+ s.summary = "Logstash Output to Amazon Elasticsearch Service"
7
+ s.description = "Output events to Amazon Elasticsearch Service with V4 signing"
8
+ s.authors = ["Amazon"]
9
+ s.email = 'feedback-prod-elasticsearch@amazon.com'
10
+ s.homepage = "http://logstash.net/"
11
+ s.require_paths = ["lib"]
12
+
13
+ # Files
14
+ s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
15
+
16
+ # Tests
17
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
18
+
19
+ # Special flag to let us know this is actually a logstash plugin
20
+ s.metadata = { "logstash_plugin" => "true", "logstash_group" => "output" }
21
+
22
+ # Gem dependencies
23
+ s.add_runtime_dependency 'concurrent-ruby'
24
+ s.add_runtime_dependency 'elasticsearch', ['>= 1.0.10', '~> 1.0']
25
+ s.add_runtime_dependency 'stud', ['>= 0.0.17', '~> 0.0']
26
+ s.add_runtime_dependency 'cabin', ['~> 0.6']
27
+ s.add_runtime_dependency "logstash-core", '>= 1.4.0', '< 2.0.0'
28
+ s.add_runtime_dependency "aws-sdk", ['>= 2.1.14', '~> 2.1']
29
+ s.add_runtime_dependency "faraday", '~> 0.9.1'
30
+ s.add_runtime_dependency "faraday_middleware", '~> 0.10.0'
31
+
32
+ s.add_development_dependency 'ftw', '~> 0.0.42'
33
+ s.add_development_dependency 'logstash-input-generator'
34
+
35
+ if RUBY_PLATFORM == 'java'
36
+ s.platform = RUBY_PLATFORM
37
+ s.add_runtime_dependency "manticore", '~> 0.4.2'
38
+ end
39
+
40
+ s.add_development_dependency 'logstash-devutils'
41
+ s.add_development_dependency 'longshoreman'
42
+ end
@@ -0,0 +1,69 @@
1
+ require "logstash/devutils/rspec/spec_helper"
2
+ require "ftw"
3
+ require "logstash/plugin"
4
+ require "logstash/json"
5
+ require "stud/try"
6
+ require "longshoreman"
7
+
8
+ CONTAINER_NAME = "logstash-output-amazon-es-#{rand(999).to_s}"
9
+ CONTAINER_IMAGE = "elasticsearch"
10
+ CONTAINER_TAG = "1.6"
11
+
12
+ DOCKER_INTEGRATION = ENV["DOCKER_INTEGRATION"]
13
+
14
+ module ESHelper
15
+ def get_host
16
+ DOCKER_INTEGRATION ? Longshoreman.new.get_host_ip : "127.0.0.1"
17
+ end
18
+
19
+ def get_port
20
+ return 9200 unless DOCKER_INTEGRATION
21
+
22
+ container = Longshoreman::Container.new
23
+ container.get(CONTAINER_NAME)
24
+ container.rport(9200)
25
+ end
26
+
27
+ def get_client
28
+ Elasticsearch::Client.new(:host => "#{get_host}:#{get_port}")
29
+ end
30
+ end
31
+
32
+
33
+ RSpec.configure do |config|
34
+ config.include ESHelper
35
+
36
+
37
+ if DOCKER_INTEGRATION
38
+ # this :all hook gets run before every describe block that is tagged with :integration => true.
39
+ config.before(:all, :integration => true) do
40
+
41
+
42
+ # check if container exists already before creating new one.
43
+ begin
44
+ ls = Longshoreman::new
45
+ ls.container.get(CONTAINER_NAME)
46
+ rescue Docker::Error::NotFoundError
47
+ Longshoreman.new("#{CONTAINER_IMAGE}:#{CONTAINER_TAG}", CONTAINER_NAME)
48
+ # TODO(talevy): verify ES is running instead of static timeout
49
+ sleep 10
50
+ end
51
+ end
52
+
53
+ # we want to do a final cleanup after all :integration runs,
54
+ # but we don't want to clean up before the last block.
55
+ # This is a final blind check to see if the ES docker container is running and
56
+ # needs to be cleaned up. If no container can be found and/or docker is not
57
+ # running on the system, we do nothing.
58
+ config.after(:suite) do
59
+ # only cleanup docker container if system has docker and the container is running
60
+ begin
61
+ ls = Longshoreman::new
62
+ ls.container.get(CONTAINER_NAME)
63
+ ls.cleanup
64
+ rescue Docker::Error::NotFoundError, Excon::Errors::SocketError
65
+ # do nothing
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,50 @@
1
+ require_relative "../../../spec/amazon_es_spec_helper"
2
+
3
+ describe "outputs/amazon_es" do
4
+ describe "http client create" do
5
+ require "logstash/outputs/amazon_es"
6
+ require "elasticsearch"
7
+
8
+ let(:options) {
9
+ {
10
+ "index" => "my-index",
11
+ "hosts" => "localhost",
12
+ "path" => "some-path"
13
+ }
14
+ }
15
+
16
+ let(:eso) {LogStash::Outputs::AmazonES.new(options)}
17
+
18
+ let(:manticore_host) {
19
+ eso.client.send(:client).transport.options[:hosts].first
20
+ }
21
+
22
+ around(:each) do |block|
23
+ thread = eso.register
24
+ block.call()
25
+ thread.kill()
26
+ end
27
+
28
+ describe "with path" do
29
+ it "should properly create a URI with the path" do
30
+ expect(eso.path).to eql(options["path"])
31
+ end
32
+
33
+
34
+ it "should properly set the path on the HTTP client" do
35
+ expect(manticore_host).to include("/" + options["path"])
36
+ end
37
+
38
+ context "with extra slashes" do
39
+ let(:path) { "/slashed-path/ "}
40
+ let(:eso) {
41
+ LogStash::Outputs::AmazonES.new(options.merge("path" => "/some-path/"))
42
+ }
43
+
44
+ it "should properly set the path on the HTTP client without adding slashes" do
45
+ expect(manticore_host).to include(options["path"])
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end