kubernetes-deploy 0.25.0 → 0.26.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.buildkite/pipeline.nightly.yml +0 -9
  3. data/.buildkite/pipeline.yml +0 -9
  4. data/CHANGELOG.md +23 -0
  5. data/CONTRIBUTING.md +164 -0
  6. data/README.md +29 -104
  7. data/dev.yml +3 -3
  8. data/exe/kubernetes-deploy +32 -21
  9. data/exe/kubernetes-render +20 -12
  10. data/exe/kubernetes-restart +5 -1
  11. data/lib/kubernetes-deploy.rb +1 -0
  12. data/lib/kubernetes-deploy/bindings_parser.rb +20 -8
  13. data/lib/kubernetes-deploy/cluster_resource_discovery.rb +1 -1
  14. data/lib/kubernetes-deploy/container_logs.rb +6 -0
  15. data/lib/kubernetes-deploy/deploy_task.rb +85 -44
  16. data/lib/kubernetes-deploy/ejson_secret_provisioner.rb +38 -84
  17. data/lib/kubernetes-deploy/errors.rb +8 -0
  18. data/lib/kubernetes-deploy/kubeclient_builder.rb +52 -20
  19. data/lib/kubernetes-deploy/kubeclient_builder/kube_config.rb +1 -1
  20. data/lib/kubernetes-deploy/kubectl.rb +11 -10
  21. data/lib/kubernetes-deploy/kubernetes_resource.rb +47 -9
  22. data/lib/kubernetes-deploy/kubernetes_resource/custom_resource.rb +1 -1
  23. data/lib/kubernetes-deploy/kubernetes_resource/custom_resource_definition.rb +1 -1
  24. data/lib/kubernetes-deploy/kubernetes_resource/deployment.rb +1 -1
  25. data/lib/kubernetes-deploy/kubernetes_resource/horizontal_pod_autoscaler.rb +12 -4
  26. data/lib/kubernetes-deploy/kubernetes_resource/network_policy.rb +22 -0
  27. data/lib/kubernetes-deploy/kubernetes_resource/pod.rb +2 -0
  28. data/lib/kubernetes-deploy/kubernetes_resource/secret.rb +23 -0
  29. data/lib/kubernetes-deploy/label_selector.rb +42 -0
  30. data/lib/kubernetes-deploy/options_helper.rb +31 -15
  31. data/lib/kubernetes-deploy/remote_logs.rb +6 -1
  32. data/lib/kubernetes-deploy/renderer.rb +5 -1
  33. data/lib/kubernetes-deploy/resource_cache.rb +4 -1
  34. data/lib/kubernetes-deploy/restart_task.rb +22 -10
  35. data/lib/kubernetes-deploy/runner_task.rb +5 -3
  36. data/lib/kubernetes-deploy/version.rb +1 -1
  37. metadata +6 -2
@@ -29,6 +29,8 @@ module KubernetesDeploy
29
29
  MSG
30
30
 
31
31
  TIMEOUT_OVERRIDE_ANNOTATION = "kubernetes-deploy.shopify.io/timeout-override"
32
+ LAST_APPLIED_ANNOTATION = "kubectl.kubernetes.io/last-applied-configuration"
33
+ KUBECTL_OUTPUT_IS_SENSITIVE = false
32
34
 
33
35
  class << self
34
36
  def build(namespace:, context:, definition:, logger:, statsd_tags:, crd: nil)
@@ -37,12 +39,8 @@ module KubernetesDeploy
37
39
  if definition["kind"].blank?
38
40
  raise InvalidTemplateError.new("Template missing 'Kind'", content: definition.to_yaml)
39
41
  end
40
- begin
41
- if KubernetesDeploy.const_defined?(definition["kind"])
42
- klass = KubernetesDeploy.const_get(definition["kind"])
43
- return klass.new(**opts)
44
- end
45
- rescue NameError
42
+ if (klass = class_for_kind(definition["kind"]))
43
+ return klass.new(**opts)
46
44
  end
47
45
  if crd
48
46
  CustomResource.new(crd: crd, **opts)
@@ -53,6 +51,14 @@ module KubernetesDeploy
53
51
  end
54
52
  end
55
53
 
