kubernetes-deploy 0.3.4 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 243574658a49fa135a24916b5ab9144d1350e3a5
4
- data.tar.gz: 8ac19f9d603838a2791da5bfe1c58222a4d082c5
3
+ metadata.gz: a5719744341ee174982a0aeec721aca1fac39154
4
+ data.tar.gz: 4d705c38c73e5de46b428b2475ad3f2dcc19bd48
5
5
  SHA512:
6
- metadata.gz: 357d21eb05b6f7ae579db5ab5749ff5dc5eac8e3d6e682735e8dfbdef3ec9e5fa1c6324ac68f9fc0b50a7d125bf5d7297549ac001f70f204b92dc4560d1c5b12
7
- data.tar.gz: 88e3506ab13ecbe080b155ecaf0bd825de3987235ab2fb62058b420ba722c299c5a12060d4ada535740cd6b40bcaf0cf969c927829a4e971a0eb30f4262d3143
6
+ metadata.gz: f97774306df820422fd5fa2cb91408e0f89e50f9e2257b003d1026a80df060db3a7e844b9a806a44b2552c8b4d88d960396f38ac99b87663478b4e6c4f3972ba
7
+ data.tar.gz: 85602d28ddb571043f4ab89239b90b6777197ca90c2a37565a78630042507dc98d493e36f0c24fc7b944485892b8a25d0565655a32f38163fb94c223fe858fd4
data/Gemfile CHANGED
@@ -4,5 +4,5 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
6
  gem 'pry'
7
- gem 'kubeclient'
8
7
  gem 'rubocop'
8
+ gem 'timecop'
data/README.md CHANGED
@@ -29,9 +29,16 @@ Requirements:
29
29
  - kubectl 1.5.1+ binary must be available in your path
30
30
  - `ENV['KUBECONFIG']` must point to a valid kubeconfig file that includes the context you want to deploy to
31
31
  - The target namespace must already exist in the target context
32
- - `ENV['GOOGLE_APPLICATION_CREDENTIALS']` must point to the credentials for an authenticated service account if your user's auth provider is gcp
32
+ - `ENV['GOOGLE_APPLICATION_CREDENTIALS']` must point to the credentials for an authenticated service account if your user's auth provider is GCP
33
33
  - `ENV['ENVIRONMENT']` must be set to use the default template path (`config/deploy/$ENVIRONMENT`) in the absence of the `--template-dir=DIR` option
34
34
 
35
+ The tool also provides a task for restarting all of the pods in one or more deployments.
36
+ It triggers the restart by touching the `RESTARTED_AT` environment variable in the deployment's podSpec.
37
+ The rollout strategy defined for each deployment will be respected by the restart.
38
+
39
+ The following command will restart all pods in the `web` and `jobs` deployments:
40
+
41
+ `kubernetes-restart <kube namespace> <kube context> --deployments=web,jobs`
35
42
 
36
43
  ## Development
37
44
 
@@ -7,8 +7,12 @@ require 'optparse'
7
7
 
8
8
  skip_wait = false
9
9
  template_dir = nil
10
+ allow_protected_ns = false
11
+ prune = true
10
12
  ARGV.options do |opts|
11
13
  opts.on("--skip-wait") { skip_wait = true }
14
+ opts.on("--allow-protected-ns") { allow_protected_ns = true }
15
+ opts.on("--no-prune") { prune = false }
12
16
  opts.on("--template-dir=DIR") { |v| template_dir = v }
13
17
  opts.parse!
14
18
  end
@@ -35,6 +39,8 @@ KubernetesDeploy::Runner.with_friendly_errors do
35
39
  current_sha: revision,
36
40
  template_dir: template_dir,
37
41
  wait_for_completion: !skip_wait,
42
+ allow_protected_ns: allow_protected_ns,
43
+ prune: prune,
38
44
  )
39
45
  runner.run
40
46
  end
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'optparse'
5
+
6
+ require 'kubernetes-deploy'
7
+ require 'kubernetes-deploy/restart_task'
8
+
9
+ raw_deployments = nil
10
+ ARGV.options do |opts|
11
+ opts.on("--deployments=LIST") { |v| raw_deployments = v }
12
+ opts.parse!
13
+ end
14
+
15
+ if raw_deployments.nil?
16
+ puts "Failed: specify at least one deployment to restart with --deployments flag"
17
+ exit 1
18
+ end
19
+
20
+ KubernetesDeploy::Runner.with_friendly_errors do
21
+ restart = KubernetesDeploy::RestartTask.new(namespace: ARGV[0], context: ARGV[1])
22
+ restart.perform(raw_deployments.split(","))
23
+ end
@@ -21,8 +21,10 @@ Gem::Specification.new do |spec|
21
21
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
22
  spec.require_paths = ["lib"]
