class Kontena::Client

Constants

ACCEPT
ACCEPT_ENCODING
AUTHORIZATION
CLIENT_ID
CLIENT_SECRET
CONTENT_JSON
CONTENT_TYPE
CONTENT_URLENCODED
GZIP
JSON_REGEX
X_KONTENA_VERSION

Attributes

api_url[R]
default_headers[RW]
host[R]
http_client[R]
last_response[R]
logger[R]
options[R]
path_prefix[RW]
token[R]

Public Class Methods

new(api_url, token = nil, options = {}) click to toggle source

Initialize api client

@param [String] api_url @param [Kontena::Cli::Config::Token,Hash] access_token @param [Hash] options

# File lib/kontena/client.rb, line 32
def initialize(api_url, token = nil, options = {})
  require 'json'
  require 'excon'
  require 'uri'
  require 'base64'
  require 'socket'
  require 'openssl'
  require 'uri'
  require 'time'
  require 'kontena/errors'
  require 'kontena/cli/version'
  require 'kontena/cli/config'

  @api_url, @token, @options = api_url, token, options
  uri = URI.parse(@api_url)
  @host = uri.host

  @logger = Kontena.logger

  @options[:default_headers] ||= {}

  excon_opts = {
    omit_default_port: true,
    connect_timeout: ENV["EXCON_CONNECT_TIMEOUT"] ? ENV["EXCON_CONNECT_TIMEOUT"].to_i : 10,
    read_timeout:    ENV["EXCON_READ_TIMEOUT"]    ? ENV["EXCON_READ_TIMEOUT"].to_i    : 30,
    write_timeout:   ENV["EXCON_WRITE_TIMEOUT"]   ? ENV["EXCON_WRITE_TIMEOUT"].to_i   : 10,
    ssl_verify_peer: ignore_ssl_errors? ? false : true,
    middlewares:     Excon.defaults[:middlewares] + [Excon::Middleware::Decompress]
  }
  if Kontena.debug?
    require 'kontena/debug_instrumentor'
    excon_opts[:instrumentor] = Kontena::DebugInstrumentor
  end
  excon_opts[:ssl_ca_file] = @options[:ssl_cert_path]
  excon_opts[:ssl_verify_peer_host] = @options[:ssl_subject_cn]

  debug { "Excon opts: #{excon_opts.inspect}" }

  @http_client = Excon.new(api_url, excon_opts)

  @default_headers = {
    ACCEPT => CONTENT_JSON,
    CONTENT_TYPE => CONTENT_JSON,
    'User-Agent' => "kontena-cli/#{Kontena::Cli::VERSION}"
  }.merge(options[:default_headers])

  if token
    if token.kind_of?(String)
      @token = { 'access_token' => token }
    else
      @token = token
    end
  end

  @api_url = api_url
  @path_prefix = options[:prefix] || '/v1/'
end

Public Instance Methods

authentication_ok?(token_verify_path) click to toggle source

Requests path supplied as argument and returns true if the request was a success. For checking if the current authentication is valid.

@param [String] token_verify_path a path that requires authentication @return [Boolean]

# File lib/kontena/client.rb, line 130
def authentication_ok?(token_verify_path)
  return false unless token
  return false unless token['access_token']
  return false unless token_verify_path

  final_path = token_verify_path.gsub(/\:access\_token/, token['access_token'])
  debug { "Requesting user info from #{final_path}" }
  request(path: final_path)
  true
rescue => ex
  error { "Authentication verification exception" }
  error { ex }
  false
end
basic_auth_header(user = nil, pass = nil) click to toggle source

Generates a header hash for HTTP basic authentication. Defaults to using client_id and client_secret as user/pass

@param [String] username @param [String] password @return [Hash] auth_header_hash

# File lib/kontena/client.rb, line 104
def basic_auth_header(user = nil, pass = nil)
  user ||= client_id
  pass ||= client_secret
  {
    AUTHORIZATION =>
      "Basic #{Base64.encode64([user, pass].join(':')).gsub(/[\r\n]/, '')}"
  }