54
+ def class_for_kind(kind)
55
+ if KubernetesDeploy.const_defined?(kind)
56
+ KubernetesDeploy.const_get(kind) # rubocop:disable Sorbet/ConstantsFromStrings
57
+ end
58
+ rescue NameError
59
+ nil
60
+ end
61
+
56
62
  def timeout
57
63
  self::TIMEOUT
58
64
  end
@@ -101,14 +107,21 @@ module KubernetesDeploy
101
107
  Kubeclient::Resource.new(@definition)
102
108
  end
103
109
 
104
- def validate_definition(kubectl)
110
+ def validate_definition(kubectl, selector: nil)
105
111
  @validation_errors = []
112
+ validate_selector(selector) if selector
106
113
  validate_timeout_annotation
107
114
 
108
115
  command = ["create", "-f", file_path, "--dry-run", "--output=name"]
109
- _, err, st = kubectl.run(*command, log_failure: false)
116
+ _, err, st = kubectl.run(*command, log_failure: false, output_is_sensitive: kubectl_output_is_sensitive?)
110
117
  return true if st.success?
111
- @validation_errors << err
118
+ if kubectl_output_is_sensitive?
119
+ @validation_errors << <<-EOS
120
+ Validation for #{id} failed. Detailed information is unavailable as the raw error may contain sensitive data.
121
+ EOS
122
+ else
123
+ @validation_errors << err
124
+ end
112
125
  false
113
126
  end
114
127
 
@@ -124,6 +137,10 @@ module KubernetesDeploy
124
137
  "#{type}/#{name}"
125
138
  end
126
139
 
140
+ def <=>(other)
141
+ id <=> other.id
142
+ end
143
+
127
144
  def file_path
128
145
  file.path
129
146
  end
@@ -305,6 +322,10 @@ module KubernetesDeploy
305
322
  end
306
323
  end
307
324
 
325
+ def kubectl_output_is_sensitive?
326
+ self.class::KUBECTL_OUTPUT_IS_SENSITIVE
327
+ end
328
+
308
329
  class Event
309
330
  EVENT_SEPARATOR = "ENDEVENT--BEGINEVENT"
310
331
  FIELD_SEPARATOR = "ENDFIELD--BEGINFIELD"
@@ -388,6 +409,23 @@ module KubernetesDeploy
388
409
  @definition.dig("metadata", "annotations", TIMEOUT_OVERRIDE_ANNOTATION)
389
410
  end
390
411
 
412
+ def validate_selector(selector)
413
+ if labels.nil?
414
+ @validation_errors << "selector #{selector} passed in, but no labels were defined"
415
+ return
416
+ end
417
+
418
+ unless selector.to_h <= labels
419
+ label_name = 'label'.pluralize(labels.size)
420
+ label_string = LabelSelector.new(labels).to_s
421
+ @validation_errors << "selector #{selector} does not match #{label_name} #{label_string}"
422
+ end
423
+ end
424
+
425
+ def labels
426
+ @definition.dig("metadata", "labels")
427
+ end
428
+
391
429
  def file
392
430
  @file ||= create_definition_tempfile
393
431
  end
@@ -62,7 +62,7 @@ module KubernetesDeploy
62
62
  kind
63
63
  end
64
64
 
65
- def validate_definition(kubectl)
65
+ def validate_definition(*)
66
66
  super
67
67
 
68
68
  @crd.validate_rollout_conditions
@@ -66,7 +66,7 @@ module KubernetesDeploy
66
66
  @rollout_conditions = nil
67
67
  end
68
68
 
69
- def validate_definition(_)
69
+ def validate_definition(*)
70
70
  super
71
71
 
72
72
  validate_rollout_conditions
@@ -92,7 +92,7 @@ module KubernetesDeploy
92
92
  progress_condition.present? ? deploy_failing_to_progress? : super
93
93
  end
94
94
 
95
- def validate_definition(_)
95
+ def validate_definition(*)
96
96
  super
97
97
 
98
98
  unless REQUIRED_ROLLOUT_TYPES.include?(required_rollout) || percent?(required_rollout)
@@ -2,16 +2,17 @@
2
2
  module KubernetesDeploy