23
23
  spec.add_dependency "activesupport", ">= 4.2"
24
+ spec.add_dependency "kubeclient", "~> 2.3"
24
25
 
25
26
  spec.add_development_dependency "bundler", "~> 1.13"
26
27
  spec.add_development_dependency "rake", "~> 10.0"
27
28
  spec.add_development_dependency "minitest", "~> 5.0"
29
+ spec.add_development_dependency "minitest-stub-const", "~> 0.6"
28
30
  end
@@ -3,6 +3,7 @@ require 'active_support/core_ext/object/blank'
3
3
  require 'active_support/core_ext/hash/slice'
4
4
  require 'active_support/core_ext/numeric/time'
5
5
  require 'active_support/core_ext/string/inflections'
6
+ require 'active_support/core_ext/string/strip'
6
7
 
7
8
  require 'logger'
8
9
  require 'kubernetes-deploy/runner'
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+ require 'kubeclient'
3
+
4
+ module KubernetesDeploy
5
+ module KubeclientBuilder
6
+ class ContextMissingError < FatalDeploymentError
7
+ def initialize(context_name)
8
+ super("`#{context_name}` context must be configured in your KUBECONFIG (#{ENV['KUBECONFIG']}). " \
9
+ "Please see the README.")
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def build_v1_kubeclient(context)
16
+ _build_kubeclient(
17
+ api_version: "v1",
18
+ context: context
19
+ )
20
+ end
21
+
22
+ def build_v1beta1_kubeclient(context)
23
+ _build_kubeclient(
24
+ api_version: "v1beta1",
25
+ context: context,
26
+ endpoint_path: "/apis/extensions/"
27
+ )
28
+ end
29
+
30
+ def _build_kubeclient(api_version:, context:, endpoint_path: nil)
31
+ config = Kubeclient::Config.read(ENV.fetch("KUBECONFIG"))
32
+ unless config.contexts.include?(context)
33
+ raise ContextMissingError, context
34
+ end
35
+ kube_context = config.context(context)
36
+
37
+ client = Kubeclient::Client.new(
38
+ "#{kube_context.api_endpoint}#{endpoint_path}",
39
+ api_version,
40
+ ssl_options: kube_context.ssl_options,
41
+ auth_options: kube_context.auth_options
42
+ )
43
+ client.discover
44
+ client
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ module KubernetesDeploy
3
+ class ResourceWatcher
4
+ def initialize(resources)
5
+ unless resources.is_a?(Enumerable)
6
+ raise ArgumentError, <<-MSG.strip
7
+ ResourceWatcher expects Enumerable collection, got `#{resources.class}` instead
8
+ MSG
9
+ end
10
+ @resources = resources
11
+ end
12
+
13
+ def run(delay_sync: 3.seconds, logger: KubernetesDeploy.logger)
14
+ delay_sync_until = Time.now.utc
15
+ started_at = delay_sync_until
16
+ human_resources = @resources.map(&:id).join(", ")
17
+ max_wait_time = @resources.map(&:timeout).max
18
+ logger.info("Waiting for #{human_resources} with #{max_wait_time}s timeout")
19
+
20
+ while @resources.present?
21
+ if Time.now.utc < delay_sync_until
22
+ sleep(delay_sync_until - Time.now.utc)
23
+ end
24
+ delay_sync_until = Time.now.utc + delay_sync # don't pummel the API if the sync is fast
25
+ @resources.each(&:sync)
26
+ newly_finished_resources, @resources = @resources.partition(&:deploy_finished?)
27
+ newly_finished_resources.each do |resource|
28
+ next unless resource.deploy_failed? || resource.deploy_timed_out?
29
+ logger.error("#{resource.id} failed to deploy with status '#{resource.status}'.")
30
+ end
31
+ end
32
+
33
+ watch_time = Time.now.utc - started_at
34
+ logger.info("Spent #{watch_time.round(2)}s waiting for #{human_resources}")
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+ require 'kubernetes-deploy/kubeclient_builder'
3
+ require 'kubernetes-deploy/ui_helpers'
4
+ require 'kubernetes-deploy/resource_watcher'
5
+
6
+ module KubernetesDeploy
7
+ class RestartTask
8
+ include UIHelpers
9
+ include KubernetesDeploy::KubeclientBuilder
10
+
11
+ class DeploymentNotFoundError < FatalDeploymentError
12
+ def initialize(name, namespace)
13
+ super("Deployment `#{name}` not found in namespace `#{namespace}`. Aborting the task.")
14
+ end
15
+ end
16
+
17
+ class NamespaceNotFoundError < FatalDeploymentError
18
+ def initialize(name, context)
19
+ super("Namespace `#{name}` not found in context `#{context}`. Aborting the task.")
20
+ end
21
+ end
22
+
23
+ class RestartError < FatalDeploymentError
24
+ def initialize(deployment_name, response)
25
+ super("Failed to restart #{deployment_name}. " \
26
+ "API returned non-200 response code (#{response.code})\n" \
27
+ "Response:\n#{response.body}")
28
+ end
29
+ end
30
+
31
+ HTTP_OK_RANGE = 200..299
32
+
33
+ def initialize(context:, namespace:, logger: KubernetesDeploy.logger)
34
+ @context = context
35
+ @namespace = namespace
36
+ @logger = logger
37
+ @kubeclient = build_v1_kubeclient(context)
38
+ @v1beta1_kubeclient = build_v1beta1_kubeclient(context)
39
+ end
40
+
41
+ def perform(deployments_names)
42
+ verify_namespace
43
+
44
+ if deployments_names.empty?
45
+ raise ArgumentError, "#perform takes at least one deployment to restart"
46
+ end
47
+
48
+ phase_heading("Triggering restart by touching ENV[RESTARTED_AT]")
49
+ deployments = fetch_deployments(deployments_names.uniq)
50
+ patch_kubeclient_deployments(deployments)
51
+
52
+ phase_heading("Waiting for rollout")
53
+ wait_for_rollout(deployments)
54
+
55
+ names = deployments.map { |d| "`#{d.metadata.name}`" }
56
+ @logger.info "Restart of #{names.join(', ')} deployments succeeded"
57
+ end
58
+
59
+ private
60
+
61
+ def wait_for_rollout(kubeclient_resources)
62
+ resources = kubeclient_resources.map { |d| Deployment.new(d.metadata.name, @namespace, @context, nil) }
63
+ watcher = ResourceWatcher.new(resources)
64
+ watcher.run
65
+ end
66
+
67
+ def verify_namespace
68
+ @kubeclient.get_namespace(@namespace)
69
+ rescue KubeException => error
70
+ if error.error_code == 404
71
+ raise NamespaceNotFoundError.new(@namespace, @context)
72
+ else
73
+ raise
74
+ end
75
+ end
76
+
77
+ def patch_deployment_with_restart(record)
78
+ @v1beta1_kubeclient.patch_deployment(
79
+ record.metadata.name,
80
+ build_patch_payload(record),
81
+ @namespace
82
+ )
83
+ end
84
+
85
+ def patch_kubeclient_deployments(deployments)
86
+ deployments.each do |record|
87
+ response = patch_deployment_with_restart(record)
88
+ if HTTP_OK_RANGE.cover?(response.code)
89
+ @logger.info "Triggered `#{record.metadata.name}` restart"
90
+ else
91
+ raise RestartError.new(record.metadata.name, response)
92
+ end
93
+ end
94
+ end
95
+
96
+ def fetch_deployments(list)
97
+ list.map do |name|
98
+ record = nil
99
+ begin
100
+ record = @v1beta1_kubeclient.get_deployment(name, @namespace)
101
+ rescue KubeException => error
102
+ if error.error_code == 404
103
+ raise DeploymentNotFoundError.new(name, @namespace)
104
+ else
105
+ raise
106
+ end
107
+ end
108
+ record
109
+ end
110
+ end
111
+
112
+ def build_patch_payload(deployment)
113
+ containers = deployment.spec.template.spec.containers
114
+ {
115
+ spec: {
116
+ template: {
117
+ spec: {
118
+ containers: containers.map do |container|
119
+ {
120
+ name: container.name,
121
+ env: [{ name: "RESTARTED_AT", value: Time.now.to_i.to_s }]
122
+ }
123
+ end
124
+ }
125
+ }
126
+ }
127
+ }
128
+ end
129
+ end
130
+ end
@@ -19,9 +19,13 @@ require 'kubernetes-deploy/kubernetes_resource'
19
19
  ).each do |subresource|