end
bearer_authorization_header() click to toggle source

Generates a bearer token authentication header hash if a token object is available. Otherwise returns an empty hash.

@return [Hash] authentication_header

# File lib/kontena/client.rb, line 117
def bearer_authorization_header
  if token && token['access_token']
    {AUTHORIZATION => "Bearer #{token['access_token']}"}
  else
    {}
  end
end
client_id() click to toggle source

OAuth2 client_id from ENV KONTENA_CLIENT_ID or client CLIENT_ID constant

@return [String]

# File lib/kontena/client.rb, line 182
def client_id
  ENV['KONTENA_CLIENT_ID'] || CLIENT_ID
end
client_secret() click to toggle source

OAuth2 client_secret from ENV KONTENA_CLIENT_SECRET or client CLIENT_SECRET constant

@return [String]

# File lib/kontena/client.rb, line 189
def client_secret
  ENV['KONTENA_CLIENT_SECRET'] || CLIENT_SECRET
end
debug(&block) click to toggle source
# File lib/kontena/client.rb, line 90
def debug(&block)
  logger.debug("CLIENT", &block)
end
delete(path, body = nil, params = {}, headers = {}, auth = true) click to toggle source

Delete request

@param [String] path @param [Hash,String] body @param [Hash] params @param [Hash] headers @return [Hash]

# File lib/kontena/client.rb, line 243
def delete(path, body = nil, params = {}, headers = {}, auth = true)
  request(http_method: :delete, path: path, body: body, query: params, headers: headers, auth: auth)
end
error(&block) click to toggle source
# File lib/kontena/client.rb, line 94
def error(&block)
  logger.error("CLIENT", &block)
end
exchange_code(code) click to toggle source

Calls the code exchange endpoint in token's config to exchange an authorization_code to a access_token

# File lib/kontena/client.rb, line 147
def exchange_code(code)
  return nil unless token_account
  return nil unless token_account['token_endpoint']

  response = request(
    http_method: token_account['token_method'].downcase.to_sym,
    path: token_account['token_endpoint'],
    headers: { CONTENT_TYPE => token_account['token_post_content_type'] },
    body: {
      'grant_type' => 'authorization_code',
      'code' => code,
      'client_id' => Kontena::Client::CLIENT_ID,
      'client_secret' => Kontena::Client::CLIENT_SECRET
    },
    expects: [200,201],
    auth: false
  )
  response['expires_at'] ||= in_to_at(response['expires_in'])
  response
end
get(path, params = nil, headers = {}, auth = true) click to toggle source

Get request

@param [String] path @param [Hash,NilClass] params @param [Hash] headers @return [Hash]

# File lib/kontena/client.rb, line 199
def get(path, params = nil, headers = {}, auth = true)
  request(path: path, query: params, headers: headers, auth: auth)
end
get_stream(path, response_block, params = nil, headers = {}, auth = true) click to toggle source

Get stream request

@param [String] path @param [Lambda] response_block @param [Hash,NilClass] params @param [Hash] headers

# File lib/kontena/client.rb, line 253
def get_stream(path, response_block, params = nil, headers = {}, auth = true)
  request(path: path, query: params, headers: headers, response_block: response_block, auth: auth, gzip: false)
end
patch(path, obj, params = {}, headers = {}, auth = true) click to toggle source

Patch request

@param [String] path @param [Object] obj @param [Hash] params @param [Hash] headers @return [Hash]

# File lib/kontena/client.rb, line 232
def patch(path, obj, params = {}, headers = {}, auth = true)
  request(http_method: :patch, path: path, body: obj, query: params, headers: headers, auth: auth)
end
post(path, obj, params = {}, headers = {}, auth = true) click to toggle source

Post request

@param [String] path @param [Object] obj @param [Hash] params @param [Hash] headers @return [Hash]