3
3
  class HorizontalPodAutoscaler < KubernetesResource
4
4
  TIMEOUT = 3.minutes
5
- RECOVERABLE_CONDITIONS = %w(ScalingDisabled FailedGet)
5
+ RECOVERABLE_CONDITION_PREFIX = "FailedGet"
6
6
 
7
7
  def deploy_succeeded?
8
- scaling_active_condition["status"] == "True"
8
+ scaling_active_condition["status"] == "True" || scaling_disabled?
9
9
  end
10
10
 
11
11
  def deploy_failed?
12
12
  return false unless exists?
13
- recoverable = RECOVERABLE_CONDITIONS.any? { |c| scaling_active_condition.fetch("reason", "").start_with?(c) }
14
- scaling_active_condition["status"] == "False" && !recoverable
13
+ return false if scaling_disabled?
14
+ scaling_active_condition["status"] == "False" &&
15
+ !scaling_active_condition.fetch("reason", "").start_with?(RECOVERABLE_CONDITION_PREFIX)
15
16
  end
16
17
 
17
18
  def kubectl_resource_type
@@ -21,6 +22,8 @@ module KubernetesDeploy
21
22
  def status
22
23
  if !exists?
23
24
  super
25
+ elsif scaling_disabled?
26
+ "ScalingDisabled"
24
27
  elsif deploy_succeeded?
25
28
  "Configured"
26
29
  elsif scaling_active_condition.present? || able_to_scale_condition.present?
@@ -42,6 +45,11 @@ module KubernetesDeploy
42
45
 
43
46
  private
44
47
 
48
+ def scaling_disabled?
49
+ scaling_active_condition["status"] == "False" &&
50
+ scaling_active_condition["reason"] == "ScalingDisabled"
51
+ end
52
+
45
53
  def conditions
46
54
  @instance_data.dig("status", "conditions") || []
47
55
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ module KubernetesDeploy
3
+ class NetworkPolicy < KubernetesResource
4
+ TIMEOUT = 30.seconds
5
+
6
+ def status
7
+ exists? ? "Created" : "Not Found"
8
+ end
9
+
10
+ def deploy_succeeded?
11
+ exists?
12
+ end
13
+
14
+ def deploy_failed?
15
+ false
16
+ end
17
+
18
+ def timeout_message
19
+ UNUSUAL_FAILURE_MESSAGE
20
+ end
21
+ end
22
+ end
@@ -9,6 +9,8 @@ module KubernetesDeploy
9
9
  Preempting
10
10
  )
11
11
 
12
+ attr_accessor :stream_logs
13
+
12
14
  def initialize(namespace:, context:, definition:, logger:,
13
15
  statsd_tags: nil, parent: nil, deploy_started_at: nil, stream_logs: false)
14
16
  @parent = parent
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ module KubernetesDeploy
3
+ class Secret < KubernetesResource
4
+ TIMEOUT = 30.seconds
5
+ KUBECTL_OUTPUT_IS_SENSITIVE = true
6
+
7
+ def status
8
+ exists? ? "Available" : "Not Found"
9
+ end
10
+
11
+ def deploy_succeeded?
12
+ exists?
13
+ end
14
+
15
+ def deploy_failed?
16
+ false
17
+ end
18
+
19
+ def timeout_message
20
+ UNUSUAL_FAILURE_MESSAGE
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KubernetesDeploy
4
+ class LabelSelector
5
+ def self.parse(string)
6
+ selector = {}
7
+
8
+ string.split(',').each do |kvp|
9
+ key, value = kvp.split('=', 2)
10
+
11
+ if key.blank?
12
+ raise ArgumentError, "key is blank"
13
+ end
14
+
15
+ if key.end_with?("!")
16
+ raise ArgumentError, "!= selectors are not supported"
17
+ end
18
+
19
+ if value&.start_with?("=")
20
+ raise ArgumentError, "== selectors are not supported"
21
+ end
22
+
23
+ selector[key] = value
24
+ end
25
+
26
+ new(selector)
27
+ end
28
+
29
+ def initialize(hash)
30
+ @selector = hash
31
+ end
32
+
33
+ def to_h
34
+ @selector
35
+ end
36
+
37
+ def to_s
38
+ return "" if @selector.nil?
39
+ @selector.map { |k, v| "#{k}=#{v}" }.join(",")
40
+ end
41
+ end
42
+ end
@@ -2,25 +2,41 @@
2
2
 
3
3
  module KubernetesDeploy
4
4
  module OptionsHelper
5
- def self.default_and_check_template_dir(template_dir)
6
- if !template_dir && ENV.key?("ENVIRONMENT")
7
- template_dir = "config/deploy/#{ENV['ENVIRONMENT']}"
8
- end
5
+ class OptionsError < StandardError; end
9
6
 
10
- if !template_dir || template_dir.empty?
11
- puts "Template directory is unknown. " \
12
- "Either specify --template-dir argument or set $ENVIRONMENT to use config/deploy/$ENVIRONMENT " \
13
- + "as a default path."
14
- exit(1)
7
+ STDIN_TEMP_FILE = "from_stdin.yml.erb"
8
+ class << self
9
+ def with_validated_template_dir(template_dir)
10
+ if template_dir == '-'
11
+ Dir.mktmpdir("kubernetes-deploy") do |dir|
12
+ template_dir_from_stdin(temp_dir: dir)
13
+ yield dir
14
+ end
15
+ else
16
+ yield default_template_dir(template_dir)
17
+ end
15
18
  end
16
19
 
17
- template_dir
18
- end
20
+ private
21
+
22
+ def default_template_dir(template_dir)
23
+ if ENV.key?("ENVIRONMENT")
24
+ template_dir = File.join("config", "deploy", ENV['ENVIRONMENT'])
25
+ end
26
+
27
+ if !template_dir || template_dir.empty?
28
+ raise OptionsError, "Template directory is unknown. " \
29
+ "Either specify --template-dir argument or set $ENVIRONMENT to use config/deploy/$ENVIRONMENT " \
30
+ "as a default path."
31
+ end
32
+
33
+ template_dir
34
+ end
19
35
 
20
- def self.revision_from_environment
21
- ENV.fetch('REVISION') do
22
- puts "ENV['REVISION'] is missing. Please specify the commit SHA"
23
- exit 1
36
+ def template_dir_from_stdin(temp_dir:)
37
+ File.open(File.join(temp_dir, STDIN_TEMP_FILE), 'w+') { |f| f.print($stdin.read) }
38
+ rescue IOError, Errno::ENOENT => e
39
+ raise OptionsError, e.message
24
40
  end
25
41
  end
26
42
  end
@@ -28,7 +28,12 @@ module KubernetesDeploy
28
28
  end
29
29
 
30
30
  def print_latest
31
- @container_logs.each { |cl| cl.print_latest(prefix: @container_logs.length > 1) }
31
+ @container_logs.each do |cl|
32
+ unless cl.printing_started?
33
+ @logger.info("Streaming logs from #{@parent_id} container '#{cl.container_name}':")
34
+ end
35
+ cl.print_latest(prefix: @container_logs.length > 1)
36
+ end
32
37
  end
33
38
 
34
39
  def print_all(prevent_duplicate: true)
@@ -24,7 +24,11 @@ module KubernetesDeploy
24
24
  @logger = logger
25
25
  @bindings = bindings
26
26
  # Max length of podname is only 63chars so try to save some room by truncating sha to 8 chars
27
- @id = current_sha[0...8] + "-#{SecureRandom.hex(4)}" if current_sha
27
+ @id = if ENV["TASK_ID"]
28
+ ENV["TASK_ID"]
29
+ elsif current_sha
30
+ current_sha[0...8] + "-#{SecureRandom.hex(4)}"
31
+ end
28
32
  end
29
33
 
30
34
  def render_template(filename, raw_template)
@@ -50,7 +50,10 @@ module KubernetesDeploy
50
50
  end
51
51
 
52
52
  def fetch_by_kind(kind)
53
- raw_json, _, st = @kubectl.run("get", kind, "--chunk-size=0", "--output=json", attempts: 5)
53
+ resource_class = KubernetesResource.class_for_kind(kind)
54
+ output_is_sensitive = resource_class.nil? ? false : resource_class::KUBECTL_OUTPUT_IS_SENSITIVE
55
+ raw_json, _, st = @kubectl.run("get", kind, "--chunk-size=0", attempts: 5, output: "json",
56
+ output_is_sensitive: output_is_sensitive)
54
57
  raise KubectlError unless st.success?
