class Krane::KubernetesResource

Constants

DEBUG_RESOURCE_NOT_FOUND_MESSAGE
DISABLED_EVENT_INFO_MESSAGE
DISABLED_LOG_INFO_MESSAGE
DISABLE_FETCHING_EVENT_INFO
DISABLE_FETCHING_LOG_INFO
GLOBAL
LAST_APPLIED_ANNOTATION
LOG_LINE_COUNT
SENSITIVE_TEMPLATE_CONTENT
SERVER_DRY_RUNNABLE
SERVER_DRY_RUN_DISABLED_ERROR
STANDARD_TIMEOUT_MESSAGE
TIMEOUT
TIMEOUT_OVERRIDE_ANNOTATION
TIMEOUT_OVERRIDE_ANNOTATION_DEPRECATED
TIMEOUT_OVERRIDE_ANNOTATION_SUFFIX
UNUSUAL_FAILURE_MESSAGE

Attributes

context[R]
deploy_started_at[W]
global[W]
name[R]
namespace[R]
type[W]

Public Class Methods

build(namespace: nil, context:, definition:, logger:, statsd_tags:, crd: nil, global_names: []) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 43
def build(namespace: nil, context:, definition:, logger:, statsd_tags:, crd: nil, global_names: [])
  validate_definition_essentials(definition)
  opts = { namespace: namespace, context: context, definition: definition, logger: logger,
           statsd_tags: statsd_tags }
  if (klass = class_for_kind(definition["kind"]))
    return klass.new(**opts)
  end
  if crd
    CustomResource.new(crd: crd, **opts)
  else
    type = definition["kind"]
    inst = new(**opts)
    inst.type = type
    inst.global = global_names.map(&:downcase).include?(type.downcase)
    inst
  end
end
class_for_kind(kind) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 61
def class_for_kind(kind)
  if Krane.const_defined?(kind)
    Krane.const_get(kind)
  end
rescue NameError
  nil
end
kind() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 73
def kind
  name.demodulize
end
new(namespace:, context:, definition:, logger:, statsd_tags: []) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 114
def initialize(namespace:, context:, definition:, logger:, statsd_tags: [])
  # subclasses must also set these if they define their own initializer
  @name = (definition.dig("metadata", "name") || definition.dig("metadata", "generateName")).to_s
  @optional_statsd_tags = statsd_tags
  @namespace = namespace
  @context = context
  @logger = logger
  @definition = definition
  @statsd_report_done = false
  @disappeared = false
  @validation_errors = []
  @validation_warnings = []
  @instance_data = {}
  @server_dry_run_validated = false
end
timeout() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 69
def timeout
  self::TIMEOUT
end

Private Class Methods

validate_definition_essentials(definition) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 79
      def validate_definition_essentials(definition)
        debug_content = <<~STRING
          apiVersion: #{definition.fetch('apiVersion', '<missing>')}
          kind: #{definition.fetch('kind', '<missing>')}
          metadata: #{definition.fetch('metadata', {})}
          <Template body suppressed because content sensitivity could not be determined.>
        STRING
        if definition["kind"].blank?
          raise InvalidTemplateError.new("Template is missing required field 'kind'", content: debug_content)
        end

        if definition.dig('metadata', 'name').blank? && definition.dig('metadata', 'generateName').blank?
          raise InvalidTemplateError.new("Template must specify one of 'metadata.name' or 'metadata.generateName'",
            content: debug_content)
        end
      end

Public Instance Methods

<=>(other) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 164
def <=>(other)
  id <=> other.id
end
after_sync() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 179
def after_sync
end
current_generation() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 211
def current_generation
  return -1 unless exists? # must be different default than observed_generation
  @instance_data.dig("metadata", "generation")