# File lib/kontena/client.rb, line 210
def post(path, obj, params = {}, headers = {}, auth = true)
  request(http_method: :post, path: path, body: obj, query: params, headers: headers, auth: auth)
end
put(path, obj, params = {}, headers = {}, auth = true) click to toggle source

Put request

@param [String] path @param [Object] obj @param [Hash] params @param [Hash] headers @return [Hash]

# File lib/kontena/client.rb, line 221
def put(path, obj, params = {}, headers = {}, auth = true)
  request(http_method: :put, path: path, body: obj, query: params, headers: headers, auth: auth)
end
refresh_request_params() click to toggle source

Build a token refresh request param hash

@return [Hash]

# File lib/kontena/client.rb, line 356
def refresh_request_params
  {
    refresh_token: token['refresh_token'],
    grant_type: 'refresh_token',
    client_id: client_id,
    client_secret: client_secret
  }
end
refresh_token() click to toggle source

Perform refresh token request to auth provider. Updates the client's Token object and writes changes to configuration.

@param [Boolean] use_basic_auth? When true, use basic auth authentication header @return [Boolean] success?

# File lib/kontena/client.rb, line 387
def refresh_token
  debug { "Performing token refresh" }
  return false if token.nil?
  return false if token['refresh_token'].nil?
  uri = URI.parse(token_account['token_endpoint'])
  endpoint_data = { path: uri.path }
  endpoint_data[:host] = uri.host if uri.host
  endpoint_data[:port] = uri.port if uri.port

  debug { "Token refresh endpoint: #{endpoint_data.inspect}" }

  return false unless endpoint_data[:path]

  response = request(
    {
      http_method: token_account['token_method'].downcase.to_sym,
      body: refresh_request_params,
      headers: {
        CONTENT_TYPE => token_account['token_post_content_type']
      }.merge(
        token_account['code_requires_basic_auth'] ? basic_auth_header : {}
      ),
      expects: [200, 201, 400, 401, 403],
      auth: false
    }.merge(endpoint_data)
  )

  if response && response['access_token']
    debug { "Got response to refresh request" }
    token['access_token']  = response['access_token']
    token['refresh_token'] = response['refresh_token']
    token['expires_at'] = in_to_at(response['expires_in'])
    token.config.write if token.respond_to?(:config)
    true
  else
    debug { "Got null or bad response to refresh request: #{last_response.inspect}" }
    false
  end
rescue => ex
  error { "Access token refresh exception" }
  error { ex }
  false
end
request(http_method: :get, path:'/', body: nil, query: {}, headers: {}, response_block: nil, expects: [200, 201, 204], host: nil, port: nil, auth: true, gzip: true) click to toggle source

Perform a HTTP request. Will try to refresh the access token and retry if it's expired or if the server responds with HTTP 401.

Automatically parses a JSON response into a hash.

After the request has been performed, the response can be inspected using client.last_response.

@param http_method [Symbol] :get, :post, etc @param path [String] if it starts with / then prefix won't be used. @param body [Hash, String] will be encoded using encode_body @param query [Hash] url query parameters @param headers [Hash] extra headers for request. @param response_block [Proc] for streaming requests, must respond to call @param expects [Array] raises unless response status code matches this list. @param auth [Boolean] use token authentication default = true @return [Hash, String] response parsed response object

# File lib/kontena/client.rb, line 285
def request(http_method: :get, path:'/', body: nil, query: {}, headers: {}, response_block: nil, expects: [200, 201, 204], host: nil, port: nil, auth: true, gzip: true)

  retried ||= false

  if auth && token_expired?
    raise Excon::Error::Unauthorized, "Token expired or not valid, you need to login again, use: kontena #{token_is_for_master? ? "master" : "cloud"} login"
  end

  request_headers = request_headers(headers, auth: auth, gzip: gzip)

  if body.nil?
    body_content = ''
    request_headers.delete(CONTENT_TYPE)
  else
    body_content =  encode_body(body, request_headers[CONTENT_TYPE])
    request_headers.merge!('Content-Length' => body_content.bytesize)
  end

  uri = URI.parse(path)
  host_options = {}

  if uri.host
    host_options[:host]   = uri.host
    host_options[:port]   = uri.port
    host_options[:scheme] = uri.scheme
    path                  = uri.request_uri
  else
    host_options[:host] = host if host
    host_options[:port] = port if port
  end

  request_options = {
      method: http_method,
      expects: Array(expects),
      path: path_with_prefix(path),
      headers: request_headers,
      body: body_content,
      query: query
  }.merge(host_options)

  request_options.merge!(response_block: response_block) if response_block

  # Store the response into client.last_response
  @last_response = http_client.request(request_options)

  parse_response(@last_response)
rescue Excon::Error::Unauthorized
  if token
    debug { 'Server reports access token expired' }

    if retried || !token || !token['refresh_token']
      raise Kontena::Errors::StandardError.new(401, 'The access token has expired and needs to be refreshed')
    end

    retried = true
    retry if refresh_token
  end
  raise Kontena::Errors::StandardError.new(401, 'Unauthorized')
rescue Excon::Error::HTTPStatus => error
  if error.response.headers['Content-Encoding'] == 'gzip'
    error.response.body = Zlib::GzipReader.new(StringIO.new(error.response.body)).read
  end

  debug { "Request #{error.request[:method].upcase} #{error.request[:path]}: #{error.response.status} #{error.response.reason_phrase}: #{error.response.body}" }

  handle_error_response(error.response)
end
server_version() click to toggle source

Return server version from a Kontena master by requesting '/'

@return [String] version_string

# File lib/kontena/client.rb, line 171
def server_version
  request(auth: false, expects: 200)['version']
rescue => ex
  error { "Server version exception" }
  error { ex }
  nil
end
token_account() click to toggle source

Accessor to token's account settings

# File lib/kontena/client.rb, line 366
def token_account
  return {} unless token
  if token.respond_to?(:account)
    token.account
  elsif token.kind_of?(Hash) && token['account'].kind_of?(String)
    config.find_account(token['account'])
  else
    {}
  end
rescue => ex
  error { "Access token refresh exception" }
  error { ex }
  false
end
token_expired?() click to toggle source
# File lib/kontena/client.rb, line 257
def token_expired?
  return false unless token
  if token.respond_to?(:expired?)
    token.expired?
  elsif token['expires_at'].to_i > 0
    token['expires_at'].to_i < Time.now.utc.to_i
  else
    false
  end
end

Private Instance Methods

add_version_warning(server_version) click to toggle source
# File lib/kontena/client.rb, line 506
def add_version_warning(server_version)
  at_exit do
    warn Kontena.pastel.yellow("Warning: Server version is #{server_version}. You are using CLI version #{Kontena::Cli::VERSION}.")
  end
end
check_version_and_warn(server_version) click to toggle source
# File lib/kontena/client.rb, line 496
def check_version_and_warn(server_version)
  return nil if $VERSION_WARNING_ADDED
  return nil unless server_version.to_s =~ /^\d+\.\d+\.\d+/

  unless server_version[/^(\d+\.\d+)/, 1] == Kontena::Cli::VERSION[/^(\d+\.\d+)/, 1] # Just compare x.y
    add_version_warning(server_version)
    $VERSION_WARNING_ADDED = true
  end
end
dump_json(obj) click to toggle source

Dump json

@param [Object] obj @return [String]

# File lib/kontena/client.rb, line 528
def dump_json(obj)
  JSON.dump(obj)
end
encode_body(body, content_type) click to toggle source

Encode body based on content type.

@param [Object] body @param [String] content_type @return [String] encoded_content

# File lib/kontena/client.rb, line 470
def encode_body(body, content_type)
  if content_type =~ JSON_REGEX # vnd.api+json should pass as json
    dump_json(body)
  elsif content_type == CONTENT_URLENCODED && body.kind_of?(Hash)
    URI.encode_www_form(body)
  else
    body
  end
