Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve configuration loading and add ability to merge configurations #69

Merged
merged 5 commits into from
Dec 29, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions lib/k8s/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,44 @@ def self.config(config, namespace: nil, **options)
)
end

# An K8s::Client instance from in-cluster config within a kube pod, using the kubernetes service envs and serviceaccount secrets
# @see K8s::Transport#in_cluster_config
#
# @param namespace [String] default namespace for all operations
# @return [K8s::Client]
# @raise [K8s::Error::Config,Errno::ENOENT,Errno::EACCES]
def self.in_cluster_config(namespace: nil)
new(Transport.in_cluster_config, namespace: namespace)
end

# Attempts to create a K8s::Client instance automatically using environment variables, existing configuration
# files or in cluster configuration.
#
# Look-up order:
# - KUBE_TOKEN, KUBE_CA, KUBE_SERVER environment variables
# - KUBECONFIG environment variable
# - $HOME/.kube/config file
# - In cluster configuration
#
# Will raise when no means of configuration is available
#
# @param namespace [String] default namespace for all operations
# @raise [K8s::Error::Config,Errno::ENOENT,Errno::EACCES]
# @return [K8s::Client]
def self.in_cluster_config
new(Transport.in_cluster_config)
def self.autoconfig(namespace: nil, **options)
if ENV.values_at('KUBE_TOKEN', 'KUBE_CA', 'KUBE_SERVER').none? { |v| v.nil? || v.empty? }
configuration = K8s::Config.build(server: ENV['KUBE_SERVER'], ca: ENV['KUBE_CA'], token: ENV['KUBE_TOKEN'])
elsif !ENV['KUBECONFIG'].to_s.empty?
configuration = K8s::Config.from_kubeconfig_env(ENV['KUBECONFIG'])
elsif File.exist?(File.join(Dir.home, '.kube', 'config'))
configuration = K8s::Config.load_file(File.join(Dir.home, '.kube', 'config'))
end

if configuration
config(configuration, namespace: namespace, **options)
else
in_cluster_config(namespace: namespace)
end
end

attr_reader :transport
Expand Down
92 changes: 86 additions & 6 deletions lib/k8s/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'dry-struct'
require 'dry-types'
require 'base64'
require 'yaml'

module K8s
Expand Down Expand Up @@ -90,19 +91,98 @@ class NamedContext < ConfigStruct

attribute :kind, Types::Strict::String.optional.default(nil)
attribute :apiVersion, Types::Strict::String.optional.default(nil)
attribute :preferences, Types::Strict::Hash.optional.default(nil)
attribute :clusters, Types::Strict::Array.of(NamedCluster)
attribute :users, Types::Strict::Array.of(NamedUser)
attribute :contexts, Types::Strict::Array.of(NamedContext)
attribute :current_context, Types::Strict::String
attribute :extensions, Types::Strict::Array.optional.default(nil)
attribute :preferences, Types::Strict::Hash.optional.default(proc { {} })
attribute :clusters, Types::Strict::Array.of(NamedCluster).optional.default(proc { [] })
attribute :users, Types::Strict::Array.of(NamedUser).optional.default(proc { [] })
attribute :contexts, Types::Strict::Array.of(NamedContext).optional.default(proc { [] })
attribute :current_context, Types::Strict::String.optional.default(nil)
attribute :extensions, Types::Strict::Array.optional.default(proc { [] })

# Loads a configuration from a YAML file
#
# @param path [String]
# @return [K8s::Config]
def self.load_file(path)
new(YAML.load_file(path))
end

# Loads configuration files listed in KUBE_CONFIG environment variable and
# merged using the configuration merge rules, @see K8s::Config.merge
#
# @param kubeconfig [String] by default read from ENV['KUBECONFIG']
def self.from_kubeconfig_env(kubeconfig = nil)
kubeconfig ||= ENV.fetch('KUBECONFIG', '')
return if kubeconfig.empty?

paths = kubeconfig.split(/(?!\\):/)
return load_file(paths.first) if paths.size == 1

