Skip to content

Commit

Permalink
Add HostPool connection metadata support (#2)
Browse files Browse the repository at this point in the history
* Add support for arbitrary metadata to be attached to each
Remotus::HostPool upon initialization. This is useful when defining
additional authentication stores that may need more information than the
hostname to gather appropriate credentials.
  • Loading branch information
wheatevo authored Mar 13, 2021
1 parent 2d3c90a commit aa368c4
Show file tree
Hide file tree
Showing 10 changed files with 168 additions and 4 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ Metrics/CyclomaticComplexity:
Metrics/PerceivedComplexity:
Exclude:
- lib/remotus/ssh_connection.rb

Metrics/ParameterLists:
Max: 6
5 changes: 3 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
remotus (0.1.0)
remotus (0.2.0)
connection_pool (~> 2.2)
net-scp (~> 3.0)
net-ssh (~> 6.1)
Expand Down Expand Up @@ -88,6 +88,7 @@ GEM
yard (0.9.26)

PLATFORMS
ruby
x86_64-linux

DEPENDENCIES
Expand All @@ -100,4 +101,4 @@ DEPENDENCIES
yard (~> 0.9)

BUNDLED WITH
2.2.9
2.2.14
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ connection = Remotus.connect("remotehost.local")
# Initialize a new connection pool to remotehost.local with a defined protocol and port
connection = Remotus.connect("remotehost.local", proto: :ssh, port: 2222)

# Initialize a new connection pool to remotehost.local with a defined protocol and port and arbitrary metadata
connection = Remotus.connect("remotehost.local", proto: :ssh, port: 2222, company: "Test Corp", location: "Oslo")

# Create a credential for the new connection pool
connection.credential = Remotus::Auth::Credential.new("username", "password")

Expand Down
4 changes: 4 additions & 0 deletions lib/remotus.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require "remotus/core_ext/string"
require "remotus/version"
require "remotus/logger"
require "remotus/pool"
Expand Down Expand Up @@ -139,4 +140,7 @@ class MissingCredential < Error; end

# Failed to find credential password when executing sudo command
class MissingSudoPassword < Error; end

# Raised when an invalid metadata key is provided to a Remotus HostPool
class InvalidMetadataKey < Error; end
end
27 changes: 27 additions & 0 deletions lib/remotus/core_ext/string.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

module Remotus
# Core Ruby extensions
module CoreExt
# String extension module
module String
unless method_defined?(:to_method_name)
#
# Converts a string into a safe method name that can be used for instance variables
#
# @return [Symbol] Method name
#
def to_method_name
gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
.gsub(/([a-z])([A-Z])/, '\1_\2')
.tr(" ", "_")
.gsub(/(?:[^_a-zA-Z0-9]|^\d+)/, "")
.downcase
.to_sym
end
end
end
end
end

String.include(Remotus::CoreExt::String)
53 changes: 52 additions & 1 deletion lib/remotus/host_pool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "remotus/auth"
require "remotus/ssh_connection"
require "remotus/winrm_connection"
require "remotus/core_ext/string"
require "connection_pool"

module Remotus
Expand Down Expand Up @@ -38,10 +39,16 @@ class HostPool
# @param [Integer] timeout amount of time to wait for a connection from the pool (optional)
# @param [Integer] port port to use for the connection
# @param [Symbol] proto protocol to use for the connection (:winrm, :ssh), must be specified if port is specified
# @param [Hash] metadata metadata for this connection. Useful for providing additional information to various authentication stores
# should be specified using snake_case symbol keys. If keys are not snake_case, they will be converted.
#
def initialize(host, size: DEFAULT_POOL_SIZE, timeout: DEFAULT_EXPIRATION_SECONDS, port: nil, proto: nil)
def initialize(host, size: DEFAULT_POOL_SIZE, timeout: DEFAULT_EXPIRATION_SECONDS, port: nil, proto: nil, **metadata)
Remotus.logger.debug { "Creating host pool for #{host}" }

# Update metadata information and generate the necessary accessor methods
@metadata = metadata
update_metadata_methods

@host = host
@proto = proto || Remotus.host_type(host)

Expand Down Expand Up @@ -177,5 +184,49 @@ def credential=(credential)
credential = Remotus::Auth::Credential.from_hash(credential) unless credential.is_a?(Remotus::Auth::Credential)
Remotus::Auth.cache[host] = credential
end

#
# Gets HostPool metadata at key
#
# @param [Object] key metadata key
#
# @return [Object] metadata value
#
def [](key)
@metadata[key]
end

#
# Sets HostPool metadata value at key
#
# @param [Object] key metadata key
# @param [Object] value new metadata value
#
def []=(key, value)
@metadata[key] = value
update_metadata_methods
end

private

#
# Updates accessor methods for any defined metadata in @metadata
#
def update_metadata_methods
@metadata.each do |k, _v|
safe_key = k.to_s.to_method_name

# Do not allow metadata to be set that conflicts with base HostPool instance methods
if RESERVED_METHOD_NAMES.include?(safe_key)
raise Remotus::InvalidMetadataKey, "Cannot use reserved method name #{safe_key} for a metadata key"
end

define_singleton_method(safe_key) { @metadata[k] } unless respond_to?(safe_key)
define_singleton_method("#{safe_key}=".to_sym) { |new_value| @metadata[k] = new_value } unless respond_to?("#{safe_key}=".to_sym)
end
end

# Array of all reserved method names, must set after all methods are defined
RESERVED_METHOD_NAMES = Remotus::HostPool.instance_methods.freeze
end
end
3 changes: 3 additions & 0 deletions lib/remotus/pool.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ def host_pool_changed?(host, **options)

options.each do |k, v|
Remotus.logger.debug { "Checking if option #{k} => #{v} has changed" }

next unless pool[host].respond_to?(k.to_sym)

host_value = pool[host].send(k.to_sym)

if v != host_value
Expand Down
2 changes: 1 addition & 1 deletion lib/remotus/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

module Remotus
# Remotus gem version
VERSION = "0.1.0"
VERSION = "0.2.0"
end
22 changes: 22 additions & 0 deletions spec/remotus/core_ext/string_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

RSpec.describe Remotus::CoreExt::String do
let(:data) do
{
"TestThisThing" => :test_this_thing,
"123Invalid_start" => :invalid_start,
" many spaces" => :___many____spaces,
"OSThing" => :os_thing,
"!@#$%^&*&()-=invalid_starting_characters" => :invalid_starting_characters,
"Invalid!@#$%^&*()-=_Interior_chars" => :invalid_interior_chars
}
end

describe "#to_method_name" do
it "converts the string to a safe method name that can be used for instance variables" do
data.each do |str, out|
expect(str.to_method_name).to eq(out)
end
end
end
end
50 changes: 50 additions & 0 deletions spec/remotus/host_pool_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,38 @@
expect(host_pool.size).to eq(5)
end
end

context "when metadata are set" do
let(:meta) do
{
data1: 123,
"Very odd string key" => 555,
"%&*(&!%another_inValid key with strange things" => "test",
{ k: :v } => "oof"
}
end

it "generates dynamic methods for each metadata entry" do
host_pool = described_class.new(host, proto: :ssh, **meta)
meta.each do |k, v|
expect(host_pool.send(k.to_s.to_method_name)).to eq(v)
expect { host_pool.send("#{k.to_s.to_method_name}=", "new_value") }.to_not raise_error
expect(host_pool.data1).to eq("new_value")
end
end
end

context "when metadata are set to a conflicting key" do
it "raises an exception" do
described_class.instance_methods.each do |k|
# Skip instance methods that will not conflict or input that will be interpreted as a keyword arg
next if k != k.to_s.to_method_name || %i[size timeout port proto].include?(k)

bad_meta = { k => "value" }
expect { described_class.new(host, port: 22, proto: :ssh, **bad_meta) }.to raise_error(Remotus::InvalidMetadataKey)
end
end
end
end

describe "#expiration_time" do
Expand Down Expand Up @@ -194,4 +226,22 @@
expect(subject.credential).to eq(cred)
end
end

describe "#[]" do
it "returns metadata by key" do
expect(subject["not a key"]).to eq(nil)

subject["valid key"] = 123
expect(subject["valid key"]).to eq(123)
expect(subject.valid_key).to eq(123)
end
end

describe "#[]=" do
it "sets metadata by key" do
subject["new key"] = 123
expect(subject["new key"]).to eq(123)
expect(subject.new_key).to eq(123)
end
end
end

0 comments on commit aa368c4

Please sign in to comment.