diff --git a/README.md b/README.md index c4997913..9d36a9f9 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,22 @@ ACLs if the anonymous token doesn't permit ACL changes (which is likely). The api token may be the master token, another management token, or any client token with sufficient privileges. +## Prepared Queries + +```puppet +consul_prepared_query { 'consul': + ensure => 'present', + service_name => 'consul', + service_failover_n => 1, + service_failover_dcs => [ 'dc1', 'dc2' ], + service_only_passing => true, + service_tags => [ 'tag1', 'tag2' ], + ttl => 10, +} +``` + +This provider currently only has support for basic prepared queries (not templated queries). + ## Limitations Depends on the JSON gem, or a modern ruby. (Ruby 1.8.7 is not officially supported) diff --git a/lib/puppet/provider/consul_prepared_query/default.rb b/lib/puppet/provider/consul_prepared_query/default.rb new file mode 100644 index 00000000..566b8925 --- /dev/null +++ b/lib/puppet/provider/consul_prepared_query/default.rb @@ -0,0 +1,203 @@ +require 'json' +require 'net/http' +require 'uri' +Puppet::Type.type(:consul_prepared_query).provide( + :default +) do + mk_resource_methods + + def self.prefetch(resources) + resources.each do |name, resource| + Puppet.debug("prefetching for #{name}") + port = resource[:port] + hostname = resource[:hostname] + protocol = resource[:protocol] + token = resource[:acl_api_token] + tries = resource[:api_tries] + + found_prepared_queries = list_resources(token, port, hostname, protocol, tries).select do |prepared_query| + prepared_query[:name] == name + end + + found_prepared_query = found_prepared_queries.first || nil + if found_prepared_query + Puppet.debug("found #{found_prepared_query}") + resource.provider = new(found_prepared_query) + else + Puppet.debug("found none #{name}") + resource.provider = new({:ensure => :absent}) + end + end + end + + def self.list_resources(acl_api_token, port, hostname, protocol, tries) + if @prepared_queries + return @prepared_queries + end + + # this might be configurable by searching /etc/consul.d + # but would break for anyone using nonstandard paths + uri = URI("#{protocol}://#{hostname}:#{port}/v1/query") + http = Net::HTTP.new(uri.host, uri.port) + + path=uri.request_uri + "?token=#{acl_api_token}" + req = Net::HTTP::Get.new(path) + res = nil + + # retry Consul API query for ACLs, in case Consul has just started + (1..tries).each do |i| + unless i == 1 + Puppet.debug("retrying Consul API query in #{i} seconds") + sleep i + end + res = http.request(req) + break if res.code == '200' + end + + if res.code == '200' + prepared_queries = JSON.parse(res.body) + else + Puppet.warning("Cannot retrieve prepared_queries: invalid return code #{res.code} uri: #{path} body: #{req.body}") + return {} + end + + nprepared_queries = prepared_queries.collect do |prepared_query| + { + :name => prepared_query["Name"], + :id => prepared_query["ID"], + :session => prepared_query["Session"], + :token => prepared_query["Token"], + :service => prepared_query["Service"], + :dns => prepared_query["DNS"], + :ensure => :present, + } + end + + @prepared_queries = nprepared_queries + nprepared_queries + end + + def get_path(id) + idstr = id ? "/#{id}" : '' + uri = URI("#{@resource[:protocol]}://#{@resource[:hostname]}:#{@resource[:port]}/v1/query#{idstr}") + http = Net::HTTP.new(uri.host, uri.port) + acl_api_token = @resource[:acl_api_token] + return uri.request_uri + "?token=#{acl_api_token}", http + end + + def create_prepared_query(body) + path, http = get_path(false) + req = Net::HTTP::Post.new(path) + if body + req.body = body.to_json + end + res = http.request(req) + if res.code != '200' + raise(Puppet::Error,"Session #{name} create: invalid return code #{res.code} uri: #{path} body: #{req.body}") + end + end + + def update_prepared_query(id, body) + path, http = get_path(id) + req = Net::HTTP::Put.new(path) + if body + body[:id] = id + req.body = body.to_json + end + res = http.request(req) + if res.code != '200' + raise(Puppet::Error,"Session #{name} update: invalid return code #{res.code} uri: #{path} body: #{req.body}") + end + end + + def delete_prepared_query(id) + path, http = get_path(id) + req = Net::HTTP::Delete.new(path) + res = http.request(req) + if res.code != '200' + raise(Puppet::Error,"Session #{name} delete: invalid return code #{res.code} uri: #{path} body: #{req.body}") + end + end + + def get_resource(name, port, hostname, protocol, tries) + acl_api_token = @resource[:acl_api_token] + resources = self.class.list_resources(acl_api_token, port, hostname, protocol, tries).select do |res| + res[:name] == name + end + # if the user creates multiple with the same name this will do odd things + resources.first || nil + end + + def initialize(value={}) + super(value) + @property_flush = {} + end + + def exists? + @property_hash[:ensure] == :present + end + + def create + @property_flush[:ensure] = :present + end + + def destroy + @property_flush[:ensure] = :absent + end + + def flush + name = @resource[:name] + token = @resource[:token] + service_name = @resource[:service_name] + service_failover_n = @resource[:service_failover_n] + service_failover_dcs = @resource[:service_failover_dcs] + service_only_passing = @resource[:service_only_passing] + service_tags = @resource[:service_tags] + ttl = @resource[:ttl] + port = @resource[:port] + hostname = @resource[:hostname] + protocol = @resource[:protocol] + tries = @resource[:api_tries] + prepared_query = self.get_resource(name, port, hostname, protocol, tries) + if prepared_query + id = prepared_query[:id] + if @property_flush[:ensure] == :absent + delete_prepared_query(id) + return + end + update_prepared_query(id, { + "Name" => "#{name}", + "Token" => "#{token}", + "Service" => { + "Service" => "#{service_name}", + "Failover" => { + "NearestN" => service_failover_n, + "Datacenters" => service_failover_dcs, + }, + "OnlyPassing" => service_only_passing, + "Tags" => service_tags, + }, + "DNS" => { + "TTL" => "#{ttl}s" + }}) + + else + create_prepared_query({ + "Name" => "#{name}", + "Token" => "#{token}", + "Service" => { + "Service" => "#{service_name}", + "Failover" => { + "NearestN" => service_failover_n, + "Datacenters" => service_failover_dcs, + }, + "OnlyPassing" => service_only_passing, + "Tags" => service_tags, + }, + "DNS" => { + "TTL" => "#{ttl}s" + }}) + end + @property_hash.clear + end +end diff --git a/lib/puppet/type/consul_prepared_query.rb b/lib/puppet/type/consul_prepared_query.rb new file mode 100644 index 00000000..67469aa9 --- /dev/null +++ b/lib/puppet/type/consul_prepared_query.rb @@ -0,0 +1,111 @@ +Puppet::Type.newtype(:consul_prepared_query) do + + desc <<-'EOD' + Manage a consul prepared query. + EOD + ensurable + + newparam(:name, :namevar => true) do + desc 'Name of the prepared query' + validate do |value| + raise ArgumentError, "Prepared query name must be a string" if not value.is_a?(String) + end + end + + newparam(:token) do + desc 'The prepared query token' + validate do |value| + raise ArgumentError, "The prepared query token must be a string" if not value.is_a?(String) + end + defaultto '' + end + + newparam(:acl_api_token) do + desc 'Token for accessing the ACL API' + validate do |value| + raise ArgumentError, "ACL API token must be a string" if not value.is_a?(String) + end + defaultto '' + end + + newparam(:service_name) do + desc 'Service name for the prepared query' + validate do |value| + raise ArgumentError, "Prepared query service definition must be a string" if not value.is_a?(String) + end + end + + newparam(:service_failover_n) do + desc 'Failover to the nearest datacenters' + defaultto 0 + validate do |value| + raise ArgumentError, "Nearest failover datacenters must be an integer" if not value.is_a?(Integer) + end + end + + newparam(:service_failover_dcs) do + desc 'List of datacenters to forward queries to if no health services found locally' + defaultto [] + validate do |value| + raise ArgumentError, "Nearest failover datacenters must be an array" if not value.is_a?(Array) + end + end + + newparam(:service_only_passing) do + desc 'Only return services in the passing state' + defaultto false + validate do |value| + raise ArgumentError, "Use only passing state must be a boolean" if not !!value == value + end + end + + newparam(:service_tags) do + desc 'List of tags to filter the query with' + defaultto [] + validate do |value| + raise ArgumentError, "Query tag filters must be an array" if not value.is_a?(Array) + end + end + + newparam(:ttl) do + desc 'TTL for the DNS lookup' + defaultto 0 + validate do |value| + raise ArgumentError, "Prepared query TTL must be an integer" if not value.is_a?(Integer) + end + end + + newproperty(:id) do + desc 'ID of prepared query' + end + + newproperty(:protocol) do + desc 'consul protocol' + newvalues('http', 'https') + defaultto 'http' + end + + newparam(:port) do + desc 'consul port' + defaultto 8500 + validate do |value| + raise ArgumentError, "The port number must be a number" if not value.is_a?(Integer) + end + end + + newparam(:hostname) do + desc 'consul hostname' + validate do |value| + raise ArgumentError, "The hostname must be a string" if not value.is_a?(String) + end + defaultto 'localhost' + end + + newparam(:api_tries) do + desc 'number of tries when contacting the Consul REST API' + defaultto 3 + validate do |value| + raise ArgumentError, "Number of API tries must be a number" if not value.is_a?(Integer) + end + end +end diff --git a/spec/unit/puppet/type/consul_prepared_query_spec.rb b/spec/unit/puppet/type/consul_prepared_query_spec.rb new file mode 100644 index 00000000..b7904a9b --- /dev/null +++ b/spec/unit/puppet/type/consul_prepared_query_spec.rb @@ -0,0 +1,31 @@ +describe Puppet::Type.type(:consul_prepared_query) do + + it 'should fail if no name is provided' do + expect do + Puppet::Type.type(:consul_prepared_query).new(:type => 'client') + end.to raise_error(Puppet::Error, /Title or name must be provided/) + end + + context 'with query parameters provided' do + before :each do + @acl = Puppet::Type.type(:consul_prepared_query).new( + :name => 'testing', + :token => '', + :service_name => 'testing', + :service_failover_n => 1, + :service_failover_dcs => [ 'dc1', 'dc2' ], + :service_tags => [ 'tag1', 'tag2' ], + :service_only_passing => true, + :ttl => 10 + ) + end + + it 'should default to localhost' do + expect(@acl[:hostname]).to eq('localhost') + end + + it 'should default to http' do + expect(@acl[:protocol]).to eq(:http) + end + end +end