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
@@ -7,85 +7,64 @@ require 'kubernetes-deploy/kubectl'
7
7
  module KubernetesDeploy
8
8
  class EjsonSecretError < FatalDeploymentError
9
9
  def initialize(msg)
10
- super("Creation of Kubernetes secrets from ejson failed: #{msg}")
10
+ super("Generation of Kubernetes secrets from ejson failed: #{msg}")
11
11
  end
12
12
  end
13
13
 
14
14
  class EjsonSecretProvisioner
15
- MANAGEMENT_ANNOTATION = "kubernetes-deploy.shopify.io/ejson-secret"
16
- MANAGED_SECRET_EJSON_KEY = "kubernetes_secrets"
15
+ EJSON_SECRET_ANNOTATION = "kubernetes-deploy.shopify.io/ejson-secret"
16
+ EJSON_SECRET_KEY = "kubernetes_secrets"
17
17
  EJSON_SECRETS_FILE = "secrets.ejson"
18
18
  EJSON_KEYS_SECRET = "ejson-keys"
19
19
 
20
- def initialize(namespace:, context:, template_dir:, logger:, prune: true)
20
+ def initialize(namespace:, context:, template_dir:, logger:, statsd_tags:, selector: nil)
21
21
  @namespace = namespace
22
22
  @context = context
23
23
  @ejson_file = "#{template_dir}/#{EJSON_SECRETS_FILE}"
24
24
  @logger = logger
25
- @prune = prune
25
+ @statsd_tags = statsd_tags
26
+ @selector = selector
26
27
  @kubectl = Kubectl.new(
27
28
  namespace: @namespace,
28
29
  context: @context,
29
30
  logger: @logger,
30
31
  log_failure_by_default: false,
31
- output_is_sensitive: true # output may contain ejson secrets
32
+ output_is_sensitive_default: true # output may contain ejson secrets
32
33
  )
33
34
  end
34
35
 
35
- def secret_changes_required?
36
- File.exist?(@ejson_file) || managed_secrets_exist?
36
+ def resources
37
+ @resources ||= build_secrets
37
38
  end
38
39
 
39
- def run
40
- create_secrets
41
- prune_managed_secrets if @prune
40
+ def ejson_keys_secret
41
+ @ejson_keys_secret ||= begin
42
+ out, err, st = @kubectl.run("get", "secret", EJSON_KEYS_SECRET, output: "json",
43
+ raise_if_not_found: true, attempts: 3, output_is_sensitive: true, log_failure: true)
44
+ unless st.success?
45
+ raise EjsonSecretError, "Error retrieving Secret/#{EJSON_KEYS_SECRET}: #{err}"
46
+ end
47
+ JSON.parse(out)
48
+ end
42
49
  end
43
50
 
44
51
  private
45
52
 
46
- def create_secrets
53
+ def build_secrets
54
+ return [] unless File.exist?(@ejson_file)
47
55
  with_decrypted_ejson do |decrypted|
48
- secrets = decrypted[MANAGED_SECRET_EJSON_KEY]
56
+ secrets = decrypted[EJSON_SECRET_KEY]
49
57
  unless secrets.present?
