diff --git a/lib/k8s/client.rb b/lib/k8s/client.rb index a3f8d06..458c071 100644 --- a/lib/k8s/client.rb +++ b/lib/k8s/client.rb @@ -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] - def self.in_cluster_config - new(Transport.in_cluster_config) + # @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 options [Hash] default namespace for all operations + # @raise [K8s::Error::Config,Errno::ENOENT,Errno::EACCES] + # @return [K8s::Client] + 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'], auth_token: options[:auth_token] || ENV['KUBE_TOKEN']) + elsif !ENV['KUBECONFIG'].to_s.empty? + configuration = K8s::Config.from_kubeconfig_env(ENV['KUBECONFIG'], auth_token: options[:auth_token]) + 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, **options) + end end attr_reader :transport diff --git a/lib/k8s/config.rb b/lib/k8s/config.rb index 600a1c3..a771684 100644 --- a/lib/k8s/config.rb +++ b/lib/k8s/config.rb @@ -2,6 +2,7 @@ require 'dry-struct' require 'dry-types' +require 'base64' require 'yaml' module K8s @@ -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:, auth_token:, cluster_name: 'kubernetes', user: 'k8s-client', context: 'k8s-client', **options) + begin + decoded_token = Base64.strict_decode64(auth_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 || auth_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) diff --git a/lib/k8s/error.rb b/lib/k8s/error.rb index c5d092f..6fb860d 100644 --- a/lib/k8s/error.rb +++ b/lib/k8s/error.rb @@ -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 diff --git a/lib/k8s/transport.rb b/lib/k8s/transport.rb index 352aaab..392a968 100644 --- a/lib/k8s/transport.rb +++ b/lib/k8s/transport.rb @@ -92,16 +92,23 @@ def self.config(config, server: nil, **overrides) # In-cluster config within a kube pod, using the kubernetes service envs and serviceaccount secrets # + # @param options [Hash] see #new # @return [K8s::Transport] - def self.in_cluster_config - host = ENV['KUBERNETES_SERVICE_HOST'] - port = ENV['KUBERNETES_SERVICE_PORT_HTTPS'] + # @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(**options) + 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}", - ssl_verify_peer: true, - ssl_ca_file: '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt', - auth_token: File.read('/var/run/secrets/kubernetes.io/serviceaccount/token') + ssl_verify_peer: options.key?(:ssl_verify_peer) ? options.delete(:ssl_verify_peer) : true, + ssl_ca_file: options.delete(:ssl_ca_file) || '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt', + auth_token: options.delete(:auth_token) || File.read('/var/run/secrets/kubernetes.io/serviceaccount/token'), + **options ) end diff --git a/spec/k8s/config_spec.rb b/spec/k8s/config_spec.rb index 1267699..8831948 100644 --- a/spec/k8s/config_spec.rb +++ b/spec/k8s/config_spec.rb @@ -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: [