55
58
 
56
59
  instances = {}
@@ -5,8 +5,6 @@ require 'kubernetes-deploy/kubectl'
5
5
 
6
6
  module KubernetesDeploy
7
7
  class RestartTask
8
- include KubernetesDeploy::KubeclientBuilder
9
-
10
8
  class FatalRestartError < FatalDeploymentError; end
11
9
 
12
10
  class RestartAPIError < FatalRestartError
@@ -34,13 +32,13 @@ module KubernetesDeploy
34
32
  false
35
33
  end
36
34
 
37
- def perform!(deployments_names = nil)
35
+ def perform!(deployments_names = nil, selector: nil)
38
36
  start = Time.now.utc
39
37
  @logger.reset
40
38
 
41
39
  @logger.phase_heading("Initializing restart")
42
40
  verify_namespace
43
- deployments = identify_target_deployments(deployments_names)
41
+ deployments = identify_target_deployments(deployments_names, selector: selector)
44
42
  if kubectl.server_version < Gem::Version.new(MIN_KUBE_VERSION)
45
43
  @logger.warn(KubernetesDeploy::Errors.server_version_warning(kubectl.server_version))
46
44
  end
@@ -76,17 +74,27 @@ module KubernetesDeploy
76
74
  %W(namespace:#{@namespace} context:#{@context} status:#{status} deployments:#{deployments.to_a.length}})
77
75
  end
78
76
 
79
- def identify_target_deployments(deployment_names)
77
+ def identify_target_deployments(deployment_names, selector: nil)
80
78
  if deployment_names.nil?
81
- @logger.info("Configured to restart all deployments with the `#{ANNOTATION}` annotation")
82
- deployments = v1beta1_kubeclient.get_deployments(namespace: @namespace)
83
- .select { |d| d.metadata.annotations[ANNOTATION] }
79
+ deployments = if selector.nil?
80
+ @logger.info("Configured to restart all deployments with the `#{ANNOTATION}` annotation")
81
+ v1beta1_kubeclient.get_deployments(namespace: @namespace)
82
+ else
83
+ selector_string = selector.to_s
84
+ @logger.info(
85
+ "Configured to restart all deployments with the `#{ANNOTATION}` annotation and #{selector_string} selector"
86
+ )
87
+ v1beta1_kubeclient.get_deployments(namespace: @namespace, label_selector: selector_string)
88
+ end
89
+ deployments.select! { |d| d.metadata.annotations[ANNOTATION] }
84
90
 
85
91
  if deployments.none?
86
92
  raise FatalRestartError, "no deployments with the `#{ANNOTATION}` annotation found in namespace #{@namespace}"
87
93
  end
88
94
  elsif deployment_names.empty?
89
95
  raise FatalRestartError, "Configured to restart deployments by name, but list of names was blank"
96
+ elsif !selector.nil?
97
+ raise FatalRestartError, "Can't specify deployment names and selector at the same time"
90
98
  else
91
99
  deployment_names = deployment_names.uniq
92
100
  list = deployment_names.join(', ')
@@ -166,7 +174,7 @@ module KubernetesDeploy
166
174
  end
167
175
 
168
176
  def kubeclient
169
- @kubeclient ||= build_v1_kubeclient(@context)
177
+ @kubeclient ||= kubeclient_builder.build_v1_kubeclient(@context)
170
178
  end
171
179
 
172
180
  def kubectl
@@ -174,7 +182,11 @@ module KubernetesDeploy
174
182
  end
175
183
 
176
184
  def v1beta1_kubeclient
177
- @v1beta1_kubeclient ||= build_v1beta1_kubeclient(@context)
185
+ @v1beta1_kubeclient ||= kubeclient_builder.build_v1beta1_kubeclient(@context)
186
+ end
187
+
188
+ def kubeclient_builder
189
+ @kubeclient_builder ||= KubeclientBuilder.new
178
190
  end
179
191
  end
180
192
  end