kubernetes-deploy 0.25.0 → 0.26.0

Sign up to get free protection for your applications and to get access to all the features.
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