50
- @logger.warn("#{EJSON_SECRETS_FILE} does not have key #{MANAGED_SECRET_EJSON_KEY}."\
58
+ @logger.warn("#{EJSON_SECRETS_FILE} does not have key #{EJSON_SECRET_KEY}."\
51
59
  "No secrets will be created.")
52
- return
60
+ return []
53
61
  end
54
62
 
55
- secrets.each do |secret_name, secret_spec|
63
+ secrets.map do |secret_name, secret_spec|
56
64
  validate_secret_spec(secret_name, secret_spec)
57
- create_or_update_secret(secret_name, secret_spec["_type"], secret_spec["data"])
65
+ generate_secret_resource(secret_name, secret_spec["_type"], secret_spec["data"])
58
66
  end
59
- @logger.summary.add_action("created/updated #{secrets.length} #{'secret'.pluralize(secrets.length)}")
60
- end
61
- end
62
-
63
- def prune_managed_secrets
64
- ejson_secret_names = encrypted_ejson.fetch(MANAGED_SECRET_EJSON_KEY, {}).keys
65
- live_secrets = run_kubectl_json("get", "secrets")
66
-
67
- prune_count = 0
68
- live_secrets.each do |secret|
69
- secret_name = secret["metadata"]["name"]
70
- next unless secret_managed?(secret)
71
- next if ejson_secret_names.include?(secret_name)
72
-
73
- @logger.info("Pruning secret #{secret_name}")
74
- prune_count += 1
75
- out, err, st = @kubectl.run("delete", "secret", secret_name)
76
- @logger.debug(out)
77
- raise EjsonSecretError, "Failed to prune secrets" unless st.success?
78
67
  end
79
- @logger.summary.add_action("pruned #{prune_count} #{'secret'.pluralize(prune_count)}") if prune_count > 0
80
- end
81
-
82
- def managed_secrets_exist?
83
- all_secrets = run_kubectl_json("get", "secrets")
84
- all_secrets.any? { |secret| secret_managed?(secret) }
85
- end
86
-
87
- def secret_managed?(secret)
88
- secret["metadata"].fetch("annotations", {}).key?(MANAGEMENT_ANNOTATION)
89
68
  end
90
69
 
91
70
  def encrypted_ejson
@@ -110,23 +89,7 @@ module KubernetesDeploy
110
89
  end
111
90
  end
112
91
 
113
- def create_or_update_secret(secret_name, secret_type, data)
114
- msg = secret_exists?(secret_name) ? "Updating secret #{secret_name}" : "Creating secret #{secret_name}"
115
- @logger.info(msg)
116
-
117
- secret_yaml = generate_secret_yaml(secret_name, secret_type, data)
118
- file = Tempfile.new(secret_name)
119
- file.write(secret_yaml)
120
- file.close
121
-
122
- out, err, st = @kubectl.run("apply", "--filename=#{file.path}")
123
- @logger.debug(out)
124
- raise EjsonSecretError, "Failed to create or update secrets" unless st.success?
125
- ensure
126
- file&.unlink
127
- end
128
-
129
- def generate_secret_yaml(secret_name, secret_type, data)
92
+ def generate_secret_resource(secret_name, secret_type, data)
130
93
  unless data.is_a?(Hash) && data.values.all? { |v| v.is_a?(String) } # Secret data is map[string]string
131
94
  raise EjsonSecretError, "Data for secret #{secret_name} was invalid. Only key-value pairs are permitted."
132
95
  end
@@ -137,24 +100,25 @@ module KubernetesDeploy
137
100
  encoded[secret_key] = Base64.strict_encode64(value)
138
101
  end
139
102
 
103
+ labels = { "name" => secret_name }
104
+ labels.reverse_merge!(@selector) if @selector
105
+
140
106
  secret = {
141
107
  'kind' => 'Secret',
142
108
  'apiVersion' => 'v1',
143
109
  'type' => secret_type,
144
110
  'metadata' => {
145
111
  "name" => secret_name,
146
- "labels" => { "name" => secret_name },
112
+ "labels" => labels,
147
113
  "namespace" => @namespace,
148
- "annotations" => { MANAGEMENT_ANNOTATION => "true" },
114
+ "annotations" => { EJSON_SECRET_ANNOTATION => "true" },
149
115
  },
150
116
  "data" => encoded_data,
151
117
  }
152
- secret.to_yaml
153
- end
154
118
 
155
- def secret_exists?(secret_name)
156
- _out, _err, st = @kubectl.run("get", "secret", secret_name)
157
- st.success?
119
+ KubernetesDeploy::Secret.build(
120
+ namespace: @namespace, context: @context, logger: @logger, definition: secret, statsd_tags: @statsd_tags,
121
+ )
158
122
  end
159
123
 
160
124
  def load_ejson_from_file
@@ -175,33 +139,23 @@ module KubernetesDeploy
175
139
  end
176
140
 
177
141
  def decrypt_ejson(key_dir)
178
- @logger.info("Decrypting #{EJSON_SECRETS_FILE}")
179
142
  # ejson seems to dump both errors and output to STDOUT
180
143
  out_err, st = Open3.capture2e("EJSON_KEYDIR=#{key_dir} ejson decrypt #{@ejson_file}")
181
144
  raise EjsonSecretError, out_err unless st.success?
182
145
  JSON.parse(out_err)
183
- rescue JSON::ParserError => e
146
+ rescue JSON::ParserError
184
147
  raise EjsonSecretError, "Failed to parse decrypted ejson"
185
148
  end
186
149
 
187
150
  def fetch_private_key_from_secret
188
- @logger.info("Fetching ejson private key from secret #{EJSON_KEYS_SECRET}")
189
-
190
- secret = run_kubectl_json("get", "secret", EJSON_KEYS_SECRET)
191
- encoded_private_key = secret["data"][public_key]
151
+ encoded_private_key = ejson_keys_secret["data"][public_key]
192
152
  unless encoded_private_key
193
153
  raise EjsonSecretError, "Private key for #{public_key} not found in #{EJSON_KEYS_SECRET} secret"
194
154
  end
195
155
 
196
156
  Base64.decode64(encoded_private_key)
197
- end
198
-
199
- def run_kubectl_json(*args)
200
- args += ["--output=json"]
201
- out, err, st = @kubectl.run(*args)
202
- raise EjsonSecretError, err unless st.success?
203
- result = JSON.parse(out)
204
- result.fetch('items', result)
157
+ rescue Kubectl::ResourceNotFoundError
158
+ raise EjsonSecretError, "Secret/#{EJSON_KEYS_SECRET} is required to decrypt EJSON and could not be found"
205
159
  end
206
160
  end
207
161
  end
@@ -23,6 +23,14 @@ module KubernetesDeploy
23
23
 
24
24
  class DeploymentTimeoutError < FatalDeploymentError; end
25
25
 
26
+ class EjsonPrunableError < FatalDeploymentError
27
+ def initialize
28
+ super("Found #{KubernetesResource::LAST_APPLIED_ANNOTATION} annotation on " \
29
+ "#{EjsonSecretProvisioner::EJSON_KEYS_SECRET} secret. " \
30
+ "kubernetes-deploy will not continue since it is extremely unlikely that this secret should be pruned.")
31
+ end
32
+ end
33
+
26
34
  module Errors
27
35
  extend self
28
36
  def server_version_warning(server_version)
@@ -3,25 +3,33 @@ require 'kubeclient'
3
3
  require 'kubernetes-deploy/kubeclient_builder/kube_config'
4
4
 
5
5
  module KubernetesDeploy
6
- module KubeclientBuilder
6
+ class KubeclientBuilder
7
7
  class ContextMissingError < FatalDeploymentError
8
- def initialize(context_name)
8
+ def initialize(context_name, kubeconfig)
9
9
  super("`#{context_name}` context must be configured in your " \
10
- "KUBECONFIG file(s) (#{ENV['KUBECONFIG']}).")
10
+ "KUBECONFIG file(s) (#{kubeconfig}).")
11
11
  end