end
handle_error_response(response) click to toggle source

@param [Excon::Response] response

# File lib/kontena/client.rb, line 538
def handle_error_response(response)
  data = parse_response(response)

  request_path = " (#{response.path})"

  if data.is_a?(Hash) && data.has_key?('error') && data['error'].is_a?(Hash)
    raise Kontena::Errors::StandardErrorHash.new(response.status, response.reason_phrase, data['error'])
  elsif data.is_a?(Hash) && data.has_key?('errors') && data['errors'].is_a?(Array) && data['errors'].all? { |e| e.is_a?(Hash) }
    error_with_status = data['errors'].find { |error| error.key?('status') }
    if error_with_status
      status = error_with_status['status']
    else
      status = response.status
    end
    raise Kontena::Errors::StandardErrorHash.new(status, response.reason_phrase, data)
  elsif data.is_a?(Hash) && data.has_key?('error')
    raise Kontena::Errors::StandardError.new(response.status, data['error'] + request_path)
  elsif data.is_a?(String) && !data.empty?
    raise Kontena::Errors::StandardError.new(response.status, data + request_path)
  else
    raise Kontena::Errors::StandardError.new(response.status, response.reason_phrase + request_path)
  end
end
ignore_ssl_errors?() click to toggle source

@return [Boolean]

# File lib/kontena/client.rb, line 533
def ignore_ssl_errors?
  ENV['SSL_IGNORE_ERRORS'] == 'true' || options[:ignore_ssl_errors]
end
in_to_at(expires_in) click to toggle source

Convert expires_in into expires_at

@param [Fixnum] seconds_till_expiration @return [Fixnum] expires_at_unix_timestamp

# File lib/kontena/client.rb, line 566
def in_to_at(expires_in)
  if expires_in.to_i < 1
    0
  else
    Time.now.utc.to_i + expires_in.to_i
  end
end
parse_json(response) click to toggle source

Parse json

@param response [Excon::Response] @return [Hash,Object,NilClass]

# File lib/kontena/client.rb, line 516
def parse_json(response)
  return nil if response.body.empty?
  
  JSON.parse(response.body)
rescue => ex
  raise Kontena::Errors::StandardError.new(520, "Invalid response JSON from server for #{response.path}: #{ex.class.name}: #{ex.message}")
end
parse_response(response) click to toggle source

Parse response. If the respons is JSON, returns a Hash representation. Otherwise returns the raw body.

@param [Excon::Response] @return [Hash,String]

# File lib/kontena/client.rb, line 486
def parse_response(response)
  check_version_and_warn(response.headers[X_KONTENA_VERSION])

  if response.headers[CONTENT_TYPE] =~ JSON_REGEX
    parse_json(response)
  else
    response.body
  end
end
path_with_prefix(path) click to toggle source

Get prefixed request path unless path starts with /

@param [String] path @return [String]

# File lib/kontena/client.rb, line 445
def path_with_prefix(path)
  path.to_s.start_with?('/') ? path : "#{path_prefix}#{path}"
end
request_headers(headers = {}, auth: true, gzip: true) click to toggle source

Build request headers. Removes empty headers. @example

request_headers('Authorization' => nil)

@param [Hash] headers @return [Hash]

# File lib/kontena/client.rb, line 457
def request_headers(headers = {}, auth: true, gzip: true)
  headers = default_headers.merge(headers)
  headers.merge!(bearer_authorization_header) if auth
  headers[ACCEPT_ENCODING] = GZIP if gzip
  headers.reject{|_,v| v.nil? || (v.respond_to?(:empty?) && v.empty?)}
end
token_is_for_master?() click to toggle source

Returns true if the token object belongs to a master

@return [Boolean]

# File lib/kontena/client.rb, line 436
def token_is_for_master?
  token_account['name'] == 'master'
end