end
debug_message(cause = nil, info_hash = {}) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 253
def debug_message(cause = nil, info_hash = {})
  helpful_info = []
  if cause == :gave_up
    debug_heading = ColorizedString.new("#{id}: GLOBAL WATCH TIMEOUT (#{info_hash[:timeout]} seconds)").yellow
    helpful_info << "If you expected it to take longer than #{info_hash[:timeout]} seconds for your deploy"\
    " to roll out, increase --max-watch-seconds."
  elsif deploy_failed?
    debug_heading = ColorizedString.new("#{id}: FAILED").red
    helpful_info << failure_message if failure_message.present?
  elsif deploy_timed_out?
    debug_heading = ColorizedString.new("#{id}: TIMED OUT (#{pretty_timeout_type})").yellow
    helpful_info << timeout_message if timeout_message.present?
  else
    # Arriving in debug_message when we neither failed nor timed out is very unexpected. Dump all available info.
    debug_heading = ColorizedString.new("#{id}: MONITORING ERROR").red
    helpful_info << failure_message if failure_message.present?
    helpful_info << timeout_message if timeout_message.present? && timeout_message != STANDARD_TIMEOUT_MESSAGE
  end

  final_status = "  - Final status: #{status}"
  final_status = "\n#{final_status}" if helpful_info.present? && !helpful_info.last.end_with?("\n")
  helpful_info.prepend(debug_heading)
  helpful_info << final_status

  if @debug_events.present?
    helpful_info << "  - Events (common success events excluded):"
    @debug_events.each do |identifier, event_hashes|
      event_hashes.each { |event| helpful_info << "      [#{identifier}]\t#{event}" }
    end
  elsif ENV[DISABLE_FETCHING_EVENT_INFO]
    helpful_info << "  - Events: #{DISABLED_EVENT_INFO_MESSAGE}"
  else
    helpful_info << "  - Events: #{DEBUG_RESOURCE_NOT_FOUND_MESSAGE}"
  end

  if print_debug_logs?
    if ENV[DISABLE_FETCHING_LOG_INFO]
      helpful_info << "  - Logs: #{DISABLED_LOG_INFO_MESSAGE}"
    elsif @debug_logs.blank?
      helpful_info << "  - Logs: #{DEBUG_RESOURCE_NOT_FOUND_MESSAGE}"
    else
      container_logs = @debug_logs.container_logs.sort_by { |c| c.lines.length }
      container_logs.each do |logs|
        if logs.empty?
          helpful_info << "  - Logs from container '#{logs.container_name}': #{DEBUG_RESOURCE_NOT_FOUND_MESSAGE}"
          next
        end

        if logs.lines.length == ContainerLogs::DEFAULT_LINE_LIMIT
          truncated = " (last #{ContainerLogs::DEFAULT_LINE_LIMIT} lines shown)"
        end
        helpful_info << "  - Logs from container '#{logs.container_name}'#{truncated}:"
        logs.lines.each do |line|
          helpful_info << "      #{line}"
        end
      end
    end
  end

  helpful_info.join("\n")
end
deploy_failed?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 190
def deploy_failed?
  false
end
deploy_method() click to toggle source

Expected values: :apply, :create, :replace, :replace_force

# File lib/krane/kubernetes_resource.rb, line 240
def deploy_method
  if @definition.dig("metadata", "name").blank? && uses_generate_name?
    :create
  else
    :apply
  end
end
deploy_started?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 194
def deploy_started?
  @deploy_started_at.present?
end
deploy_succeeded?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 198
def deploy_succeeded?
  return false unless deploy_started?
  unless @success_assumption_warning_shown
    @logger.warn("Don't know how to monitor resources of type #{type}. Assuming #{id} deployed successfully.")
    @success_assumption_warning_shown = true
  end
  true
end
deploy_timed_out?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 234
def deploy_timed_out?
  return false unless deploy_started?
  !deploy_succeeded? && !deploy_failed? && (Time.now.utc - @deploy_started_at > timeout)
end
disappeared?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 186
def disappeared?
  @disappeared
end
exists?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 207
def exists?
  @instance_data.present?
end
failure_message() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 338
def failure_message
end
fetch_events(kubectl) click to toggle source

Returns a hash in the following format: {

"pod/web-1" => [
  "Pulling: pulling image "hello-world:latest" (1 events)",
  "Pulled: Successfully pulled image "hello-world:latest" (1 events)"
]

}

# File lib/krane/kubernetes_resource.rb, line 322
def fetch_events(kubectl)
  return {} unless exists?
  out, _err, st = kubectl.run("get", "events", "--output=go-template=#{Event.go_template_for(type, name)}",
    log_failure: false, use_namespace: !global?)
  return {} unless st.success?

  event_collector = Hash.new { |hash, key| hash[key] = [] }
  Event.extract_all_from_go_template_blob(out).each_with_object(event_collector) do |candidate, events|
    events[id] << candidate.to_s if candidate.seen_since?(@deploy_started_at - 5.seconds)
  end
end
file_path() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 168
def file_path
  file.path
end
global?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 441
def global?
  @global || self.class::GLOBAL
end
has_warnings?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 148
def has_warnings?
  @validation_warnings.present?
end
id() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 160
def id
  "#{type}/#{name}"
end
kubectl_resource_type() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 230
def kubectl_resource_type
  type
