kubernetes-deploy 0.3.4 → 0.4.0

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