logstash-output-amazon_es 0.1.0-java
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Gemfile +3 -0
- data/LICENSE +202 -0
- data/NOTICE.TXT +14 -0
- data/README.md +158 -0
- data/lib/logstash/outputs/amazon_es.rb +472 -0
- data/lib/logstash/outputs/amazon_es/aws_transport.rb +103 -0
- data/lib/logstash/outputs/amazon_es/aws_v4_signer.rb +7 -0
- data/lib/logstash/outputs/amazon_es/aws_v4_signer_impl.rb +58 -0
- data/lib/logstash/outputs/amazon_es/elasticsearch-template.json +41 -0
- data/lib/logstash/outputs/amazon_es/http_client.rb +117 -0
- data/logstash-output-amazon_es.gemspec +42 -0
- data/spec/amazon_es_spec_helper.rb +69 -0
- data/spec/unit/outputs/amazon_es_spec.rb +50 -0
- data/spec/unit/outputs/elasticsearch/protocol_spec.rb +36 -0
- data/spec/unit/outputs/elasticsearch_proxy_spec.rb +58 -0
- metadata +270 -0
@@ -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,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
|