end
observed_generation() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 216
def observed_generation
  return -2 unless exists?
  # populating this is a best practice, but not all controllers actually do it
  @instance_data.dig('status', 'observedGeneration')
end
pretty_status() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 341
def pretty_status
  padding = " " * [50 - id.length, 1].max
  "#{id}#{padding}#{status}"
end
pretty_timeout_type() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 110
def pretty_timeout_type
  "timeout: #{timeout}s"
end
report_status_to_statsd(watch_time) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 346
def report_status_to_statsd(watch_time)
  unless @statsd_report_done
    StatsD.client.distribution('resource.duration', watch_time, tags: statsd_tags)
    @statsd_report_done = true
  end
end
sensitive_template_content?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 353
def sensitive_template_content?
  self.class::SENSITIVE_TEMPLATE_CONTENT
end
server_dry_run_validated?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 367
def server_dry_run_validated?
  @server_dry_run_validated
end
server_dry_runnable_resource?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 357
def server_dry_runnable_resource?
  # generateName and server-side dry run are incompatible because the former only works with `create`
  # and the latter only works with `apply`
  self.class::SERVER_DRY_RUNNABLE && !uses_generate_name?
end
status() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 222
def status
  exists? ? "Exists" : "Not Found"
end
sync(cache) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 172
def sync(cache)
  @instance_data = cache.get_instance(kubectl_resource_type, name, raise_if_not_found: true)
rescue Krane::Kubectl::ResourceNotFoundError
  @disappeared = true if deploy_started?
  @instance_data = {}
end
sync_debug_info(kubectl) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 248
def sync_debug_info(kubectl)
  @debug_events = fetch_events(kubectl) unless ENV[DISABLE_FETCHING_EVENT_INFO]
  @debug_logs = fetch_debug_logs if print_debug_logs? && !ENV[DISABLE_FETCHING_LOG_INFO]
end
terminating?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 182
def terminating?
  @instance_data.dig('metadata', 'deletionTimestamp').present?
end
timeout() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 97
def timeout
  return timeout_override if timeout_override.present?
  self.class.timeout
end
timeout_message() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 334
def timeout_message
  STANDARD_TIMEOUT_MESSAGE
end
timeout_override() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 102
def timeout_override
  return @timeout_override if defined?(@timeout_override)

  @timeout_override = DurationParser.new(krane_annotation_value(TIMEOUT_OVERRIDE_ANNOTATION_SUFFIX)).parse!.to_i
rescue DurationParser::ParsingError
  @timeout_override = nil
end
to_kubeclient_resource() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 130
def to_kubeclient_resource
  Kubeclient::Resource.new(@definition)
end
type() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 226
def type
  @type || self.class.kind
end
use_generated_name(instance_data) click to toggle source

If a resource uses generateName, we don't know the full name of the resource until it's deployed to the cluster. In this case, we need to update our local definition with the realized name in order to accurately track the resource during deploy

# File lib/krane/kubernetes_resource.rb, line 374
def use_generated_name(instance_data)
  @name = instance_data.dig('metadata', 'name')
  @definition['metadata']['name'] = @name
  @definition['metadata'].delete('generateName')
  @file = create_definition_tempfile
end
uses_generate_name?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 363
def uses_generate_name?
  @definition.dig('metadata', 'generateName').present?
end
validate_definition(kubectl, selector: nil) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 134
def validate_definition(kubectl, selector: nil)
  @validation_errors = []
  @validation_warnings = []
  validate_selector(selector) if selector
  validate_timeout_annotation
  validate_annotation_version
  validate_spec_with_kubectl(kubectl)
  @validation_errors.present?
end
validation_error_msg() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 152
def validation_error_msg
  @validation_errors.join("\n")
end
validation_failed?() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 156
def validation_failed?
  @validation_errors.present?
end
validation_warning_msg() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 144
def validation_warning_msg
  @validation_warnings.join("\n")
end

Private Instance Methods

create_definition_tempfile() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 546
def create_definition_tempfile
  file = Tempfile.new(["#{type}-#{name}", ".yml"])
  file.write(YAML.dump(@definition))
  file
ensure
  file&.close
end
file() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 542
def file
  @file ||= create_definition_tempfile
end
krane_annotation_key(suffix) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 480
def krane_annotation_key(suffix)
  if @definition.dig("metadata", "annotations", "kubernetes-deploy.shopify.io/#{suffix}")
    "kubernetes-deploy.shopify.io/#{suffix}"
  elsif @definition.dig("metadata", "annotations", "krane.shopify.io/#{suffix}")
    "krane.shopify.io/#{suffix}"
  end
