diff --git a/REFERENCE.md b/REFERENCE.md
index 550a92a..38a74e5 100644
--- a/REFERENCE.md
+++ b/REFERENCE.md
@@ -8,6 +8,10 @@
* [`elastic_stack::repo`](#elastic_stack--repo): Set up the package repository for Elastic Stack components
+### Resource types
+
+* [`elastic_stack_keystore`](#elastic_stack_keystore): Manages a keystore settings file (for either Elasticserach or Kibana service.
+
## Classes
### `elastic_stack::repo`
@@ -81,3 +85,61 @@ The base url for the repo path
Default value: `undef`
+## Resource types
+
+### `elastic_stack_keystore`
+
+Manages a keystore settings file (for either Elasticserach or Kibana service.
+
+#### Properties
+
+The following properties are available in the `elastic_stack_keystore` type.
+
+##### `ensure`
+
+Valid values: `present`, `absent`
+
+The basic property that the resource should be in.
+
+Default value: `present`
+
+##### `password`
+
+Password to protect keystore.
+
+Default value: `''`
+
+##### `settings`
+
+A key/value hash of settings names and values.
+
+#### Parameters
+
+The following parameters are available in the `elastic_stack_keystore` type.
+
+* [`provider`](#-elastic_stack_keystore--provider)
+* [`purge`](#-elastic_stack_keystore--purge)
+* [`service`](#-elastic_stack_keystore--service)
+
+##### `provider`
+
+The specific backend to use for this `elastic_stack_keystore` resource. You will seldom need to specify this --- Puppet
+will usually discover the appropriate provider for your platform.
+
+##### `purge`
+
+Valid values: `true`, `false`, `yes`, `no`
+
+Whether to proactively remove settings that exist in the keystore but
+are not present in this resource's settings.
+
+Default value: `false`
+
+##### `service`
+
+Valid values: `elasticsearch`, `kibana`
+
+Service that manages the keystore (either "elasticsearch" or "kibana").
+
+Default value: `elasticsearch`
+
diff --git a/lib/puppet/provider/elastic_stack_keystore/ruby.rb b/lib/puppet/provider/elastic_stack_keystore/ruby.rb
new file mode 100644
index 0000000..cdf890b
--- /dev/null
+++ b/lib/puppet/provider/elastic_stack_keystore/ruby.rb
@@ -0,0 +1,313 @@
+# frozen_string_literal: true
+
+Puppet::Type.type(:elastic_stack_keystore).provide(
+ :elastic_stack_keystore
+) do
+ desc 'Provider for both `elasticsearch-keystore` and `kibana-keystore` based secret management.'
+
+ mk_resource_methods
+
+ def self.defaults_dir
+ @defaults_dir ||= case Facter.value(:os)['family']
+ when 'RedHat'
+ '/etc/sysconfig'
+ else
+ '/etc/default'
+ end
+ end
+
+ def self.root_dir
+ @root_dir ||= case Facter.value(:os)['family']
+ when 'OpenBSD'
+ '/usr/local'
+ else
+ '/usr/share'
+ end
+ end
+
+ def self.home_dir_kibana
+ @home_dir_kibana ||= File.join(root_dir, 'kibana')
+ end
+
+ def self.home_dir_elasticsearch
+ @home_dir_elasticsearch ||= File.join(root_dir, 'elasticsearch')
+ end
+
+ def self.elastic_keystore_password_file
+ keystore_env = get_envvar('elasticsearch', 'ES_KEYSTORE_PASSPHRASE_FILE')
+ @elastic_keystore_password_file ||= keystore_env.empty? ? "#{configdir('elasticsearch')}/.elasticsearch-keystore-password" : keystore_env
+ end
+
+ def self.elastic_keystore_password(password = '')
+ if File.file?(elastic_keystore_password_file)
+ @elastic_keystore_password ||= File.open(elastic_keystore_password_file, &:readline).strip
+ else
+ @elastic_keystore_password = password.empty? ? @elastic_keystore_password : password
+ end
+ end
+
+ def self.elastic_keystore_password_file_bak
+ @elastic_keystore_password_file_bak ||= "#{elastic_keystore_password_file}.puppet-bak"
+ end
+
+ def self.elastic_keystore_password_bak
+ @elastic_keystore_password_bak ||= File.file?(elastic_keystore_password_file_bak) ? File.open(elastic_keystore_password_file_bak, &:readline).strip : ''
+ end
+
+ attr_accessor :defaults_dir, :root_dir, :home_dir_kibana, :home_dir_elasticsearch, :elastic_keystore_password_file, :elastic_keystore_password, :elastic_keystore_password_file_bak, :elastic_keystore_password_bak
+
+ optional_commands kibana_keystore: "#{home_dir_kibana}/bin/kibana-keystore"
+ optional_commands elasticsearch_keystore: "#{home_dir_elasticsearch}/bin/elasticsearch-keystore"
+
+ def self.run_keystore(args, service, stdin = nil)
+ options = {
+ uid: service.to_s,
+ gid: service.to_s,
+ failonfail: true
+ }
+
+ password = case service
+ when 'elasticsearch'
+ File.file?(elastic_keystore_password_file_bak) ? elastic_keystore_password_bak : elastic_keystore_password
+ else
+ ''
+ end
+
+ cmd = [command("#{service}_keystore")]
+ if args[0] == 'create' || args[0] == 'has-passwd'
+ options[:failonfail] = false
+ options[:combine] = true
+ elsif args[0] == 'passwd'
+ options[:combine] = true
+ stdin = File.file?(elastic_keystore_password_file_bak) ? "#{elastic_keystore_password_bak}\n#{elastic_keystore_password}\n#{elastic_keystore_password}" : "#{elastic_keystore_password}\n#{elastic_keystore_password}"
+ end
+
+ unless args[0] == 'passwd' || args[0] == 'has-passwd'
+ stdin = stdin.nil? ? password : "#{password}\n#{stdin}"
+ end
+
+ unless stdin.nil?
+ stdinfile = Tempfile.new("#{service}-keystore")
+ stdinfile << stdin
+ stdinfile.flush
+ options[:stdinfile] = stdinfile.path
+ end
+
+ begin
+ stdout = execute(cmd + args, options)
+ ensure
+ unless stdin.nil?
+ stdinfile.close
+ stdinfile.unlink
+ end
+ end
+
+ if stdout.exitstatus.zero?
+ stdout
+ else
+ options[:failonfail] ? raise(Puppet::Error, stdout) : stdout
+ end
+ end
+
+ def self.present_keystores(configdir, service, password = '')
+ keystore_file = File.join(configdir, "#{service}.keystore")
+ if File.file?(keystore_file)
+ current_password = case service
+ when 'elasticsearch'
+ if passwd?(service) && File.file?(elastic_keystore_password_file_bak)
+ elastic_keystore_password_bak
+ elsif passwd?(service)
+ elastic_keystore_password(password.value)
+ else
+ elastic_keystore_password(password.value)
+ ''
+ end
+ else
+ ''
+ end
+ settings = {}
+ run_keystore(['list'], service).split("\n").each do |setting|
+ settings[setting] = service == 'kibana' ? '' : run_keystore(['show', setting], service)
+ end
+ [{
+ name: service,
+ ensure: :present,
+ provider: name,
+ settings: settings,
+ password: current_password,
+ }]
+ else
+ []
+ end
+ end
+
+ def self.configdir(service)
+ dir = get_envvar(service, '(ES|KBN)_PATH_CONF')
+ if dir.empty?
+ File.join('/etc', service)
+ else
+ dir
+ end
+ end
+
+ def self.get_envvar(service, env)
+ defaults_file = File.join(defaults_dir, service)
+ val = ''
+ if File.file?(defaults_file)
+ File.readlines(defaults_file).each do |line|
+ next if line =~ %r{^#}
+
+ key, value = line.split '='
+ val = value.gsub(%r{"}, '').strip if key =~ %r{#{env}}
+ end
+ end
+ val
+ end
+
+ def self.instances(password = '')
+ keystores = []
+ %w[kibana elasticsearch].each do |service|
+ keystores.concat(present_keystores(configdir(service), service, password))
+ end
+ keystores.map do |keystore|
+ new keystore
+ end
+ end
+
+ def self.passwd?(service)
+ has_passwd = run_keystore(['has-passwd'], service).split("\n").last
+ has_passwd.match?(%r{^Keystore is password-protected})
+ end
+
+ def self.keystore_password_management(service)
+ if passwd?(service)
+ run_keystore(['passwd'], service) unless elastic_keystore_password_bak.strip.empty? || elastic_keystore_password == elastic_keystore_password_bak
+ else
+ run_keystore(['passwd'], service) unless elastic_keystore_password.empty?
+ end
+ end
+
+ def self.prefetch(resources)
+ password = resources.key?(:elasticsearch) ? resources[:elasticsearch].parameters[:password] : ''
+ keystores = instances(password)
+ resources.each_key do |name|
+ provider = keystores.find { |keystore| keystore.name.to_sym == name }
+ resources[name].provider = provider if provider
+ end
+ end
+
+ def initialize(value = {})
+ super(value)
+ @property_flush = {}
+ end
+
+ def flush
+ configdir = self.class.configdir(resource[:service].to_s)
+ service = resource[:service].to_s
+
+ case @property_flush[:ensure]
+ when :present
+ debug(self.class.run_keystore(['create', '-s'], service, 'N'))
+ @property_flush[:settings] = resource[:settings]
+ when :absent
+ File.delete(File.join([
+ configdir, "#{resource[:service]}.keystore"
+ ]))
+ return
+ end
+
+ # Note that since the property is :array_matching => :all, we have to
+ # expect that the hash is wrapped in an array.
+ if @property_flush.key?(:settings) && !(@property_flush[:settings].empty? && @property_hash.nil? && @property_hash[:settings].nil?)
+ # Flush properties that _should_ be present
+ @property_flush[:settings].each do |setting, value|
+ next if @property_hash.key?(:settings) && @property_hash[:settings].key?(setting) \
+ && @property_hash[:settings][setting] == value
+
+ args = ['add', '--force']
+ args << '--stdin' if service == 'kibana'
+ args << setting
+ debug(self.class.run_keystore(args, service, value))
+ end
+
+ # Remove properties that are no longer present
+ if resource[:purge]
+ (@property_hash[:settings].keys.sort - @property_flush[:settings].keys.sort).each do |setting|
+ debug(self.class.run_keystore(
+ ['remove', setting], service
+ ))
+ end
+ end
+ end
+
+ keystore_settings = {}
+ self.class.run_keystore(['list'], service).split("\n").each do |setting|
+ keystore_settings[setting] = service == 'kibana' ? '' : self.class.run_keystore(['show', setting], service)
+ end
+
+ # if service == 'elasticsearch' && @property_flush.key?(:password)
+ if service == 'elasticsearch'
+ # set and update keystore password if needed
+ self.class.keystore_password_management(service)
+ # unlink backup file containing keystore password (synced)
+ File.unlink(self.class.elastic_keystore_password_file_bak) if File.file?(self.class.elastic_keystore_password_file_bak)
+ end
+
+ @property_hash = {
+ name: service,
+ ensure: :present,
+ provider: resource[:name],
+ settings: keystore_settings,
+ password: self.class.elastic_keystore_password,
+ }
+ end
+
+ # settings property setter
+ #
+ # @return [Hash] settings
+ def settings=(new_settings)
+ @property_flush[:settings] = new_settings
+ end
+
+ # settings property getter
+ #
+ # @return [Hash] settings
+ def settings
+ @property_hash[:settings]
+ end
+
+ # settings property setter
+ #
+ # @return [String] password
+ def password=(new_password)
+ @property_flush[:password] = new_password
+ end
+
+ # settings property getter
+ #
+ # @return [Hash] password
+ def password
+ @property_hash[:password]
+ end
+
+ # Sets the ensure property in the @property_flush hash.
+ #
+ # @return [Symbol] :present
+ def create
+ @property_flush[:ensure] = :present
+ end
+
+ # Determine whether this resource is present on the system.
+ #
+ # @return [Boolean]
+ def exists?
+ @property_hash[:ensure] == :present
+ end
+
+ # Set flushed ensure property to absent.
+ #
+ # @return [Symbol] :absent
+ def destroy
+ @property_flush[:ensure] = :absent
+ end
+end
diff --git a/lib/puppet/type/elastic_stack_keystore.rb b/lib/puppet/type/elastic_stack_keystore.rb
new file mode 100644
index 0000000..783d26b
--- /dev/null
+++ b/lib/puppet/type/elastic_stack_keystore.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: false
+
+require 'puppet/parameter/boolean'
+
+Puppet::Type.newtype(:elastic_stack_keystore) do
+ desc 'Manages a keystore settings file (for either Elasticserach or Kibana service.'
+
+ ensurable
+
+ newparam(:service, namevar: true) do
+ desc 'Service that manages the keystore (either "elasticsearch" or "kibana").'
+ newvalues(:elasticsearch, :kibana)
+ defaultto 'elasticsearch'
+ end
+
+ newparam(:purge, boolean: true, parent: Puppet::Parameter::Boolean) do
+ desc <<-EOS
+ Whether to proactively remove settings that exist in the keystore but
+ are not present in this resource's settings.
+ EOS
+
+ defaultto false
+ end
+
+ newproperty(:password) do
+ desc 'Password to protect keystore.'
+
+ defaultto ''
+
+ def insync?(value)
+ if resource[:service].to_s == 'kibana'
+ true
+ else
+ value == @should.first
+ end
+ end
+ end
+
+ newproperty(:settings) do
+ desc 'A key/value hash of settings names and values.'
+
+ # The keystore utility can only retrieve a list of stored settings,
+ # so here we only compare the existing settings (sorted) with the
+ # desired settings' keys
+ def insync?(value)
+ if resource[:service].to_s == 'kibana'
+ if resource[:purge]
+ value.keys.sort == @should.first.keys.sort
+ else
+ (@should.first.keys.sort - value.keys.sort).empty?
+ end
+ elsif resource[:purge]
+ value == @should.first
+ elsif (@should.first.keys.sort - value.keys.sort).empty?
+ # compare the values of keys in common
+ (@should.first.values.sort - value.values.sort).empty?
+ else
+ false
+ end
+ end
+
+ def is_to_s(value)
+ debug("into is_to_s #{value}")
+ # hide sensitive data
+ value.to_h { |k, _| [k, 'xxxx'] }.inspect
+ end
+
+ def should_to_s(value)
+ debug("into should_to_s #{value}")
+ # hide sensitive data
+ value.to_h { |k, _| [k, 'xxxx'] }.inspect
+ end
+
+ def change_to_s(currentvalue, newvalue)
+ ret = ''
+
+ added_settings = newvalue.keys - currentvalue.keys
+ ret << "added: #{added_settings.join(', ')} " unless added_settings.empty?
+
+ removed_settings = currentvalue.keys - newvalue.keys
+ unless removed_settings.empty?
+ ret << if resource[:purge]
+ "removed: #{removed_settings.join(', ')} "
+ else
+ "would have removed: #{removed_settings.join(', ')}, but purging is disabled "
+ end
+ end
+
+ changed = newvalue.map { |k, v| currentvalue[k] == v ? nil : k }.compact
+ ret << "changed: #{changed.join(', ')}" unless changed.empty?
+
+ ret
+ end
+ end
+
+ autorequire(:augeas) do
+ "defaults_#{self[:name]}"
+ end
+end
diff --git a/spec/unit/type/elastic_stack_keystore_spec.rb b/spec/unit/type/elastic_stack_keystore_spec.rb
new file mode 100644
index 0000000..581763d
--- /dev/null
+++ b/spec/unit/type/elastic_stack_keystore_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'facter'
+
+describe Puppet::Type.type(:elastic_stack_keystore) do
+ let(:resource_name) { 'elasticsearch' }
+
+ describe 'validating attributes' do
+ %i[purge service].each do |param|
+ it "has a `#{param}` parameter" do
+ expect(described_class.attrtype(param)).to eq(:param)
+ end
+ end
+
+ %i[ensure password settings].each do |prop|
+ it "has a #{prop} property" do
+ expect(described_class.attrtype(prop)).to eq(:property)
+ end
+ end
+
+ describe 'namevar validation' do
+ it 'has :service as its namevar' do
+ expect(described_class.key_attributes).to eq([:service])
+ end
+ end
+ end
+
+ describe 'when validating values' do
+ describe 'ensure' do
+ it 'supports present as a value for ensure' do
+ expect do
+ described_class.new(
+ name: resource_name,
+ ensure: :present
+ )
+ end.not_to raise_error
+ end
+
+ it 'supports absent as a value for ensure' do
+ expect do
+ described_class.new(
+ name: resource_name,
+ ensure: :absent
+ )
+ end.not_to raise_error
+ end
+
+ it 'does not support other values' do
+ expect do
+ described_class.new(
+ name: resource_name,
+ ensure: :foo
+ )
+ end.to raise_error(Puppet::Error, %r{Invalid value})
+ end
+ end
+
+ describe 'settings' do
+ [{ 'node.name' => 'foo' }, ['node.name', 'node.data']].each do |setting|
+ it "accepts #{setting.class}s" do
+ expect do
+ described_class.new(
+ name: resource_name,
+ settings: setting
+ )
+ end.not_to raise_error
+ end
+ end
+
+ describe 'insync' do
+ it 'only checks lists or hash key membership' do
+ expect(described_class.new(
+ name: resource_name,
+ settings: { 'node.name' => 'foo', 'node.data' => 'true' }
+ ).property(:settings).insync?(
+ { 'node.name' => 'foo', 'node.data' => 'true' }
+ )).to be true
+ end
+
+ context 'purge' do
+ it 'defaults to not purge values' do
+ expect(described_class.new(
+ name: resource_name,
+ settings: { 'node.name' => 'foo', 'node.data' => 'true' }
+ ).property(:settings).insync?(
+ { 'node.name' => 'foo', 'node.data' => 'true', 'node.attr.rack' => 'true' }
+ )).to be true
+ end
+
+ it 'respects the purge parameter' do
+ expect(described_class.new(
+ name: resource_name,
+ settings: { 'node.name' => 'foo', 'node.data' => 'true' },
+ purge: true
+ ).property(:settings).insync?(
+ { 'node.name' => 'foo', 'node.data' => 'true', 'node.attr.rack' => 'true' }
+ )).to be false
+ end
+ end
+ end
+ end
+ end
+end