paths.inject(load_file(paths.shift)) do |memo, other_cfg|
memo.merge(load_file(other_cfg))
end
end

# Build a minimal configuration from at least a server address, server certificate authority data and an access token.
#
# @param server [String] kubernetes server address
# @param ca [String] server certificate authority data
# @param token [String] access token (optionally base64 encoded)
# @param cluster_name [String] cluster name
# @param user [String] user name
# @param context [String] context name
# @param options [Hash] (see #initialize)
def self.build(server:, ca:, token:, cluster_name: 'kubernetes', user: 'k8s-client', context: 'k8s-client', **options)
begin
decoded_token = Base64.strict_decode64(token)
rescue ArgumentError
decoded_token = nil
end

new(
{
clusters: [{ name: cluster_name, cluster: { server: server, certificate_authority_data: ca } }],
users: [{ name: user, user: { token: decoded_token || token } }],
contexts: [{ name: context, context: { cluster: cluster_name, user: user } }],
current_context: context
}.merge(options)
)
end

# Merges configuration according to the rules specified in
# https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files
#
# @param other [Hash, K8s::Config]
# @return [K8s::Config]
def merge(other)
old_attributes = attributes
other_attributes = other.is_a?(Hash) ? other : other.attributes

old_attributes.merge!(other_attributes) do |key, old_value, new_value|
case key
when :clusters, :contexts, :users
old_value + new_value.reject do |new_mapping|
old_value.any? { |old_mapping| old_mapping[:name] == new_mapping[:name] }
end
else
case old_value
when Array
(old_value + new_value).uniq
when Hash
old_value.merge(new_value) do |_key, inner_old_value, inner_new_value|
inner_old_value.nil? ? inner_new_value : inner_old_value
end
when NilClass
new_value
else
STDERR.puts "key is #{key} old val is #{old_value.inspect} and new val is #{new_value.inspect}"
old_value
end
end
end

self.class.new(old_attributes)
end

# TODO: raise error if not found
# @return [K8s::Config::Context]
def context(name = current_context)
Expand Down
2 changes: 2 additions & 0 deletions lib/k8s/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,7 @@ def initialize(method, path, code, reason, status = nil)
# Attempt to create a ResourceClient for an unknown resource type.
# The client cannot construct the correct API URL without having the APIResource definition.
UndefinedResource = Class.new(Error)

Configuration = Class.new(Error)
end
end
9 changes: 7 additions & 2 deletions lib/k8s/transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,14 @@ def self.config(config, server: nil, **overrides)
# In-cluster config within a kube pod, using the kubernetes service envs and serviceaccount secrets
#
# @return [K8s::Transport]
# @raise [K8s::Error::Config] when the environment variables KUBERNETES_SEVICE_HOST and KUBERNETES_SERVICE_PORT_HTTPS are not set
# @raise [Errno::ENOENT,Errno::EACCES] when /var/run/secrets/kubernetes.io/serviceaccount/ca.crt or /var/run/secrets/kubernetes.io/serviceaccount/token can not be read
def self.in_cluster_config
host = ENV['KUBERNETES_SERVICE_HOST']
port = ENV['KUBERNETES_SERVICE_PORT_HTTPS']
host = ENV['KUBERNETES_SERVICE_HOST'].to_s
raise(K8s::Error::Config, "in_cluster_config failed: KUBERNETES_SERVICE_HOST environment not set") if host.empty?

port = ENV['KUBERNETES_SERVICE_PORT_HTTPS'].to_s
raise(K8s::Error::Config, "in_cluster_config failed: KUBERNETES_SERVICE_HOST environment not set") if port.empty?