end
krane_annotation_value(suffix) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 475
def krane_annotation_value(suffix)
  @definition.dig("metadata", "annotations", "kubernetes-deploy.shopify.io/#{suffix}") ||
    @definition.dig("metadata", "annotations", "krane.shopify.io/#{suffix}")
end
labels() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 538
def labels
  @definition.dig("metadata", "labels")
end
print_debug_logs?() click to toggle source
statsd_tags() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 558
def statsd_tags
  status = if deploy_failed?
    "failure"
  elsif deploy_timed_out?
    "timeout"
  elsif deploy_succeeded?
    "success"
  else
    "unknown"
  end
  tags = %W(context:#{context} namespace:#{namespace} type:#{type} status:#{status})
  tags | @optional_statsd_tags
end
validate_annotation_version() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 462
def validate_annotation_version
  return if validation_warning_msg.include?("annotations is deprecated")
  annotation_keys = @definition.dig("metadata", "annotations")&.keys
  annotation_keys&.each do |annotation|
    if annotation.include?("kubernetes-deploy.shopify.io")
      annotation_prefix = annotation.split('/').first
      @validation_warnings << "#{annotation_prefix} as a prefix for annotations is deprecated: "\
        "Use the 'krane.shopify.io' annotation prefix instead"
      return
    end
  end
end
validate_selector(selector) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 488
def validate_selector(selector)
  if labels.nil?
    @validation_errors << "selector #{selector} passed in, but no labels were defined"
    return
  end

  unless selector.to_h <= labels
    label_name = 'label'.pluralize(labels.size)
    label_string = LabelSelector.new(labels).to_s
    @validation_errors << "selector #{selector} does not match #{label_name} #{label_string}"
  end
end
validate_spec_with_kubectl(kubectl) click to toggle source
# File lib/krane/kubernetes_resource.rb, line 501
def validate_spec_with_kubectl(kubectl)
  err = ""
  if kubectl.server_dry_run_enabled? && server_dry_runnable_resource?
    _, err, st = validate_with_server_side_dry_run(kubectl)
    @server_dry_run_validated = st.success?
    return true if st.success?
  end

  if err.empty? || err.match(SERVER_DRY_RUN_DISABLED_ERROR)
    _, err, st = validate_with_local_dry_run(kubectl)
  end

  return true if st.success?
  @validation_errors << if sensitive_template_content?
    "Validation for #{id} failed. Detailed information is unavailable as the raw error may contain sensitive data."
  else
    err
  end
end
validate_timeout_annotation() click to toggle source
# File lib/krane/kubernetes_resource.rb, line 447
def validate_timeout_annotation
  timeout_override_value = krane_annotation_value(TIMEOUT_OVERRIDE_ANNOTATION_SUFFIX)
  timeout_annotation_key = krane_annotation_key(TIMEOUT_OVERRIDE_ANNOTATION_SUFFIX)
  return if timeout_override_value.nil?

  override = DurationParser.new(timeout_override_value).parse!
  if override <= 0
    @validation_errors << "#{timeout_annotation_key} annotation is invalid: Value must be greater than 0"
  elsif override > 24.hours
    @validation_errors << "#{timeout_annotation_key} annotation is invalid: Value must be less than 24h"
  end
rescue DurationParser::ParsingError => e
  @validation_errors << "#{timeout_annotation_key} annotation is invalid: #{e}"
end
validate_with_local_dry_run(kubectl) click to toggle source

Local dry run is supported on only create and apply If the deploy method is create, validating with apply will fail If the resource template uses generateName, validating with apply will fail

# File lib/krane/kubernetes_resource.rb, line 531
def validate_with_local_dry_run(kubectl)
  verb = deploy_method == :apply ? "apply" : "create"
  command = [verb, "-f", file_path, "--dry-run", "--output=name"]
  kubectl.run(*command, log_failure: false, output_is_sensitive: sensitive_template_content?,
    retry_whitelist: [:client_timeout], attempts: 3, use_namespace: !global?)
end
validate_with_server_side_dry_run(kubectl) click to toggle source

Server side dry run is only supported on apply

# File lib/krane/kubernetes_resource.rb, line 522
def validate_with_server_side_dry_run(kubectl)
  command = ["apply", "-f", file_path, "--server-dry-run", "--output=name"]
  kubectl.run(*command, log_failure: false, output_is_sensitive: sensitive_template_content?,
    retry_whitelist: [:client_timeout], attempts: 3)
end