diff options
Diffstat (limited to 'vendor/gems/kubeclient/lib')
16 files changed, 1396 insertions, 0 deletions
diff --git a/vendor/gems/kubeclient/lib/kubeclient.rb b/vendor/gems/kubeclient/lib/kubeclient.rb new file mode 100644 index 00000000000..eed4872834e --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient.rb @@ -0,0 +1,35 @@ +require 'json' +require 'rest-client' + +require 'kubeclient/aws_eks_credentials' +require 'kubeclient/common' +require 'kubeclient/config' +require 'kubeclient/entity_list' +require 'kubeclient/exec_credentials' +require 'kubeclient/gcp_auth_provider' +require 'kubeclient/http_error' +require 'kubeclient/missing_kind_compatibility' +require 'kubeclient/oidc_auth_provider' +require 'kubeclient/resource' +require 'kubeclient/resource_not_found_error' +require 'kubeclient/version' +require 'kubeclient/watch_stream' + +module Kubeclient + # Kubernetes Client + class Client + include ClientMixin + def initialize( + uri, + version = 'v1', + **options + ) + initialize_client( + uri, + '/api', + version, + **options + ) + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/aws_eks_credentials.rb b/vendor/gems/kubeclient/lib/kubeclient/aws_eks_credentials.rb new file mode 100644 index 00000000000..9b54b9e06cc --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/aws_eks_credentials.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Kubeclient + # Get a bearer token to authenticate against aws eks. + class AmazonEksCredentials + class AmazonEksDependencyError < LoadError # rubocop:disable Lint/InheritException + end + + class << self + def token(credentials, eks_cluster) + begin + require 'aws-sigv4' + require 'base64' + require 'cgi' + rescue LoadError => e + raise AmazonEksDependencyError, + 'Error requiring aws gems. Kubeclient itself does not include the following ' \ + 'gems: [aws-sigv4]. To support auth-provider eks, you must ' \ + "include it in your calling application. Failed with: #{e.message}" + end + # https://github.com/aws/aws-sdk-ruby/pull/1848 + # Get a signer + # Note - sts only has ONE endpoint (not regional) so 'us-east-1' hardcoding should be OK + signer = Aws::Sigv4::Signer.new( + service: 'sts', + region: 'us-east-1', + credentials: credentials + ) + + # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/Sigv4/Signer.html#presign_url-instance_method + presigned_url_string = signer.presign_url( + http_method: 'GET', + url: 'https://sts.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15', + body: '', + credentials: credentials, + expires_in: 60, + headers: { + 'X-K8s-Aws-Id' => eks_cluster + } + ) + kube_token = 'k8s-aws-v1.' + Base64.urlsafe_encode64(presigned_url_string.to_s).sub(/=*$/, '') # rubocop:disable Metrics/LineLength + kube_token + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/common.rb b/vendor/gems/kubeclient/lib/kubeclient/common.rb new file mode 100644 index 00000000000..51087fbe888 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/common.rb @@ -0,0 +1,661 @@ +require 'json' +require 'rest-client' + +module Kubeclient + # Common methods + # this is mixed in by other gems + module ClientMixin + ENTITY_METHODS = %w[get watch delete create update patch json_patch merge_patch apply].freeze + + DEFAULT_SSL_OPTIONS = { + client_cert: nil, + client_key: nil, + ca_file: nil, + cert_store: nil, + verify_ssl: OpenSSL::SSL::VERIFY_PEER + }.freeze + + DEFAULT_AUTH_OPTIONS = { + username: nil, + password: nil, + bearer_token: nil, + bearer_token_file: nil + }.freeze + + DEFAULT_SOCKET_OPTIONS = { + socket_class: nil, + ssl_socket_class: nil + }.freeze + + DEFAULT_TIMEOUTS = { + # These do NOT affect watch, watching never times out. + open: Net::HTTP.new('127.0.0.1').open_timeout, # depends on ruby version + read: Net::HTTP.new('127.0.0.1').read_timeout + }.freeze + + DEFAULT_HTTP_PROXY_URI = nil + DEFAULT_HTTP_MAX_REDIRECTS = 10 + + SEARCH_ARGUMENTS = { + 'labelSelector' => :label_selector, + 'fieldSelector' => :field_selector, + 'resourceVersion' => :resource_version, + 'limit' => :limit, + 'continue' => :continue + }.freeze + + WATCH_ARGUMENTS = { + 'labelSelector' => :label_selector, + 'fieldSelector' => :field_selector, + 'resourceVersion' => :resource_version + }.freeze + + attr_reader :api_endpoint + attr_reader :ssl_options + attr_reader :auth_options + attr_reader :http_proxy_uri + attr_reader :http_max_redirects + attr_reader :headers + attr_reader :discovered + + def initialize_client( + uri, + path, + version, + ssl_options: DEFAULT_SSL_OPTIONS, + auth_options: DEFAULT_AUTH_OPTIONS, + socket_options: DEFAULT_SOCKET_OPTIONS, + timeouts: DEFAULT_TIMEOUTS, + http_proxy_uri: DEFAULT_HTTP_PROXY_URI, + http_max_redirects: DEFAULT_HTTP_MAX_REDIRECTS, + as: :ros + ) + validate_auth_options(auth_options) + handle_uri(uri, path) + + @entities = {} + @discovered = false + @api_version = version + @headers = {} + @ssl_options = ssl_options + @auth_options = auth_options + @socket_options = socket_options + # Allow passing partial timeouts hash, without unspecified + # @timeouts[:foo] == nil resulting in infinite timeout. + @timeouts = DEFAULT_TIMEOUTS.merge(timeouts) + @http_proxy_uri = http_proxy_uri ? http_proxy_uri.to_s : nil + @http_max_redirects = http_max_redirects + @as = as + + if auth_options[:bearer_token] + bearer_token(@auth_options[:bearer_token]) + elsif auth_options[:bearer_token_file] + validate_bearer_token_file + bearer_token(File.read(@auth_options[:bearer_token_file])) + end + end + + def method_missing(method_sym, *args, &block) + if discovery_needed?(method_sym) + discover + send(method_sym, *args, &block) + else + super + end + end + + def respond_to_missing?(method_sym, include_private = false) + if discovery_needed?(method_sym) + discover + respond_to?(method_sym, include_private) + else + super + end + end + + def discovery_needed?(method_sym) + !@discovered && ENTITY_METHODS.any? { |x| method_sym.to_s.start_with?(x) } + end + + def handle_exception + yield + rescue RestClient::Exception => e + json_error_msg = begin + JSON.parse(e.response || '') || {} + rescue JSON::ParserError + {} + end + err_message = json_error_msg['message'] || e.message + error_klass = e.http_code == 404 ? ResourceNotFoundError : HttpError + raise error_klass.new(e.http_code, err_message, e.response) + end + + def discover + load_entities + define_entity_methods + @discovered = true + end + + def self.parse_definition(kind, name) + # Kubernetes gives us 3 inputs: + # kind: "ComponentStatus", "NetworkPolicy", "Endpoints" + # name: "componentstatuses", "networkpolicies", "endpoints" + # singularName: "componentstatus" etc (usually omitted, defaults to kind.downcase) + # and want to derive singular and plural method names, with underscores: + # "network_policy" + # "network_policies" + # kind's CamelCase word boundaries determine our placement of underscores. + + if IRREGULAR_NAMES[kind] + # In a few cases, the given kind / singularName itself is still plural. + # We require a distinct singular method name, so force it. + method_names = IRREGULAR_NAMES[kind] + else + # TODO: respect singularName from discovery? + # But how? If it differs from kind.downcase, kind's word boundaries don't apply. + singular_name = kind.downcase + + if !(/[A-Z]/ =~ kind) + # Some custom resources have a fully lowercase kind - can't infer underscores. + method_names = [singular_name, name] + else + # Some plurals are not exact suffixes, e.g. NetworkPolicy -> networkpolicies. + # So don't expect full last word to match. + /^(?<prefix>(.*[A-Z]))(?<singular_suffix>[^A-Z]*)$/ =~ kind # "NetworkP", "olicy" + if name.start_with?(prefix.downcase) + plural_suffix = name[prefix.length..-1] # "olicies" + prefix_underscores = ClientMixin.underscore_entity(prefix) # "network_p" + method_names = [prefix_underscores + singular_suffix, # "network_policy" + prefix_underscores + plural_suffix] # "network_policies" + else + method_names = resolve_unconventional_method_names(name, kind, singular_name) + end + end + end + + OpenStruct.new( + entity_type: kind, + resource_name: name, + method_names: method_names + ) + end + + def self.resolve_unconventional_method_names(name, kind, singular_name) + underscored_name = name.tr('-', '_') + singular_underscores = ClientMixin.underscore_entity(kind) + if underscored_name.start_with?(singular_underscores) + [singular_underscores, underscored_name] + else + # fallback to lowercase, no separators for both names + [singular_name, underscored_name.tr('_', '')] + end + end + + def handle_uri(uri, path) + raise ArgumentError, 'Missing uri' unless uri + @api_endpoint = (uri.is_a?(URI) ? uri : URI.parse(uri)) + + # This regex will anchor at the last `/api`, `/oapi` or`/apis/:group`) part of the URL + # The whole path will be matched and if existing, the api_group will be extracted. + re = /^(?<path>.*\/o?api(?:s\/(?<apigroup>[^\/]+))?)$/mi + match = re.match(@api_endpoint.path.chomp('/')) + + if match + # Since `re` captures 2 groups, match will always have 3 elements + # If thus we have a non-nil value in match 2, this is our api_group. + @api_group = match[:apigroup].nil? ? '' : match[:apigroup] + '/' + @api_endpoint.path = match[:path] + else + # This is a fallback, for when `/api` was not provided as part of the uri + @api_group = '' + @api_endpoint.path = @api_endpoint.path.chomp('/') + path + end + end + + def build_namespace_prefix(namespace) + namespace.to_s.empty? ? '' : "namespaces/#{namespace}/" + end + + # rubocop:disable Metrics/BlockLength + def define_entity_methods + @entities.values.each do |entity| + # get all entities of a type e.g. get_nodes, get_pods, etc. + define_singleton_method("get_#{entity.method_names[1]}") do |options = {}| + get_entities(entity.entity_type, entity.resource_name, options) + end + + # watch all entities of a type e.g. watch_nodes, watch_pods, etc. + define_singleton_method("watch_#{entity.method_names[1]}") do |options = {}, &block| + # This method used to take resource_version as a param, so + # this conversion is to keep backwards compatibility + options = { resource_version: options } unless options.is_a?(Hash) + + watch_entities(entity.resource_name, options, &block) + end + + # get a single entity of a specific type by name + define_singleton_method("get_#{entity.method_names[0]}") \ + do |name, namespace = nil, opts = {}| + get_entity(entity.resource_name, name, namespace, opts) + end + + define_singleton_method("delete_#{entity.method_names[0]}") \ + do |name, namespace = nil, opts = {}| + delete_entity(entity.resource_name, name, namespace, **opts) + end + + define_singleton_method("create_#{entity.method_names[0]}") do |entity_config| + create_entity(entity.entity_type, entity.resource_name, entity_config) + end + + define_singleton_method("update_#{entity.method_names[0]}") do |entity_config| + update_entity(entity.resource_name, entity_config) + end + + define_singleton_method("patch_#{entity.method_names[0]}") \ + do |name, patch, namespace = nil| + patch_entity(entity.resource_name, name, patch, 'strategic-merge-patch', namespace) + end + + define_singleton_method("json_patch_#{entity.method_names[0]}") \ + do |name, patch, namespace = nil| + patch_entity(entity.resource_name, name, patch, 'json-patch', namespace) + end + + define_singleton_method("merge_patch_#{entity.method_names[0]}") \ + do |name, patch, namespace = nil| + patch_entity(entity.resource_name, name, patch, 'merge-patch', namespace) + end + + define_singleton_method("apply_#{entity.method_names[0]}") do |resource, opts = {}| + apply_entity(entity.resource_name, resource, **opts) + end + end + end + # rubocop:enable Metrics/BlockLength + + def self.underscore_entity(entity_name) + entity_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase + end + + def create_rest_client(path = nil) + path ||= @api_endpoint.path + options = { + ssl_ca_file: @ssl_options[:ca_file], + ssl_cert_store: @ssl_options[:cert_store], + verify_ssl: @ssl_options[:verify_ssl], + ssl_client_cert: @ssl_options[:client_cert], + ssl_client_key: @ssl_options[:client_key], + proxy: @http_proxy_uri, + max_redirects: @http_max_redirects, + user: @auth_options[:username], + password: @auth_options[:password], + open_timeout: @timeouts[:open], + read_timeout: @timeouts[:read] + } + RestClient::Resource.new(@api_endpoint.merge(path).to_s, options) + end + + def rest_client + @rest_client ||= begin + create_rest_client("#{@api_endpoint.path}/#{@api_version}") + end + end + + # Accepts the following options: + # :namespace (string) - the namespace of the entity. + # :name (string) - the name of the entity to watch. + # :label_selector (string) - a selector to restrict the list of returned objects by labels. + # :field_selector (string) - a selector to restrict the list of returned objects by fields. + # :resource_version (string) - shows changes that occur after passed version of a resource. + # :as (:raw|:ros) - defaults to :ros + # :raw - return the raw response body as a string + # :ros - return a collection of RecursiveOpenStruct objects + # Accepts an optional block, that will be called with each entity, + # otherwise returns a WatchStream + def watch_entities(resource_name, options = {}, &block) + ns = build_namespace_prefix(options[:namespace]) + + path = "watch/#{ns}#{resource_name}" + path += "/#{options[:name]}" if options[:name] + uri = @api_endpoint.merge("#{@api_endpoint.path}/#{@api_version}/#{path}") + + params = {} + WATCH_ARGUMENTS.each { |k, v| params[k] = options[v] if options[v] } + uri.query = URI.encode_www_form(params) if params.any? + + watcher = Kubeclient::Common::WatchStream.new( + uri, + http_options(uri), + formatter: ->(value) { format_response(options[:as] || @as, value) } + ) + + return_or_yield_to_watcher(watcher, &block) + end + + # Accepts the following options: + # :namespace (string) - the namespace of the entity. + # :label_selector (string) - a selector to restrict the list of returned objects by labels. + # :field_selector (string) - a selector to restrict the list of returned objects by fields. + # :limit (integer) - a maximum number of items to return in each response + # :continue (string) - a token used to retrieve the next chunk of entities + # :as (:raw|:ros) - defaults to :ros + # :raw - return the raw response body as a string + # :ros - return a collection of RecursiveOpenStruct objects + def get_entities(entity_type, resource_name, options = {}) + params = {} + SEARCH_ARGUMENTS.each { |k, v| params[k] = options[v] if options[v] } + + ns_prefix = build_namespace_prefix(options[:namespace]) + response = handle_exception do + rest_client[ns_prefix + resource_name] + .get({ 'params' => params }.merge(@headers)) + end + format_response(options[:as] || @as, response.body, entity_type) + end + + # Accepts the following options: + # :as (:raw|:ros) - defaults to :ros + # :raw - return the raw response body as a string + # :ros - return a collection of RecursiveOpenStruct objects + def get_entity(resource_name, name, namespace = nil, options = {}) + ns_prefix = build_namespace_prefix(namespace) + response = handle_exception do + rest_client[ns_prefix + resource_name + "/#{name}"] + .get(@headers) + end + format_response(options[:as] || @as, response.body) + end + + # delete_options are passed as a JSON payload in the delete request + def delete_entity(resource_name, name, namespace = nil, delete_options: {}) + delete_options_hash = delete_options.to_hash + ns_prefix = build_namespace_prefix(namespace) + payload = delete_options_hash.to_json unless delete_options_hash.empty? + response = handle_exception do + rs = rest_client[ns_prefix + resource_name + "/#{name}"] + RestClient::Request.execute( + rs.options.merge( + method: :delete, + url: rs.url, + headers: { 'Content-Type' => 'application/json' }.merge(@headers), + payload: payload + ) + ) + end + format_response(@as, response.body) + end + + def create_entity(entity_type, resource_name, entity_config) + # Duplicate the entity_config to a hash so that when we assign + # kind and apiVersion, this does not mutate original entity_config obj. + hash = entity_config.to_hash + + ns_prefix = build_namespace_prefix(hash[:metadata][:namespace]) + + # TODO: temporary solution to add "kind" and apiVersion to request + # until this issue is solved + # https://github.com/GoogleCloudPlatform/kubernetes/issues/6439 + hash[:kind] = entity_type + hash[:apiVersion] = @api_group + @api_version + response = handle_exception do + rest_client[ns_prefix + resource_name] + .post(hash.to_json, { 'Content-Type' => 'application/json' }.merge(@headers)) + end + format_response(@as, response.body) + end + + def update_entity(resource_name, entity_config) + name = entity_config[:metadata][:name] + ns_prefix = build_namespace_prefix(entity_config[:metadata][:namespace]) + response = handle_exception do + rest_client[ns_prefix + resource_name + "/#{name}"] + .put(entity_config.to_h.to_json, { 'Content-Type' => 'application/json' }.merge(@headers)) + end + format_response(@as, response.body) + end + + def patch_entity(resource_name, name, patch, strategy, namespace) + ns_prefix = build_namespace_prefix(namespace) + response = handle_exception do + rest_client[ns_prefix + resource_name + "/#{name}"] + .patch( + patch.to_json, + { 'Content-Type' => "application/#{strategy}+json" }.merge(@headers) + ) + end + format_response(@as, response.body) + end + + def apply_entity(resource_name, resource, field_manager:, force: true) + name = "#{resource[:metadata][:name]}?fieldManager=#{field_manager}&force=#{force}" + ns_prefix = build_namespace_prefix(resource[:metadata][:namespace]) + response = handle_exception do + rest_client[ns_prefix + resource_name + "/#{name}"] + .patch( + resource.to_json, + { 'Content-Type' => 'application/apply-patch+yaml' }.merge(@headers) + ) + end + format_response(@as, response.body) + end + + def all_entities(options = {}) + discover unless @discovered + @entities.values.each_with_object({}) do |entity, result_hash| + # method call for get each entities + # build hash of entity name to array of the entities + method_name = "get_#{entity.method_names[1]}" + begin + result_hash[entity.method_names[0]] = send(method_name, options) + rescue Kubeclient::HttpError + next # do not fail due to resources not supporting get + end + end + end + + def get_pod_log(pod_name, namespace, + container: nil, previous: false, + timestamps: false, since_time: nil, tail_lines: nil, limit_bytes: nil) + params = {} + params[:previous] = true if previous + params[:container] = container if container + params[:timestamps] = timestamps if timestamps + params[:sinceTime] = format_datetime(since_time) if since_time + params[:tailLines] = tail_lines if tail_lines + params[:limitBytes] = limit_bytes if limit_bytes + + ns = build_namespace_prefix(namespace) + handle_exception do + rest_client[ns + "pods/#{pod_name}/log"] + .get({ 'params' => params }.merge(@headers)) + end + end + + def watch_pod_log(pod_name, namespace, container: nil, &block) + # Adding the "follow=true" query param tells the Kubernetes API to keep + # the connection open and stream updates to the log. + params = { follow: true } + params[:container] = container if container + + ns = build_namespace_prefix(namespace) + + uri = @api_endpoint.dup + uri.path += "/#{@api_version}/#{ns}pods/#{pod_name}/log" + uri.query = URI.encode_www_form(params) + + watcher = Kubeclient::Common::WatchStream.new( + uri, http_options(uri), formatter: ->(value) { value } + ) + return_or_yield_to_watcher(watcher, &block) + end + + def proxy_url(kind, name, port, namespace = '') + discover unless @discovered + entity_name_plural = + if %w[services pods nodes].include?(kind.to_s) + kind.to_s + else + @entities[kind.to_s].resource_name + end + ns_prefix = build_namespace_prefix(namespace) + rest_client["#{ns_prefix}#{entity_name_plural}/#{name}:#{port}/proxy"].url + end + + def process_template(template) + ns_prefix = build_namespace_prefix(template[:metadata][:namespace]) + response = handle_exception do + rest_client[ns_prefix + 'processedtemplates'] + .post(template.to_h.to_json, { 'Content-Type' => 'application/json' }.merge(@headers)) + end + JSON.parse(response) + end + + def api_valid? + result = api + result.is_a?(Hash) && (result['versions'] || []).any? do |group| + @api_group.empty? ? group.include?(@api_version) : group['version'] == @api_version + end + end + + def api + response = handle_exception { create_rest_client.get(@headers) } + JSON.parse(response) + end + + private + + IRREGULAR_NAMES = { + # In a few cases, the given kind itself is still plural. + # https://github.com/kubernetes/kubernetes/issues/8115 + 'Endpoints' => %w[endpoint endpoints], + 'SecurityContextConstraints' => %w[security_context_constraint + security_context_constraints] + }.freeze + + # Format datetime according to RFC3339 + def format_datetime(value) + case value + when DateTime, Time + value.strftime('%FT%T.%9N%:z') + when String + value + else + raise ArgumentError, "unsupported type '#{value.class}' of time value '#{value}'" + end + end + + def format_response(as, body, list_type = nil) + case as + when :raw + body + when :parsed + JSON.parse(body) + when :parsed_symbolized + JSON.parse(body, symbolize_names: true) + when :ros + result = JSON.parse(body) + + if list_type + resource_version = + result.fetch('resourceVersion') do + result.fetch('metadata', {}).fetch('resourceVersion', nil) + end + + # If 'limit' was passed save the continue token + # see https://kubernetes.io/docs/reference/using-api/api-concepts/#retrieving-large-results-sets-in-chunks + continue = result.fetch('metadata', {}).fetch('continue', nil) + + # result['items'] might be nil due to https://github.com/kubernetes/kubernetes/issues/13096 + collection = result['items'].to_a.map { |item| Kubeclient::Resource.new(item) } + + Kubeclient::Common::EntityList.new(list_type, resource_version, collection, continue) + else + Kubeclient::Resource.new(result) + end + else + raise ArgumentError, "Unsupported format #{as.inspect}" + end + end + + def load_entities + @entities = {} + fetch_entities['resources'].each do |resource| + next if resource['name'].include?('/') + # Not a regular entity, special functionality covered by `process_template`. + # https://github.com/openshift/origin/issues/21668 + next if resource['kind'] == 'Template' && resource['name'] == 'processedtemplates' + resource['kind'] ||= + Kubeclient::Common::MissingKindCompatibility.resource_kind(resource['name']) + entity = ClientMixin.parse_definition(resource['kind'], resource['name']) + @entities[entity.method_names[0]] = entity if entity + end + end + + def fetch_entities + JSON.parse(handle_exception { rest_client.get(@headers) }) + end + + def bearer_token(bearer_token) + @headers ||= {} + @headers[:Authorization] = "Bearer #{bearer_token}" + end + + def validate_auth_options(opts) + # maintain backward compatibility: + opts[:username] = opts[:user] if opts[:user] + + if %i[bearer_token bearer_token_file username].count { |key| opts[key] } > 1 + raise( + ArgumentError, + 'Invalid auth options: specify only one of username/password,' \ + ' bearer_token or bearer_token_file' + ) + elsif %i[username password].count { |key| opts[key] } == 1 + raise ArgumentError, 'Basic auth requires both username & password' + end + end + + def validate_bearer_token_file + msg = "Token file #{@auth_options[:bearer_token_file]} does not exist" + raise ArgumentError, msg unless File.file?(@auth_options[:bearer_token_file]) + + msg = "Cannot read token file #{@auth_options[:bearer_token_file]}" + raise ArgumentError, msg unless File.readable?(@auth_options[:bearer_token_file]) + end + + def return_or_yield_to_watcher(watcher, &block) + return watcher unless block_given? + + begin + watcher.each(&block) + ensure + watcher.finish + end + end + + def http_options(uri) + options = { + basic_auth_user: @auth_options[:username], + basic_auth_password: @auth_options[:password], + headers: @headers, + http_proxy_uri: @http_proxy_uri, + http_max_redirects: http_max_redirects + } + + if uri.scheme == 'https' + options[:ssl] = { + ca_file: @ssl_options[:ca_file], + cert: @ssl_options[:client_cert], + cert_store: @ssl_options[:cert_store], + key: @ssl_options[:client_key], + # ruby HTTP uses verify_mode instead of verify_ssl + # http://ruby-doc.org/stdlib-1.9.3/libdoc/openssl/rdoc/OpenSSL/SSL/SSLContext.html + verify_mode: @ssl_options[:verify_ssl] + } + end + + options.merge(@socket_options) + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/config.rb b/vendor/gems/kubeclient/lib/kubeclient/config.rb new file mode 100644 index 00000000000..3598afe83fe --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/config.rb @@ -0,0 +1,202 @@ +require 'yaml' +require 'base64' +require 'pathname' + +module Kubeclient + # Kubernetes client configuration class + class Config + # Kubernetes client configuration context class + class Context + attr_reader :api_endpoint, :api_version, :ssl_options, :auth_options, :namespace + + def initialize(api_endpoint, api_version, ssl_options, auth_options, namespace) + @api_endpoint = api_endpoint + @api_version = api_version + @ssl_options = ssl_options + @auth_options = auth_options + @namespace = namespace + end + end + + # data (Hash) - Parsed kubeconfig data. + # kcfg_path (string) - Base directory for resolving relative references to external files. + # If set to nil, all external lookups & commands are disabled (even for absolute paths). + # See also the more convenient Config.read + def initialize(data, kcfg_path) + @kcfg = data + @kcfg_path = kcfg_path + raise 'Unknown kubeconfig version' if @kcfg['apiVersion'] != 'v1' + end + + # Builds Config instance by parsing given file, with lookups relative to file's directory. + def self.read(filename) + parsed = + if RUBY_VERSION >= '2.6' + YAML.safe_load(File.read(filename), permitted_classes: [Date, Time]) + else + YAML.safe_load(File.read(filename), [Date, Time]) + end + Config.new(parsed, File.dirname(filename)) + end + + def contexts + @kcfg['contexts'].map { |x| x['name'] } + end + + def context(context_name = nil) + cluster, user, namespace = fetch_context(context_name || @kcfg['current-context']) + + if user.key?('exec') + exec_opts = expand_command_option(user['exec'], 'command') + user['exec_result'] = ExecCredentials.run(exec_opts) + end + + client_cert_data = fetch_user_cert_data(user) + client_key_data = fetch_user_key_data(user) + auth_options = fetch_user_auth_options(user) + + ssl_options = {} + + ssl_options[:verify_ssl] = if cluster['insecure-skip-tls-verify'] == true + OpenSSL::SSL::VERIFY_NONE + else + OpenSSL::SSL::VERIFY_PEER + end + + if cluster_ca_data?(cluster) + cert_store = OpenSSL::X509::Store.new + populate_cert_store_from_cluster_ca_data(cluster, cert_store) + ssl_options[:cert_store] = cert_store + end + + unless client_cert_data.nil? + ssl_options[:client_cert] = OpenSSL::X509::Certificate.new(client_cert_data) + end + + unless client_key_data.nil? + ssl_options[:client_key] = OpenSSL::PKey.read(client_key_data) + end + + Context.new(cluster['server'], @kcfg['apiVersion'], ssl_options, auth_options, namespace) + end + + private + + def allow_external_lookups? + @kcfg_path != nil + end + + def ext_file_path(path) + unless allow_external_lookups? + raise "Kubeclient::Config: external lookups disabled, can't load '#{path}'" + end + Pathname(path).absolute? ? path : File.join(@kcfg_path, path) + end + + def ext_command_path(path) + unless allow_external_lookups? + raise "Kubeclient::Config: external lookups disabled, can't execute '#{path}'" + end + # Like go client https://github.com/kubernetes/kubernetes/pull/59495#discussion_r171138995, + # distinguish 3 cases: + # - absolute (e.g. /path/to/foo) + # - $PATH-based (e.g. curl) + # - relative to config file's dir (e.g. ./foo) + if Pathname(path).absolute? + path + elsif File.basename(path) == path + path + else + File.join(@kcfg_path, path) + end + end + + def fetch_context(context_name) + context = @kcfg['contexts'].detect do |x| + break x['context'] if x['name'] == context_name + end + + raise KeyError, "Unknown context #{context_name}" unless context + + cluster = @kcfg['clusters'].detect do |x| + break x['cluster'] if x['name'] == context['cluster'] + end + + raise KeyError, "Unknown cluster #{context['cluster']}" unless cluster + + user = @kcfg['users'].detect do |x| + break x['user'] if x['name'] == context['user'] + end || {} + + namespace = context['namespace'] + + [cluster, user, namespace] + end + + def cluster_ca_data?(cluster) + cluster.key?('certificate-authority') || cluster.key?('certificate-authority-data') + end + + def populate_cert_store_from_cluster_ca_data(cluster, cert_store) + if cluster.key?('certificate-authority') + cert_store.add_file(ext_file_path(cluster['certificate-authority'])) + elsif cluster.key?('certificate-authority-data') + ca_cert_data = Base64.decode64(cluster['certificate-authority-data']) + cert_store.add_cert(OpenSSL::X509::Certificate.new(ca_cert_data)) + end + end + + def fetch_user_cert_data(user) + if user.key?('client-certificate') + File.read(ext_file_path(user['client-certificate'])) + elsif user.key?('client-certificate-data') + Base64.decode64(user['client-certificate-data']) + elsif user.key?('exec_result') && user['exec_result'].key?('clientCertificateData') + user['exec_result']['clientCertificateData'] + end + end + + def fetch_user_key_data(user) + if user.key?('client-key') + File.read(ext_file_path(user['client-key'])) + elsif user.key?('client-key-data') + Base64.decode64(user['client-key-data']) + elsif user.key?('exec_result') && user['exec_result'].key?('clientKeyData') + user['exec_result']['clientKeyData'] + end + end + + def fetch_user_auth_options(user) + options = {} + if user.key?('token') + options[:bearer_token] = user['token'] + elsif user.key?('exec_result') && user['exec_result'].key?('token') + options[:bearer_token] = user['exec_result']['token'] + elsif user.key?('auth-provider') + options[:bearer_token] = fetch_token_from_provider(user['auth-provider']) + else + %w[username password].each do |attr| + options[attr.to_sym] = user[attr] if user.key?(attr) + end + end + options + end + + def fetch_token_from_provider(auth_provider) + case auth_provider['name'] + when 'gcp' + config = expand_command_option(auth_provider['config'], 'cmd-path') + Kubeclient::GCPAuthProvider.token(config) + when 'oidc' + Kubeclient::OIDCAuthProvider.token(auth_provider['config']) + end + end + + def expand_command_option(config, key) + config = config.dup + config[key] = ext_command_path(config[key]) if config[key] + + config + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/entity_list.rb b/vendor/gems/kubeclient/lib/kubeclient/entity_list.rb new file mode 100644 index 00000000000..2f734560a4b --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/entity_list.rb @@ -0,0 +1,21 @@ +require 'delegate' +module Kubeclient + module Common + # Kubernetes Entity List + class EntityList < DelegateClass(Array) + attr_reader :continue, :kind, :resourceVersion + + def initialize(kind, resource_version, list, continue = nil) + @kind = kind + # rubocop:disable Style/VariableName + @resourceVersion = resource_version + @continue = continue + super(list) + end + + def last? + continue.nil? + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/exec_credentials.rb b/vendor/gems/kubeclient/lib/kubeclient/exec_credentials.rb new file mode 100644 index 00000000000..016d48ae289 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/exec_credentials.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Kubeclient + # An exec-based client auth provide + # https://kubernetes.io/docs/reference/access-authn-authz/authentication/#configuration + # Inspired by https://github.com/kubernetes/client-go/blob/master/plugin/pkg/client/auth/exec/exec.go + class ExecCredentials + class << self + def run(opts) + require 'open3' + require 'json' + + raise ArgumentError, 'exec options are required' if opts.nil? + + cmd = opts['command'] + args = opts['args'] + env = map_env(opts['env']) + + # Validate exec options + validate_opts(opts) + + out, err, st = Open3.capture3(env, cmd, *args) + + raise "exec command failed: #{err}" unless st.success? + + creds = JSON.parse(out) + validate_credentials(opts, creds) + creds['status'] + end + + private + + def validate_opts(opts) + raise KeyError, 'exec command is required' unless opts['command'] + end + + def validate_client_credentials_status(status) + has_client_cert_data = status.key?('clientCertificateData') + has_client_key_data = status.key?('clientKeyData') + + if has_client_cert_data && !has_client_key_data + raise 'exec plugin didn\'t return client key data' + end + + if !has_client_cert_data && has_client_key_data + raise 'exec plugin didn\'t return client certificate data' + end + + has_client_cert_data && has_client_key_data + end + + def validate_credentials_status(status) + raise 'exec plugin didn\'t return a status field' if status.nil? + + has_client_credentials = validate_client_credentials_status(status) + has_token = status.key?('token') + + if has_client_credentials && has_token + raise 'exec plugin returned both token and client data' + end + + return if has_client_credentials || has_token + + raise 'exec plugin didn\'t return a token or client data' unless has_token + end + + def validate_credentials(opts, creds) + # out should have ExecCredential structure + raise 'invalid credentials' if creds.nil? + + # Verify apiVersion? + api_version = opts['apiVersion'] + if api_version && api_version != creds['apiVersion'] + raise "exec plugin is configured to use API version #{api_version}, " \ + "plugin returned version #{creds['apiVersion']}" + end + + validate_credentials_status(creds['status']) + end + + # Transform name/value pairs to hash + def map_env(env) + return {} unless env + + Hash[env.map { |e| [e['name'], e['value']] }] + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/gcp_auth_provider.rb b/vendor/gems/kubeclient/lib/kubeclient/gcp_auth_provider.rb new file mode 100644 index 00000000000..b28e54bfd88 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/gcp_auth_provider.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'kubeclient/google_application_default_credentials' +require 'kubeclient/gcp_command_credentials' + +module Kubeclient + # Handle different ways to get a bearer token for Google Cloud Platform. + class GCPAuthProvider + class << self + def token(config) + if config.key?('cmd-path') + Kubeclient::GCPCommandCredentials.token(config) + else + Kubeclient::GoogleApplicationDefaultCredentials.token + end + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/gcp_command_credentials.rb b/vendor/gems/kubeclient/lib/kubeclient/gcp_command_credentials.rb new file mode 100644 index 00000000000..9c68c1a2847 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/gcp_command_credentials.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Kubeclient + # Generates a bearer token for Google Cloud Platform. + class GCPCommandCredentials + class << self + def token(config) + require 'open3' + require 'shellwords' + require 'json' + require 'jsonpath' + + cmd = config['cmd-path'] + args = config['cmd-args'] + token_key = config['token-key'] + + out, err, st = Open3.capture3(cmd, *args.split(' ')) + + raise "exec command failed: #{err}" unless st.success? + + extract_token(out, token_key) + end + + private + + def extract_token(output, token_key) + JsonPath.on(output, token_key.gsub(/^{|}$/, '')).first + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/google_application_default_credentials.rb b/vendor/gems/kubeclient/lib/kubeclient/google_application_default_credentials.rb new file mode 100644 index 00000000000..78f99ec9f32 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/google_application_default_credentials.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Kubeclient + # Get a bearer token from the Google's application default credentials. + class GoogleApplicationDefaultCredentials + class GoogleDependencyError < LoadError # rubocop:disable Lint/InheritException + end + + class << self + def token + begin + require 'googleauth' + rescue LoadError => e + raise GoogleDependencyError, + 'Error requiring googleauth gem. Kubeclient itself does not include the ' \ + 'googleauth gem. To support auth-provider gcp, you must include it in your ' \ + "calling application. Failed with: #{e.message}" + end + + scopes = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email' + ] + + authorization = Google::Auth.get_application_default(scopes) + authorization.apply({}) + authorization.access_token + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/http_error.rb b/vendor/gems/kubeclient/lib/kubeclient/http_error.rb new file mode 100644 index 00000000000..121368c2f17 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/http_error.rb @@ -0,0 +1,25 @@ +# TODO: remove this on next major version bump +# Deprected http exception +class KubeException < StandardError + attr_reader :error_code, :message, :response + + def initialize(error_code, message, response) + @error_code = error_code + @message = message + @response = response + end + + def to_s + string = "HTTP status code #{@error_code}, #{@message}" + if @response.is_a?(RestClient::Response) && @response.request + string << " for #{@response.request.method.upcase} #{@response.request.url}" + end + string + end +end + +module Kubeclient + # Exception that is raised when a http request fails + class HttpError < KubeException + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/missing_kind_compatibility.rb b/vendor/gems/kubeclient/lib/kubeclient/missing_kind_compatibility.rb new file mode 100644 index 00000000000..ec88960a546 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/missing_kind_compatibility.rb @@ -0,0 +1,68 @@ +module Kubeclient + module Common + # Backward compatibility for old versions where kind is missing (e.g. OpenShift Enterprise 3.1) + class MissingKindCompatibility + MAPPING = { + 'bindings' => 'Binding', + 'componentstatuses' => 'ComponentStatus', + 'endpoints' => 'Endpoints', + 'events' => 'Event', + 'limitranges' => 'LimitRange', + 'namespaces' => 'Namespace', + 'nodes' => 'Node', + 'persistentvolumeclaims' => 'PersistentVolumeClaim', + 'persistentvolumes' => 'PersistentVolume', + 'pods' => 'Pod', + 'podtemplates' => 'PodTemplate', + 'replicationcontrollers' => 'ReplicationController', + 'resourcequotas' => 'ResourceQuota', + 'secrets' => 'Secret', + 'securitycontextconstraints' => 'SecurityContextConstraints', + 'serviceaccounts' => 'ServiceAccount', + 'services' => 'Service', + 'buildconfigs' => 'BuildConfig', + 'builds' => 'Build', + 'clusternetworks' => 'ClusterNetwork', + 'clusterpolicies' => 'ClusterPolicy', + 'clusterpolicybindings' => 'ClusterPolicyBinding', + 'clusterrolebindings' => 'ClusterRoleBinding', + 'clusterroles' => 'ClusterRole', + 'deploymentconfigrollbacks' => 'DeploymentConfigRollback', + 'deploymentconfigs' => 'DeploymentConfig', + 'generatedeploymentconfigs' => 'DeploymentConfig', + 'groups' => 'Group', + 'hostsubnets' => 'HostSubnet', + 'identities' => 'Identity', + 'images' => 'Image', + 'imagestreamimages' => 'ImageStreamImage', + 'imagestreammappings' => 'ImageStreamMapping', + 'imagestreams' => 'ImageStream', + 'imagestreamtags' => 'ImageStreamTag', + 'localresourceaccessreviews' => 'LocalResourceAccessReview', + 'localsubjectaccessreviews' => 'LocalSubjectAccessReview', + 'netnamespaces' => 'NetNamespace', + 'oauthaccesstokens' => 'OAuthAccessToken', + 'oauthauthorizetokens' => 'OAuthAuthorizeToken', + 'oauthclientauthorizations' => 'OAuthClientAuthorization', + 'oauthclients' => 'OAuthClient', + 'policies' => 'Policy', + 'policybindings' => 'PolicyBinding', + 'processedtemplates' => 'Template', + 'projectrequests' => 'ProjectRequest', + 'projects' => 'Project', + 'resourceaccessreviews' => 'ResourceAccessReview', + 'rolebindings' => 'RoleBinding', + 'roles' => 'Role', + 'routes' => 'Route', + 'subjectaccessreviews' => 'SubjectAccessReview', + 'templates' => 'Template', + 'useridentitymappings' => 'UserIdentityMapping', + 'users' => 'User' + }.freeze + + def self.resource_kind(name) + MAPPING[name] + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/oidc_auth_provider.rb b/vendor/gems/kubeclient/lib/kubeclient/oidc_auth_provider.rb new file mode 100644 index 00000000000..ffdfd7e2a5d --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/oidc_auth_provider.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Kubeclient + # Uses OIDC id-tokens and refreshes them if they are stale. + class OIDCAuthProvider + class OpenIDConnectDependencyError < LoadError # rubocop:disable Lint/InheritException + end + + class << self + def token(provider_config) + begin + require 'openid_connect' + rescue LoadError => e + raise OpenIDConnectDependencyError, + 'Error requiring openid_connect gem. Kubeclient itself does not include the ' \ + 'openid_connect gem. To support auth-provider oidc, you must include it in your ' \ + "calling application. Failed with: #{e.message}" + end + + issuer_url = provider_config['idp-issuer-url'] + discovery = OpenIDConnect::Discovery::Provider::Config.discover! issuer_url + + if provider_config.key? 'id-token' + return provider_config['id-token'] unless expired?(provider_config['id-token'], discovery) + end + + client = OpenIDConnect::Client.new( + identifier: provider_config['client-id'], + secret: provider_config['client-secret'], + authorization_endpoint: discovery.authorization_endpoint, + token_endpoint: discovery.token_endpoint, + userinfo_endpoint: discovery.userinfo_endpoint + ) + client.refresh_token = provider_config['refresh-token'] + client.access_token!.id_token + end + + def expired?(id_token, discovery) + decoded_token = OpenIDConnect::ResponseObject::IdToken.decode( + id_token, + discovery.jwks + ) + # If token expired or expiring within 60 seconds + Time.now.to_i + 60 > decoded_token.exp.to_i + rescue JSON::JWK::Set::KidNotFound + # Token cannot be verified: the kid it was signed with is not available for discovery + # Consider it expired and fetch a new one. + true + end + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/resource.rb b/vendor/gems/kubeclient/lib/kubeclient/resource.rb new file mode 100644 index 00000000000..08a50c3fe4f --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/resource.rb @@ -0,0 +1,11 @@ +require 'recursive_open_struct' + +module Kubeclient + # Represents all the objects returned by Kubeclient + class Resource < RecursiveOpenStruct + def initialize(hash = nil, args = {}) + args[:recurse_over_arrays] = true + super(hash, args) + end + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/resource_not_found_error.rb b/vendor/gems/kubeclient/lib/kubeclient/resource_not_found_error.rb new file mode 100644 index 00000000000..045a83642d7 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/resource_not_found_error.rb @@ -0,0 +1,4 @@ +module Kubeclient + class ResourceNotFoundError < HttpError + end +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/version.rb b/vendor/gems/kubeclient/lib/kubeclient/version.rb new file mode 100644 index 00000000000..bff50841794 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/version.rb @@ -0,0 +1,4 @@ +# Kubernetes REST-API Client +module Kubeclient + VERSION = '4.9.4-gitlab1'.freeze +end diff --git a/vendor/gems/kubeclient/lib/kubeclient/watch_stream.rb b/vendor/gems/kubeclient/lib/kubeclient/watch_stream.rb new file mode 100644 index 00000000000..ef676660d53 --- /dev/null +++ b/vendor/gems/kubeclient/lib/kubeclient/watch_stream.rb @@ -0,0 +1,97 @@ +require 'json' +require 'http' +module Kubeclient + module Common + # HTTP Stream used to watch changes on entities + class WatchStream + def initialize(uri, http_options, formatter:) + @uri = uri + @http_client = nil + @http_options = http_options + @http_options[:http_max_redirects] ||= Kubeclient::Client::DEFAULT_HTTP_MAX_REDIRECTS + @formatter = formatter + end + + def each + @finished = false + + @http_client = build_client + response = @http_client.request(:get, @uri, build_client_options) + unless response.code < 300 + raise Kubeclient::HttpError.new(response.code, response.reason, response) + end + + buffer = '' + response.body.each do |chunk| + buffer << chunk + while (line = buffer.slice!(/.+\n/)) + yield @formatter.call(line.chomp) + end + end + rescue StandardError + raise unless @finished + end + + def finish + @finished = true + @http_client.close unless @http_client.nil? + end + + private + + def max_hops + @http_options[:http_max_redirects] + 1 + end + + def follow_option + if max_hops > 1 + { max_hops: max_hops } + else + # i.e. Do not follow redirects as we have set http_max_redirects to 0 + # Setting `{ max_hops: 1 }` does not work FWIW + false + end + end + + def build_client + client = HTTP::Client.new(follow: follow_option) + + if @http_options[:basic_auth_user] && @http_options[:basic_auth_password] + client = client.basic_auth( + user: @http_options[:basic_auth_user], + pass: @http_options[:basic_auth_password] + ) + end + + client + end + + def using_proxy + proxy = @http_options[:http_proxy_uri] + return nil unless proxy + p_uri = URI.parse(proxy) + { + proxy_address: p_uri.hostname, + proxy_port: p_uri.port, + proxy_username: p_uri.user, + proxy_password: p_uri.password + } + end + + def build_client_options + client_options = { + headers: @http_options[:headers], + proxy: using_proxy + } + if @http_options[:ssl] + client_options[:ssl] = @http_options[:ssl] + socket_option = :ssl_socket_class + else + socket_option = :socket_class + end + client_options[socket_option] = @http_options[socket_option] if @http_options[socket_option] + client_options + end + end + end +end |