new(
"https://#{host}:#{port}",
Expand Down
110 changes: 110 additions & 0 deletions spec/k8s/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,116 @@
end
end

describe '#self.from_kubeconfig_env' do
context 'KUBECONFIG points to a single file' do
it 'reads the file' do
expect(YAML).to receive(:load_file).with('kubeconfig_path').and_return(current_context: 'foo')
described_class.from_kubeconfig_env('kubeconfig_path')
end
end

context 'KUBECONFIG points to two files' do
it 'reads all of the files' do
expect(YAML).to receive(:load_file).with('kubeconfig_path').and_return(current_context: 'foo')
expect(YAML).to receive(:load_file).with('kubeconfig2_path').and_return(current_context: 'should not overwrite 1')
expect(described_class.from_kubeconfig_env('kubeconfig_path:kubeconfig2_path').current_context).to eq 'foo'
end
end
end

describe '#merge' do
subject { base.merge(other) }

context 'clusters' do
context 'base config and other config define a cluster with the same name' do
let(:base) { described_class.new(clusters: [ { name: 'kubernetes', cluster: { server: 'http://first.example.com:8080' } } ]) }
let(:other) { described_class.new(clusters: [ { name: 'kubernetes', cluster: { server: 'http://second.example.com:8080' } } ]) }

it 'does not overwrite the existing one' do
expect(subject.cluster('kubernetes').server).to eq 'http://first.example.com:8080'
expect(subject.clusters.size).to eq 1
end
end

context 'base config and other config define two clusters with differents names' do
let(:base) { described_class.new(clusters: [ { name: 'first', cluster: { server: 'http://first.example.com:8080' } } ]) }
let(:other) { described_class.new(clusters: [ { name: 'second', cluster: { server: 'http://second.example.com:8080' } } ]) }

it 'includes both clusters in the outcome' do
expect(subject.cluster('first').server).to eq 'http://first.example.com:8080'
expect(subject.cluster('second').server).to eq 'http://second.example.com:8080'
expect(subject.clusters.size).to eq 2
end
end
end

context 'contexts' do
context 'base config and other config define a context with the same name' do
let(:base) { described_class.new(contexts: [ { name: 'kubernetes', context: { cluster: 'first', user: 'user' } } ]) }
let(:other) { described_class.new(contexts: [ { name: 'kubernetes', context: { cluster: 'second', user: 'user' } } ]) }

it 'does not overwrite the existing one' do
expect(subject.context('kubernetes').cluster).to eq 'first'
expect(subject.contexts.size).to eq 1
end
end

context 'base config and other config define two contexts with differents names' do
let(:base) { described_class.new(contexts: [ { name: 'first', context: { cluster: 'first', user: 'user' } } ]) }
let(:other) { described_class.new(contexts: [ { name: 'second', context: { cluster: 'second', user: 'user' } } ]) }

it 'includes both contexts in the outcome' do
expect(subject.context('first').cluster).to eq 'first'
expect(subject.context('second').cluster).to eq 'second'
expect(subject.contexts.size).to eq 2
end
end
end

context 'users' do
context 'base config and other config define a user with the same name' do
let(:base) { described_class.new(users: [ { name: 'first', user: { token: 'first' } } ]) }
let(:other) { described_class.new(users: [ { name: 'first', user: { token: 'second' } } ]) }

it 'does not overwrite the existing one' do
expect(subject.user('first').token).to eq 'first'
expect(subject.users.size).to eq 1
end
end

context 'base config and other config define two users with differents names' do
let(:base) { described_class.new(users: [ { name: 'first', user: { token: 'first' } } ]) }
let(:other) { described_class.new(users: [ { name: 'second', user: { token: 'second' } } ]) }

it 'includes both users in the outcome' do
expect(subject.user('first').token).to eq 'first'
expect(subject.user('second').token).to eq 'second'
expect(subject.users.size).to eq 2
end
end
end

context 'current context' do
context 'base config specifies a current context' do
let(:base) { described_class.new(current_context: 'first') }
let(:other) { described_class.new(current_context: 'second') }

it 'config with a current context does not overwrite it' do
expect(subject.current_context).to eq 'first'
end
end

context 'base config has no current context' do
let(:base) { described_class.new }
let(:other) { described_class.new(current_context: 'second') }

it 'config with a current context sets it' do
expect(subject.current_context).to eq 'second'
end
end
end
end

it 'does not require optional params' do
subject = described_class.new(
clusters: [
Expand Down