12
12
  end
13
13
 
14
- private
14
+ def self.kubeconfig
15
+ new.kubeconfig
16
+ end
17
+
18
+ attr_reader :kubeconfig
19
+
20
+ def initialize(kubeconfig: ENV["KUBECONFIG"])
21
+ @kubeconfig = kubeconfig || "#{Dir.home}/.kube/config"
22
+ end
15
23
 
16
24
  def build_v1_kubeclient(context)
17
- _build_kubeclient(
25
+ build_kubeclient(
18
26
  api_version: "v1",
19
27
  context: context
20
28
  )
21
29
  end
22
30
 
23
31
  def build_v1beta1_kubeclient(context)
24
- _build_kubeclient(
32
+ build_kubeclient(
25
33
  api_version: "v1beta1",
26
34
  context: context,
27
35
  endpoint_path: "/apis/extensions/"
@@ -29,7 +37,7 @@ module KubernetesDeploy
29
37
  end
30
38
 
31
39
  def build_batch_v1beta1_kubeclient(context)
32
- _build_kubeclient(
40
+ build_kubeclient(
33
41
  api_version: "v1beta1",
34
42
  context: context,
35
43
  endpoint_path: "/apis/batch/"
@@ -37,7 +45,7 @@ module KubernetesDeploy
37
45
  end
38
46
 
39
47
  def build_batch_v1_kubeclient(context)
40
- _build_kubeclient(
48
+ build_kubeclient(
41
49
  api_version: "v1",
42
50
  context: context,
43
51
  endpoint_path: "/apis/batch/"
@@ -45,7 +53,7 @@ module KubernetesDeploy
45
53
  end
46
54
 
47
55
  def build_policy_v1beta1_kubeclient(context)
48
- _build_kubeclient(
56
+ build_kubeclient(
49
57
  api_version: "v1beta1",
50
58
  context: context,
51
59
  endpoint_path: "/apis/policy/"
@@ -53,7 +61,7 @@ module KubernetesDeploy
53
61
  end
54
62
 
55
63
  def build_apps_v1beta1_kubeclient(context)
56
- _build_kubeclient(
64
+ build_kubeclient(
57
65
  api_version: "v1beta1",
58
66
  context: context,
59
67
  endpoint_path: "/apis/apps"
@@ -61,7 +69,7 @@ module KubernetesDeploy
61
69
  end
62
70
 
63
71
  def build_apiextensions_v1beta1_kubeclient(context)
64
- _build_kubeclient(
72
+ build_kubeclient(
65
73
  api_version: "v1beta1",
66
74
  context: context,
67
75
  endpoint_path: "/apis/apiextensions.k8s.io"
@@ -69,7 +77,7 @@ module KubernetesDeploy
69
77
  end
70
78
 
71
79
  def build_autoscaling_v1_kubeclient(context)
72
- _build_kubeclient(
80
+ build_kubeclient(
73
81
  api_version: "v2beta1",
74
82
  context: context,
75
83
  endpoint_path: "/apis/autoscaling"
@@ -77,19 +85,48 @@ module KubernetesDeploy
77
85
  end
78
86
 
79
87
  def build_rbac_v1_kubeclient(context)
80
- _build_kubeclient(
88
+ build_kubeclient(
81
89
  api_version: "v1",
82
90
  context: context,
83
91
  endpoint_path: "/apis/rbac.authorization.k8s.io"
84
92
  )
85
93
  end
86
94
 
87
- def _build_kubeclient(api_version:, context:, endpoint_path: nil)
95
+ def build_networking_v1_kubeclient(context)
96
+ build_kubeclient(
97
+ api_version: "v1",
98
+ context: context,
99
+ endpoint_path: "/apis/networking.k8s.io"
100
+ )
101
+ end
102
+
103
+ def config_files
104
+ # Split the list by colon for Linux and Mac, and semicolon for Windows.
105
+ kubeconfig.split(/[:;]/).map!(&:strip).reject(&:empty?)
106
+ end
107
+
108
+ def validate_config_files
109
+ errors = []
110
+ if config_files.empty?
111
+ errors << "Kube config file name(s) not set in $KUBECONFIG"
112
+ else
113
+ config_files.each do |f|
114
+ unless File.file?(f)
115
+ errors << "Kube config not found at #{f}"
116
+ end
117
+ end
118
+ end
119
+ errors
120
+ end
121
+
122
+ private
123
+
124
+ def build_kubeclient(api_version:, context:, endpoint_path: nil)
88
125
  # Find a context defined in kube conf files that matches the input context by name
89
126
  configs = config_files.map { |f| KubeConfig.read(f) }
90
127
  config = configs.find { |c| c.contexts.include?(context) }
91
128
 
92
- raise ContextMissingError, context unless config
129
+ raise ContextMissingError.new(context, kubeconfig) unless config
93
130
 
94
131
  kube_context = config.context(context)
95
132
  client = Kubeclient::Client.new(
@@ -105,10 +142,5 @@ module KubernetesDeploy
105
142
  client.discover
106
143
  client
107
144
  end
108
-
109
- def config_files
110
- # Split the list by colon for Linux and Mac, and semicolon for Windows.
111
- ENV.fetch("KUBECONFIG").split(/[:;]/).map!(&:strip).reject(&:empty?)
112
- end
113
145
  end
114
146
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'googleauth'
4
4
  module KubernetesDeploy
5
- module KubeclientBuilder
5
+ class KubeclientBuilder
6
6
  class KubeConfig < Kubeclient::Config
7
7
  def self.read(filename)
8
8
  parsed = YAML.safe_load(File.read(filename), [Date, Time])
@@ -8,43 +8,48 @@ module KubernetesDeploy
8
8
  class ResourceNotFoundError < StandardError; end
9
9
 
10
10
  def initialize(namespace:, context:, logger:, log_failure_by_default:, default_timeout: DEFAULT_TIMEOUT,
11
- output_is_sensitive: false)
11
+ output_is_sensitive_default: false)
12
+ @kubeconfig = KubeclientBuilder.kubeconfig
12
13
  @namespace = namespace
13
14
  @context = context
14
15
  @logger = logger
15
16
  @log_failure_by_default = log_failure_by_default
16
17
  @default_timeout = default_timeout
17
- @output_is_sensitive = output_is_sensitive
18
+ @output_is_sensitive_default = output_is_sensitive_default
18
19
 
19
20
  raise ArgumentError, "namespace is required" if namespace.blank?
20
21
  raise ArgumentError, "context is required" if context.blank?
21
22
  end
22
23
 
23
- def run(*args, log_failure: nil, use_context: true, use_namespace: true, raise_if_not_found: false, attempts: 1)
24
+ def run(*args, log_failure: nil, use_context: true, use_namespace: true, output: nil,
25
+ raise_if_not_found: false, attempts: 1, output_is_sensitive: nil)
24
26
  log_failure = @log_failure_by_default if log_failure.nil?
27
+ output_is_sensitive = @output_is_sensitive_default if output_is_sensitive.nil?
25
28
 
26
29
  args = args.unshift("kubectl")
30
+ args.push("--kubeconfig=#{@kubeconfig}")
27
31
  args.push("--namespace=#{@namespace}") if use_namespace
28
32
  args.push("--context=#{@context}") if use_context
33
+ args.push("--output=#{output}") if output
29
34
  args.push("--request-timeout=#{@default_timeout}") if @default_timeout
30
35
  out, err, st = nil
31
36
 
32
37
  (1..attempts).to_a.each do |attempt|
33
38
  @logger.debug("Running command (attempt #{attempt}): #{args.join(' ')}")
34
39
  out, err, st = Open3.capture3(*args)
35
- @logger.debug("Kubectl out: " + out.gsub(/\s+/, ' ')) unless output_is_sensitive?
40
+ @logger.debug("Kubectl out: " + out.gsub(/\s+/, ' ')) unless output_is_sensitive
36
41
 
37
42
  break if st.success?
38
43
 
39
44
  if log_failure
40
45
  @logger.warn("The following command failed (attempt #{attempt}/#{attempts}): #{Shellwords.join(args)}")
41
- @logger.warn(err) unless output_is_sensitive?
46
+ @logger.warn(err) unless output_is_sensitive
42
47
  end
43
48
 
44
49
  if err.match(NOT_FOUND_ERROR_TEXT)
45
50
  raise(ResourceNotFoundError, err) if raise_if_not_found
46
51
  else
47
- @logger.debug("Kubectl err: #{err}") unless output_is_sensitive?
52
+ @logger.debug("Kubectl err: #{err}") unless output_is_sensitive
48
53
  StatsD.increment('kubectl.error', 1, tags: { context: @context, namespace: @namespace, cmd: args[1] })
49
54
  end
50
55
  sleep(retry_delay(attempt)) unless attempt == attempts
@@ -76,10 +81,6 @@ module KubernetesDeploy
76
81
 
77
82
  private
78
83
 
79
- def output_is_sensitive?
80
- @output_is_sensitive
81
- end
82
-
83
84
  def extract_version_info_from_kubectl_response(response)
84
85
  info = {}
85
86
  response.each_line do |l|