20
20
  require "kubernetes-deploy/kubernetes_resource/#{subresource}"
21
21
  end
22
+ require 'kubernetes-deploy/resource_watcher'
23
+ require "kubernetes-deploy/ui_helpers"
22
24
 
23
25
  module KubernetesDeploy
24
26
  class Runner
27
+ include UIHelpers
28
+
25
29
  PREDEPLOY_SEQUENCE = %w(
26
30
  Cloudsql
27
31
  Redis
@@ -64,7 +68,8 @@ MSG
64
68
  exit 1
65
69
  end
66
70
 
67
- def initialize(namespace:, current_sha:, context:, wait_for_completion:, template_dir:)
71
+ def initialize(namespace:, current_sha:, context:, template_dir:,
72
+ wait_for_completion:, allow_protected_ns: false, prune: true)
68
73
  @namespace = namespace
69
74
  @context = context
70
75
  @current_sha = current_sha
@@ -72,14 +77,19 @@ MSG
72
77
  # Max length of podname is only 63chars so try to save some room by truncating sha to 8 chars
73
78
  @id = current_sha[0...8] + "-#{SecureRandom.hex(4)}" if current_sha
74
79
  @wait_for_completion = wait_for_completion
80
+ @allow_protected_ns = allow_protected_ns
81
+ @prune = prune
75
82
  end
76
83
 
77
84
  def wait_for_completion?
78
85
  @wait_for_completion
79
86
  end
80
87
 
88
+ def allow_protected_ns?
89
+ @allow_protected_ns
90
+ end
91
+
81
92
  def run
82
- @current_phase = 0
83
93
  phase_heading("Validating configuration")
84
94
  validate_configuration
85
95
 
@@ -97,7 +107,10 @@ MSG
97
107
  predeploy_priority_resources(resources)
98
108
 
99
109
  phase_heading("Deploying all resources")
100
- deploy_resources(resources, prune: true)
110
+ if PROTECTED_NAMESPACES.include?(@namespace) && @prune
111
+ raise FatalDeploymentError, "Refusing to deploy to protected namespace '#{@namespace}' with pruning enabled"
112
+ end
113
+ deploy_resources(resources, prune: @prune)
101
114
 
102
115
  return unless wait_for_completion?
103
116
  wait_for_completion(resources)
@@ -192,26 +205,8 @@ MSG
192
205
  end
193
206
 
194
207
  def wait_for_completion(watched_resources)
195
- delay_sync_until = Time.now.utc
196
- started_at = delay_sync_until
197
- human_resources = watched_resources.map(&:id).join(", ")
198
- max_wait_time = watched_resources.map(&:timeout).max
199
- KubernetesDeploy.logger.info("Waiting for #{human_resources} with #{max_wait_time}s timeout")
200
- while watched_resources.present?
201
- if Time.now.utc < delay_sync_until
202
- sleep(delay_sync_until - Time.now.utc)
203
- end
204
- delay_sync_until = Time.now.utc + 3 # don't pummel the API if the sync is fast
205
- watched_resources.each(&:sync)
206
- newly_finished_resources, watched_resources = watched_resources.partition(&:deploy_finished?)
207
- newly_finished_resources.each do |resource|
208
- next unless resource.deploy_failed? || resource.deploy_timed_out?
209
- KubernetesDeploy.logger.error("#{resource.id} failed to deploy with status '#{resource.status}'.")
210
- end
211
- end
212
-
213
- watch_time = Time.now.utc - started_at
214
- KubernetesDeploy.logger.info("Spent #{watch_time.round(2)}s waiting for #{human_resources}")
208
+ watcher = ResourceWatcher.new(watched_resources)
209
+ watcher.run
215
210
  end
216
211
 
217
212
  def render_template(filename, raw_template)
@@ -244,7 +239,18 @@ MSG
244
239
  if @namespace.blank?
245
240
  errors << "Namespace must be specified"
246
241
  elsif PROTECTED_NAMESPACES.include?(@namespace)
247
- errors << "Refusing to deploy to protected namespace #{@namespace}"
242
+ if allow_protected_ns? && @prune
243
+ errors << "Refusing to deploy to protected namespace '#{@namespace}' with pruning enabled"
244
+ elsif allow_protected_ns?
245
+ warning = <<-WARNING.strip_heredoc
246
+ You're deploying to protected namespace #{@namespace}, which cannot be pruned.
247
+ Existing resources can only be removed manually with kubectl. Removing templates from the set deployed will have no effect.
248
+ ***Please do not deploy to #{@namespace} unless you really know what you are doing.***
249
+ WARNING
250
+ KubernetesDeploy.logger.warn(warning)
251
+ else
252
+ errors << "Refusing to deploy to protected namespace '#{@namespace}'"
253
+ end
248
254
  end
249
255
 
250
256
  if @context.blank?
@@ -335,17 +341,5 @@ MSG
335
341
  end
336
342
  [out.chomp, err.chomp, st]
337
343
  end
338
-
339
- def phase_heading(phase_name)
340
- @current_phase += 1
341
- heading = "Phase #{@current_phase}: #{phase_name}"
342
- padding = (100.0 - heading.length) / 2
343
- KubernetesDeploy.logger.info("")
344
- KubernetesDeploy.logger.info("#{'-' * padding.floor}#{heading}#{'-' * padding.ceil}")
345
- end
346
-
347
- def log_green(msg)
348
- KubernetesDeploy.logger.info("\033[0;32m#{msg}\x1b[0m")
349
- end
350
344
  end
351
345
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ module KubernetesDeploy
3
+ module UIHelpers
4
+ private
5
+
6
+ def phase_heading(phase_name)
7
+ @current_phase ||= 0
8
+ @current_phase += 1
9
+ heading = "Phase #{@current_phase}: #{phase_name}"
10
+ padding = (100.0 - heading.length) / 2
11
+ KubernetesDeploy.logger.info("")
12
+ KubernetesDeploy.logger.info("#{'-' * padding.floor}#{heading}#{'-' * padding.ceil}")
13
+ end
14
+
15
+ def log_green(msg)
16
+ KubernetesDeploy.logger.info("\033[0;32m#{msg}\x1b[0m")
17
+ end
18
+ end
19
+ end
@@ -1,3 +1,3 @@
1
1
  module KubernetesDeploy
2
- VERSION = "0.3.4"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kubernetes-deploy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kir Shatrov
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: exe
12
12
  cert_chain: []
13
- date: 2017-03-22 00:00:00.000000000 Z
13
+ date: 2017-03-28 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activesupport
@@ -26,6 +26,20 @@ dependencies:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
28
  version: '4.2'
29
+ - !ruby/object:Gem::Dependency
30
+ name: kubeclient
31
+ requirement: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - "~>"
34
+ - !ruby/object:Gem::Version
35
+ version: '2.3'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '2.3'
29
43
  - !ruby/object:Gem::Dependency
30
44
  name: bundler
31
45
  requirement: !ruby/object:Gem::Requirement
@@ -68,11 +82,26 @@ dependencies:
68
82
  - - "~>"
69
83
  - !ruby/object:Gem::Version
70
84
  version: '5.0'
85
+ - !ruby/object:Gem::Dependency
86
+ name: minitest-stub-const
87
+ requirement: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - "~>"
90
+ - !ruby/object:Gem::Version
91
+ version: '0.6'
92
+ type: :development
93
+ prerelease: false
94
+ version_requirements: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - "~>"
97
+ - !ruby/object:Gem::Version
98
+ version: '0.6'
71
99
  description: Kubernetes deploy scripts
72
100
  email:
73
101
  - ops-accounts+shipit@shopify.com
74
102
  executables:
75
103
  - kubernetes-deploy
104
+ - kubernetes-restart
76
105
  extensions: []
77
106
  extra_rdoc_files: []
78
107
  files:
@@ -85,8 +114,10 @@ files:
85
114
  - bin/ci
86
115
  - bin/setup
87
116
  - exe/kubernetes-deploy
117
+ - exe/kubernetes-restart
88
118
  - kubernetes-deploy.gemspec
89
119
  - lib/kubernetes-deploy.rb
120
+ - lib/kubernetes-deploy/kubeclient_builder.rb
90
121
  - lib/kubernetes-deploy/kubernetes_resource.rb
91
122
  - lib/kubernetes-deploy/kubernetes_resource/cloudsql.rb
92
123
  - lib/kubernetes-deploy/kubernetes_resource/config_map.rb
@@ -96,7 +127,10 @@ files:
96
127
  - lib/kubernetes-deploy/kubernetes_resource/pod.rb
97
128
  - lib/kubernetes-deploy/kubernetes_resource/redis.rb
98
129
  - lib/kubernetes-deploy/kubernetes_resource/service.rb
130
+ - lib/kubernetes-deploy/resource_watcher.rb
131
+ - lib/kubernetes-deploy/restart_task.rb
99
132
  - lib/kubernetes-deploy/runner.rb
133
+ - lib/kubernetes-deploy/ui_helpers.rb
100
134
  - lib/kubernetes-deploy/version.rb
101
135
  homepage: https://github.com/Shopify/kubernetes-deploy
102
136
  licenses: