tobsch-krane 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (99) hide show
  1. checksums.yaml +7 -0
  2. data/.buildkite/pipeline.nightly.yml +43 -0
  3. data/.github/probots.yml +2 -0
  4. data/.gitignore +20 -0
  5. data/.rubocop.yml +17 -0
  6. data/.shopify-build/VERSION +1 -0
  7. data/.shopify-build/kubernetes-deploy.yml +53 -0
  8. data/1.0-Upgrade.md +185 -0
  9. data/CHANGELOG.md +431 -0
  10. data/CODE_OF_CONDUCT.md +46 -0
  11. data/CONTRIBUTING.md +164 -0
  12. data/Gemfile +16 -0
  13. data/ISSUE_TEMPLATE.md +25 -0
  14. data/LICENSE.txt +21 -0
  15. data/README.md +655 -0
  16. data/Rakefile +36 -0
  17. data/bin/ci +21 -0
  18. data/bin/setup +16 -0
  19. data/bin/test +47 -0
  20. data/dev.yml +28 -0
  21. data/dev/flamegraph-from-tests +35 -0
  22. data/exe/krane +5 -0
  23. data/krane.gemspec +44 -0
  24. data/lib/krane.rb +7 -0
  25. data/lib/krane/bindings_parser.rb +88 -0
  26. data/lib/krane/cli/deploy_command.rb +75 -0
  27. data/lib/krane/cli/global_deploy_command.rb +54 -0
  28. data/lib/krane/cli/krane.rb +91 -0
  29. data/lib/krane/cli/render_command.rb +41 -0
  30. data/lib/krane/cli/restart_command.rb +34 -0
  31. data/lib/krane/cli/run_command.rb +54 -0
  32. data/lib/krane/cli/version_command.rb +13 -0
  33. data/lib/krane/cluster_resource_discovery.rb +113 -0
  34. data/lib/krane/common.rb +23 -0
  35. data/lib/krane/concerns/template_reporting.rb +29 -0
  36. data/lib/krane/concurrency.rb +18 -0
  37. data/lib/krane/container_logs.rb +106 -0
  38. data/lib/krane/deferred_summary_logging.rb +95 -0
  39. data/lib/krane/delayed_exceptions.rb +14 -0
  40. data/lib/krane/deploy_task.rb +363 -0
  41. data/lib/krane/deploy_task_config_validator.rb +29 -0
  42. data/lib/krane/duration_parser.rb +27 -0
  43. data/lib/krane/ejson_secret_provisioner.rb +154 -0
  44. data/lib/krane/errors.rb +28 -0
  45. data/lib/krane/formatted_logger.rb +57 -0
  46. data/lib/krane/global_deploy_task.rb +210 -0
  47. data/lib/krane/global_deploy_task_config_validator.rb +12 -0
  48. data/lib/krane/kubeclient_builder.rb +156 -0
  49. data/lib/krane/kubectl.rb +120 -0
  50. data/lib/krane/kubernetes_resource.rb +621 -0
  51. data/lib/krane/kubernetes_resource/cloudsql.rb +43 -0
  52. data/lib/krane/kubernetes_resource/config_map.rb +22 -0
  53. data/lib/krane/kubernetes_resource/cron_job.rb +18 -0
  54. data/lib/krane/kubernetes_resource/custom_resource.rb +87 -0
  55. data/lib/krane/kubernetes_resource/custom_resource_definition.rb +98 -0
  56. data/lib/krane/kubernetes_resource/daemon_set.rb +90 -0
  57. data/lib/krane/kubernetes_resource/deployment.rb +213 -0
  58. data/lib/krane/kubernetes_resource/horizontal_pod_autoscaler.rb +65 -0
  59. data/lib/krane/kubernetes_resource/ingress.rb +18 -0
  60. data/lib/krane/kubernetes_resource/job.rb +60 -0
  61. data/lib/krane/kubernetes_resource/network_policy.rb +22 -0
  62. data/lib/krane/kubernetes_resource/persistent_volume_claim.rb +80 -0
  63. data/lib/krane/kubernetes_resource/pod.rb +269 -0
  64. data/lib/krane/kubernetes_resource/pod_disruption_budget.rb +23 -0
  65. data/lib/krane/kubernetes_resource/pod_set_base.rb +71 -0
  66. data/lib/krane/kubernetes_resource/pod_template.rb +20 -0
  67. data/lib/krane/kubernetes_resource/replica_set.rb +92 -0
  68. data/lib/krane/kubernetes_resource/resource_quota.rb +22 -0
  69. data/lib/krane/kubernetes_resource/role.rb +22 -0
  70. data/lib/krane/kubernetes_resource/role_binding.rb +22 -0
  71. data/lib/krane/kubernetes_resource/secret.rb +24 -0
  72. data/lib/krane/kubernetes_resource/service.rb +104 -0
  73. data/lib/krane/kubernetes_resource/service_account.rb +22 -0
  74. data/lib/krane/kubernetes_resource/stateful_set.rb +70 -0
  75. data/lib/krane/label_selector.rb +42 -0
  76. data/lib/krane/oj.rb +4 -0
  77. data/lib/krane/options_helper.rb +39 -0
  78. data/lib/krane/remote_logs.rb +60 -0
  79. data/lib/krane/render_task.rb +118 -0
  80. data/lib/krane/renderer.rb +118 -0
  81. data/lib/krane/resource_cache.rb +68 -0
  82. data/lib/krane/resource_deployer.rb +265 -0
  83. data/lib/krane/resource_watcher.rb +171 -0
  84. data/lib/krane/restart_task.rb +228 -0
  85. data/lib/krane/rollout_conditions.rb +103 -0
  86. data/lib/krane/runner_task.rb +212 -0
  87. data/lib/krane/runner_task_config_validator.rb +18 -0
  88. data/lib/krane/statsd.rb +65 -0
  89. data/lib/krane/task_config.rb +22 -0
  90. data/lib/krane/task_config_validator.rb +96 -0
  91. data/lib/krane/template_sets.rb +173 -0
  92. data/lib/krane/version.rb +4 -0
  93. data/pull_request_template.md +8 -0
  94. data/screenshots/deploy-demo.gif +0 -0
  95. data/screenshots/migrate-logs.png +0 -0
  96. data/screenshots/missing-secret-fail.png +0 -0
  97. data/screenshots/success.png +0 -0
  98. data/screenshots/test-output.png +0 -0
  99. metadata +375 -0
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'krane'
4
+ require 'thor'
5
+ require 'krane/cli/version_command'
6
+ require 'krane/cli/restart_command'
7
+ require 'krane/cli/run_command'
8
+ require 'krane/cli/render_command'
9
+ require 'krane/cli/deploy_command'
10
+ require 'krane/cli/global_deploy_command'
11
+
12
+ module Krane
13
+ module CLI
14
+ class Krane < Thor
15
+ TIMEOUT_EXIT_CODE = 70
16
+ FAILURE_EXIT_CODE = 1
17
+
18
+ package_name "Krane"
19
+
20
+ def self.expand_options(task_options)
21
+ task_options.each { |option_name, config| method_option(option_name, config) }
22
+ end
23
+
24
+ desc("render", "Render templates")
25
+ expand_options(RenderCommand::OPTIONS)
26
+ def render
27
+ rescue_and_exit do
28
+ RenderCommand.from_options(options)
29
+ end
30
+ end
31
+
32
+ desc("version", "Prints the version")
33
+ expand_options(VersionCommand::OPTIONS)
34
+ def version
35
+ VersionCommand.from_options(options)
36
+ end
37
+
38
+ desc("restart NAMESPACE CONTEXT", "Restart the pods in one or more deployments")
39
+ expand_options(RestartCommand::OPTIONS)
40
+ def restart(namespace, context)
41
+ rescue_and_exit do
42
+ RestartCommand.from_options(namespace, context, options)
43
+ end
44
+ end
45
+
46
+ desc("run NAMESPACE CONTEXT", "Run a pod that exits upon completing a task")
47
+ expand_options(RunCommand::OPTIONS)
48
+ def run_command(namespace, context)
49
+ rescue_and_exit do
50
+ RunCommand.from_options(namespace, context, options)
51
+ end
52
+ end
53
+
54
+ desc("deploy NAMESPACE CONTEXT", "Ship resources to a namespace")
55
+ expand_options(DeployCommand::OPTIONS)
56
+ def deploy(namespace, context)
57
+ rescue_and_exit do
58
+ DeployCommand.from_options(namespace, context, options)
59
+ end
60
+ end
61
+
62
+ desc("global-deploy CONTEXT", "Ship non-namespaced resources to a cluster")
63
+ expand_options(GlobalDeployCommand::OPTIONS)
64
+ def global_deploy(context)
65
+ rescue_and_exit do
66
+ GlobalDeployCommand.from_options(context, options)
67
+ end
68
+ end
69
+
70
+ def self.exit_on_failure?
71
+ true
72
+ end
73
+
74
+ private
75
+
76
+ def rescue_and_exit
77
+ yield
78
+ rescue ::Krane::DeploymentTimeoutError
79
+ exit(TIMEOUT_EXIT_CODE)
80
+ rescue ::Krane::FatalDeploymentError
81
+ exit(FAILURE_EXIT_CODE)
82
+ rescue ::Krane::DurationParser::ParsingError => e
83
+ STDERR.puts(<<~ERROR_MESSAGE)
84
+ Error parsing duration
85
+ #{e.message}. Duration must be a full ISO8601 duration or time value (e.g. 300s, 10m, 1h)
86
+ ERROR_MESSAGE
87
+ exit(FAILURE_EXIT_CODE)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Krane
4
+ module CLI
5
+ class RenderCommand
6
+ OPTIONS = {
7
+ "bindings" => { type: :array, banner: "foo=bar abc=def", desc: 'Bindings for erb' },
8
+ "filenames" => { type: :array, banner: 'config/deploy/production config/deploy/my-extra-resource.yml',
9
+ required: false, default: [], aliases: 'f', desc: 'Directories and files to render' },
10
+ "stdin" => { type: :boolean, desc: "Read resources from stdin", default: false },
11
+ "current-sha" => { type: :string, banner: "SHA", desc: "Expose SHA `current_sha` in ERB bindings",
12
+ lazy_default: '' },
13
+ }
14
+
15
+ def self.from_options(options)
16
+ require 'krane/render_task'
17
+ require 'krane/bindings_parser'
18
+ require 'krane/options_helper'
19
+
20
+ bindings_parser = ::Krane::BindingsParser.new
21
+ options[:bindings]&.each { |b| bindings_parser.add(b) }
22
+
23
+ # never mutate options directly
24
+ filenames = options[:filenames].dup
25
+ filenames << "-" if options[:stdin]
26
+ if filenames.empty?
27
+ raise Thor::RequiredArgumentMissingError, 'At least one of --filenames or --stdin must be set'
28
+ end
29
+
30
+ ::Krane::OptionsHelper.with_processed_template_paths(filenames, render_erb: true) do |paths|
31
+ renderer = ::Krane::RenderTask.new(
32
+ current_sha: options['current-sha'],
33
+ filenames: paths,
34
+ bindings: bindings_parser.parse,
35
+ )
36
+ renderer.run!(stream: STDOUT)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Krane
4
+ module CLI
5
+ class RestartCommand
6
+ DEFAULT_RESTART_TIMEOUT = '300s'
7
+ OPTIONS = {
8
+ "deployments" => { type: :array, banner: "list of deployments",
9
+ desc: "List of workload names to restart" },
10
+ "global-timeout" => { type: :string, banner: "duration", default: DEFAULT_RESTART_TIMEOUT,
11
+ desc: "Max duration to monitor workloads correctly restarted" },
12
+ "selector" => { type: :string, banner: "'label=value'",
13
+ desc: "Select workloads by selector(s)" },
14
+ "verify-result" => { type: :boolean, default: true,
15
+ desc: "Verify workloads correctly restarted" },
16
+ }
17
+
18
+ def self.from_options(namespace, context, options)
19
+ require 'krane/restart_task'
20
+ selector = ::Krane::LabelSelector.parse(options[:selector]) if options[:selector]
21
+ restart = ::Krane::RestartTask.new(
22
+ namespace: namespace,
23
+ context: context,
24
+ global_timeout: ::Krane::DurationParser.new(options["global-timeout"]).parse!.to_i,
25
+ )
26
+ restart.run!(
27
+ deployments: options[:deployments],
28
+ selector: selector,
29
+ verify_result: options["verify-result"]
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Krane
4
+ module CLI
5
+ class RunCommand
6
+ DEFAULT_RUN_TIMEOUT = '300s'
7
+
8
+ OPTIONS = {
9
+ "global-timeout" => {
10
+ type: :string,
11
+ banner: "duration",
12
+ desc: "Timeout error is raised if the pod runs for longer than the specified number of seconds",
13
+ default: DEFAULT_RUN_TIMEOUT,
14
+ },
15
+ "arguments" => {
16
+ type: :string,
17
+ banner: '"ARG1 ARG2 ARG3"',
18
+ desc: "Override the default arguments for the command with a space-separated list of arguments",
19
+ },
20
+ "verify-result" => { type: :boolean, desc: "Wait for completion and verify pod success", default: true },
21
+ "command" => { type: :array, desc: "Override the default command in the container image" },
22
+ "template" => {
23
+ type: :string,
24
+ desc: "The template file you'll be rendering",
25
+ required: true,
26
+ aliases: :f,
27
+ },
28
+ "env-vars" => {
29
+ type: :string,
30
+ banner: "VAR=val,FOO=bar",
31
+ desc: "A Comma-separated list of env vars",
32
+ default: '',
33
+ },
34
+ }
35
+
36
+ def self.from_options(namespace, context, options)
37
+ require "krane/runner_task"
38
+ runner = ::Krane::RunnerTask.new(
39
+ namespace: namespace,
40
+ context: context,
41
+ global_timeout: ::Krane::DurationParser.new(options["global-timeout"]).parse!.to_i,
42
+ )
43
+
44
+ runner.run!(
45
+ verify_result: options['verify-result'],
46
+ template: options['template'],
47
+ command: options['command'],
48
+ arguments: options['arguments']&.split(" "),
49
+ env_vars: options['env-vars'].split(','),
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Krane
4
+ module CLI
5
+ class VersionCommand
6
+ OPTIONS = {}
7
+
8
+ def self.from_options(_)
9
+ puts("krane #{::Krane::VERSION}")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Krane
4
+ class ClusterResourceDiscovery
5
+ delegate :namespace, :context, :logger, to: :@task_config
6
+
7
+ def initialize(task_config:, namespace_tags: [])
8
+ @task_config = task_config
9
+ @namespace_tags = namespace_tags
10
+ end
11
+
12
+ def crds
13
+ @crds ||= fetch_crds.map do |cr_def|
14
+ CustomResourceDefinition.new(namespace: namespace, context: context, logger: logger,
15
+ definition: cr_def, statsd_tags: @namespace_tags)
16
+ end
17
+ end
18
+
19
+ def prunable_resources(namespaced:)
20
+ black_list = %w(Namespace Node ControllerRevision)
21
+ api_versions = fetch_api_versions
22
+
23
+ fetch_resources(namespaced: namespaced).uniq { |r| r['kind'] }.map do |resource|
24
+ next unless resource['verbs'].one? { |v| v == "delete" }
25
+ next if black_list.include?(resource['kind'])
26
+ group_versions = api_versions[resource['apigroup'].to_s]
27
+ version = version_for_kind(group_versions, resource['kind'])
28
+ [resource['apigroup'], version, resource['kind']].compact.join("/")
29
+ end.compact
30
+ end
31
+
32
+ # kubectl api-resources -o wide returns 5 columns
33
+ # NAME SHORTNAMES APIGROUP NAMESPACED KIND VERBS
34
+ # SHORTNAMES and APIGROUP may be blank
35
+ # VERBS is an array
36
+ # serviceaccounts sa <blank> true ServiceAccount [create delete deletecollection get list patch update watch]
37
+ def fetch_resources(namespaced: false)
38
+ command = %w(api-resources)
39
+ command << "--namespaced=#{namespaced}"
40
+ raw, _, st = kubectl.run(*command, output: "wide", attempts: 5,
41
+ use_namespace: false)
42
+ if st.success?
43
+ rows = raw.split("\n")
44
+ header = rows[0]
45
+ resources = rows[1..-1]
46
+ full_width_field_names = header.downcase.scan(/[a-z]+[\W]*/)
47
+ cursor = 0
48
+ fields = full_width_field_names.each_with_object({}) do |name, hash|
49
+ start = cursor
50
+ cursor = start + name.length
51
+ # Last field should consume the remainder of the line
52
+ cursor = 0 if full_width_field_names.last == name.strip
53
+ hash[name.strip] = [start, cursor - 1]
54
+ end
55
+ resources.map do |resource|
56
+ resource = fields.map { |k, (s, e)| [k.strip, resource[s..e].strip] }.to_h
57
+ # Manually parse verbs: "[get list]" into %w(get list)
58
+ resource["verbs"] = resource["verbs"][1..-2].split
59
+ resource
60
+ end
61
+ else
62
+ []
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ # kubectl api-versions returns a list of group/version strings e.g. autoscaling/v2beta2
69
+ # A kind may not exist in all versions of the group.
70
+ def fetch_api_versions
71
+ raw, _, st = kubectl.run("api-versions", attempts: 5, use_namespace: false)
72
+ # The "core" group is represented by an empty string
73
+ versions = { "" => %w(v1) }
74
+ if st.success?
75
+ rows = raw.split("\n")
76
+ rows.each do |group_version|
77
+ group, version = group_version.split("/")
78
+ versions[group] ||= []
79
+ versions[group] << version
80
+ end
81
+ end
82
+ versions
83
+ end
84
+
85
+ def version_for_kind(versions, kind)
86
+ # Override list for kinds that don't appear in the lastest version of a group
87
+ version_override = { "CronJob" => "v1beta1", "VolumeAttachment" => "v1beta1",
88
+ "CSIDriver" => "v1beta1", "Ingress" => "v1beta1", "CSINode" => "v1beta1" }
89
+
90
+ pattern = /v(?<major>\d+)(?<pre>alpha|beta)?(?<minor>\d+)?/
91
+ latest = versions.sort_by do |version|
92
+ match = version.match(pattern)
93
+ pre = { "alpha" => 0, "beta" => 1, nil => 2 }.fetch(match[:pre])
94
+ [match[:major].to_i, pre, match[:minor].to_i]
95
+ end.last
96
+ version_override.fetch(kind, latest)
97
+ end
98
+
99
+ def fetch_crds
100
+ raw_json, _, st = kubectl.run("get", "CustomResourceDefinition", output: "json", attempts: 5,
101
+ use_namespace: false)
102
+ if st.success?
103
+ JSON.parse(raw_json)["items"]
104
+ else
105
+ []
106
+ end
107
+ end
108
+
109
+ def kubectl
110
+ @kubectl ||= Kubectl.new(task_config: @task_config, log_failure_by_default: true)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'active_support/core_ext/hash/reverse_merge'
5
+ require 'active_support/core_ext/hash/slice'
6
+ require 'active_support/core_ext/numeric/time'
7
+ require 'active_support/core_ext/string/inflections'
8
+ require 'active_support/core_ext/string/strip'
9
+ require 'active_support/core_ext/hash/keys'
10
+ require 'active_support/core_ext/array/conversions'
11
+ require 'colorized_string'
12
+
13
+ require 'krane/version'
14
+ require 'krane/oj'
15
+ require 'krane/errors'
16
+ require 'krane/formatted_logger'
17
+ require 'krane/statsd'
18
+ require 'krane/task_config'
19
+ require 'krane/task_config_validator'
20
+
21
+ module Krane
22
+ MIN_KUBE_VERSION = '1.11.0'
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Krane
4
+ module TemplateReporting
5
+ def record_invalid_template(logger:, err:, filename:, content: nil)
6
+ debug_msg = ColorizedString.new("Invalid template: #{filename}\n").red
7
+ debug_msg += "> Error message:\n#{Krane::FormattedLogger.indent_four(err)}"
8
+ if content
9
+ debug_msg += if content =~ /kind:\s*Secret/
10
+ "\n> Template content: Suppressed because it may contain a Secret"
11
+ else
12
+ "\n> Template content:\n#{Krane::FormattedLogger.indent_four(content)}"
13
+ end
14
+ end
15
+ logger.summary.add_paragraph(debug_msg)
16
+ end
17
+
18
+ def record_warnings(logger:, warning:, filename:)
19
+ warn_msg = "Template warning: #{filename}\n"
20
+ warn_msg += "> Warning message:\n#{Krane::FormattedLogger.indent_four(warning)}"
21
+ logger.summary.add_paragraph(ColorizedString.new(warn_msg).yellow)
22
+ end
23
+
24
+ def add_para_from_list(logger:, action:, enum:)
25
+ logger.summary.add_action(action)
26
+ logger.summary.add_paragraph(enum.map { |e| "- #{e}" }.join("\n"))
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Krane
3
+ module Concurrency
4
+ MAX_THREADS = 8
5
+
6
+ def self.split_across_threads(all_work, &block)
7
+ return if all_work.empty?
8
+ raise ArgumentError, "Block of work is required" unless block_given?
9
+
10
+ slice_size = ((all_work.length + MAX_THREADS - 1) / MAX_THREADS)
11
+ threads = []
12
+ all_work.each_slice(slice_size) do |work_group|
13
+ threads << Thread.new { work_group.each(&block) }
14
+ end
15
+ threads.each(&:join)
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+ module Krane
3
+ class ContainerLogs
4
+ attr_reader :lines, :container_name
5
+
6
+ DEFAULT_LINE_LIMIT = 250
7
+
8
+ def initialize(parent_id:, container_name:, namespace:, context:, logger:)
9
+ @parent_id = parent_id
10
+ @container_name = container_name
11
+ @namespace = namespace
12
+ @context = context
13
+ @logger = logger
14
+ @lines = []
15
+ @next_print_index = 0
16
+ @printed_latest = false
17
+ end
18
+
19
+ def sync
20
+ new_logs = fetch_latest
21
+ return unless new_logs.present?
22
+ @lines += sort_and_deduplicate(new_logs)
23
+ end
24
+
25
+ def empty?
26
+ lines.empty?
27
+ end
28
+
29
+ def print_latest(prefix: false)
30
+ prefix_str = "[#{container_name}] " if prefix
31
+
32
+ lines[@next_print_index..-1].each do |msg|
33
+ @logger.info("#{prefix_str}#{msg}")
34
+ end
35
+
36
+ @next_print_index = lines.length
37
+ @printed_latest = true
38
+ end
39
+
40
+ def print_all
41
+ lines.each { |line| @logger.info("\t#{line}") }
42
+ end
43
+
44
+ def printing_started?
45
+ @printed_latest
46
+ end
47
+
48
+ private
49
+
50
+ def fetch_latest
51
+ cmd = ["logs", @parent_id, "--container=#{container_name}", "--timestamps"]
52
+ cmd << if @last_timestamp.present?
53
+ "--since-time=#{rfc3339_timestamp(@last_timestamp)}"
54
+ else
55
+ "--tail=#{DEFAULT_LINE_LIMIT}"
56
+ end
57
+ out, _err, _st = kubectl.run(*cmd, log_failure: false)
58
+ out.split("\n")
59
+ end
60
+
61
+ def kubectl
62
+ task_config = TaskConfig.new(@context, @namespace, @logger)
63
+ @kubectl ||= Kubectl.new(task_config: task_config, log_failure_by_default: false)
64
+ end
65
+
66
+ def rfc3339_timestamp(time)
67
+ time.strftime("%FT%T.%N%:z")
68
+ end
69
+
70
+ def sort_and_deduplicate(logs)
71
+ parsed_lines = logs.map { |line| split_timestamped_line(line) }
72
+ sorted_lines = parsed_lines.sort do |(timestamp1, _msg1), (timestamp2, _msg2)|
73
+ if timestamp1.nil?
74
+ -1
75
+ elsif timestamp2.nil?
76
+ 1
77
+ else
78
+ timestamp1 <=> timestamp2
79
+ end
80
+ end
81
+
82
+ deduped = []
83
+ sorted_lines.each do |timestamp, msg|
84
+ next if likely_duplicate?(timestamp)
85
+ @last_timestamp = timestamp if timestamp
86
+ deduped << msg
87
+ end
88
+ deduped
89
+ end
90
+
91
+ def split_timestamped_line(log_line)
92
+ timestamp, message = log_line.split(" ", 2)
93
+ [Time.parse(timestamp), message]
94
+ rescue ArgumentError
95
+ # Don't fail on unparsable timestamp
96
+ [nil, log_line]
97
+ end
98
+
99
+ def likely_duplicate?(timestamp)
100
+ return false unless @last_timestamp && timestamp
101
+ # The --since-time granularity the API server supports is not adequate to prevent duplicates
102
+ # This comparison takes the fractional seconds into account
103
+ timestamp <= @last_timestamp
104
+ end
105
+ end
106
+ end