tobsch-krane 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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