kubernetes-deploy 0.6.1 → 0.6.2

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: 8d4d12495a2c45feea06818619b789fe435c9458
4
- data.tar.gz: be4b5c1fd86dd89e61ff4c209325dd355a9c4ca6
3
+ metadata.gz: 55540c9fa5a002f87eba1c06883e0b237d194803
4
+ data.tar.gz: 5d2e45261bb51d18d34f5337eb7006892885b99e
5
5
  SHA512:
6
- metadata.gz: dbc9a37c932a11084c881b5333bb32d2d86500095c704f0a17ad2e0d53331f91105b4f0afbe5134a30f244fdce6b53b48cc9e43f8005d6c6bcfc4ed6ff59c29a
7
- data.tar.gz: 3910718f3c377f15a88e478211c1ebc25a9ba365af017405f7999f1c0a2fcdf14f43b0b348d900666640200a623baaef2b3c37149d32ccee83f75246e6130cd5
6
+ metadata.gz: 0fb77ac54ebf8f56eb7c116a29ef373da76d694c4977a5c704f78cafacf9c97c2cad25e5137e9991eaf689661323ab889b205da8db96a87c02c0ce94d641f023
7
+ data.tar.gz: 2e00d11e27cd3965e30592552adcf77ea7b599cb120a376d2c32b226b7bd07f4c0a1040da24534304b9910ed8c95cf4ad447937a3773d01cc11d5e1b5eac3e2a
data/Gemfile CHANGED
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
6
  gem 'pry'
7
+ gem 'pry-byebug'
7
8
  gem 'rubocop'
8
9
  gem 'timecop'
9
10
  gem 'byebug'
data/README.md CHANGED
@@ -40,6 +40,42 @@ The following command will restart all pods in the `web` and `jobs` deployments:
40
40
 
41
41
  `kubernetes-restart <kube namespace> <kube context> --deployments=web,jobs`
42
42
 
43
+ ### Deploying Kubernetes secrets
44
+
45
+ **Note: If you're a Shopify employee using our cloud platform, this setup has already been done for you. Please consult the CloudPlatform User Guide for usage instructions.**
46
+
47
+ Since their data is only base64 encoded, Kubernetes secrets should not be committed to your repository. Instead, `kubernetes-deploy` supports generating secrets from an encrypted [ejson](https://github.com/Shopify/ejson) file in your template directory. Here's how to use this feature:
48
+
49
+ 1. Install the ejson gem: `gem install ejson`
50
+ 2. Generate a new keypair: `ejson keygen` (prints the keypair to stdout)
51
+ 3. Create a Kubernetes secret in your target namespace with the new keypair: `kubectl create secret generic ejson-keys --from-literal=YOUR_PUBLIC_KEY=YOUR_PRIVATE_KEY --namespace=TARGET_NAMESPACE`
52
+ 4. (optional but highly recommended) Back up the keypair somewhere secure, such as a password manager, for disaster recovery purposes.
53
+ 5. In your template directory (alongside your Kubernetes templates), create `secrets.ejson` with the format shown below. The `_type` key should have the value “kubernetes.io/tls” for TLS secrets and “Opaque” for all others. The `data` key must be a json object, but its keys and values can be whatever you need.
54
+
55
+ ```json
56
+ {
57
+ "_public_key": "YOUR_PUBLIC_KEY",
58
+ "kubernetes_secrets": {
59
+ "catphotoscom": {
60
+ "_type": "kubernetes.io/tls",
61
+ "data": {
62
+ "tls.crt": "cert-data-here",
63
+ "tls.key": "key-data-here"
64
+ }
65
+ },
66
+ "monitoring-token": {
67
+ "_type": "Opaque",
68
+ "data": {
69
+ "api-token": "token-value-here"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ 6. Encrypt the file: `ejson encrypt /PATH/TO/secrets.ejson`
77
+ 7. Commit the encrypted file and deploy as usual. The deploy will create secrets from the data in the `kubernetes_secrets` key.
78
+
43
79
  ### Running one off tasks
44
80
 
45
81
  To trigger a one-off job such as a rake task _outside_ of a deploy, use the following command:
@@ -50,7 +86,7 @@ This command assumes that you've already deployed a `PodTemplate` named `task-ru
50
86
 
51
87
  #### Creating a PodTemplate
52
88
 
53
- The [`PodTemplate`](https://kubernetes.io/docs/api-reference/v1.6/#podtemplate-v1-core) object should have a field `template` containing a `Pod` specification which does not include the `apiVersion` or `kind` parameters. An example is provided in this repo in `test/fixtures/hello-cloud/template-runner.yml`.
89
+ The [`PodTemplate`](https://kubernetes.io/docs/api-reference/v1.6/#podtemplate-v1-core) object should have a field `template` containing a `Pod` specification which does not include the `apiVersion` or `kind` parameters. An example is provided in this repo in `test/fixtures/hello-cloud/template-runner.yml`.
54
90
 
55
91
  #### Providing multiple different task-runner configurations
56
92
 
@@ -8,16 +8,11 @@ require 'kubernetes-deploy/restart_task'
8
8
 
9
9
  raw_deployments = nil
10
10
  ARGV.options do |opts|
11
- opts.on("--deployments=LIST") { |v| raw_deployments = v }
11
+ opts.on("--deployments=LIST") { |v| raw_deployments = v.split(",") }
12
12
  opts.parse!
13
13
  end
14
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
15
  KubernetesDeploy::Runner.with_friendly_errors do
21
16
  restart = KubernetesDeploy::RestartTask.new(namespace: ARGV[0], context: ARGV[1])
22
- restart.perform(raw_deployments.split(","))
17
+ restart.perform(raw_deployments)
23
18
  end
@@ -23,10 +23,12 @@ Gem::Specification.new do |spec|
23
23
  spec.add_dependency "activesupport", ">= 4.2"
24
24
  spec.add_dependency "kubeclient", "~> 2.3"
25
25
  spec.add_dependency "googleauth", ">= 0.5"
26
+ spec.add_dependency "ejson", "1.0.1"
26
27
 
27
28
  spec.add_development_dependency "bundler", "~> 1.13"
28
29
  spec.add_development_dependency "rake", "~> 10.0"
29
30
  spec.add_development_dependency "minitest", "~> 5.0"
30
31
  spec.add_development_dependency "minitest-stub-const", "~> 0.6"
31
32
  spec.add_development_dependency "webmock", "~> 3.0"
33
+ spec.add_development_dependency "mocha", "~> 1.1"
32
34
  end
@@ -4,18 +4,12 @@ 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
6
  require 'active_support/core_ext/string/strip'
7
+ require 'active_support/core_ext/hash/keys'
7
8
 
9
+ require 'kubernetes-deploy/errors'
8
10
  require 'kubernetes-deploy/logger'
9
11
  require 'kubernetes-deploy/runner'
10
12
 
11
13
  module KubernetesDeploy
12
- class FatalDeploymentError < StandardError; end
13
-
14
- class NamespaceNotFoundError < FatalDeploymentError
15
- def initialize(name, context)
16
- super("Namespace `#{name}` not found in context `#{context}`. Aborting the task.")
17
- end
18
- end
19
-
20
14
  include Logger
21
15
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'base64'
4
+ require 'open3'
5
+ require 'kubernetes-deploy/logger'
6
+
7
+ module KubernetesDeploy
8
+ class EjsonSecretError < FatalDeploymentError
9
+ def initialize(msg)
10
+ super("Creation of Kubernetes secrets from ejson failed: #{msg}")
11
+ end
12
+ end
13
+
14
+ class EjsonSecretProvisioner
15
+ MANAGEMENT_ANNOTATION = "kubernetes-deploy.shopify.io/ejson-secret"
16
+ MANAGED_SECRET_EJSON_KEY = "kubernetes_secrets"
17
+ EJSON_SECRETS_FILE = "secrets.ejson"
18
+ EJSON_KEYS_SECRET = "ejson-keys"
19
+
20
+ def initialize(namespace:, template_dir:, client:)
21
+ @namespace = namespace
22
+ @ejson_file = "#{template_dir}/#{EJSON_SECRETS_FILE}"
23
+ @kubeclient = client
24
+ end
25
+
26
+ def secret_changes_required?
27
+ File.exist?(@ejson_file) || managed_secrets_exist?
28
+ end
29
+
30
+ def run
31
+ create_secrets
32
+ prune_managed_secrets
33
+ end
34
+
35
+ private
36
+
37
+ def create_secrets
38
+ with_decrypted_ejson do |decrypted|
39
+ secrets = decrypted[MANAGED_SECRET_EJSON_KEY]
40
+ unless secrets.present?
41
+ KubernetesDeploy.logger.warn("#{EJSON_SECRETS_FILE} does not have key #{MANAGED_SECRET_EJSON_KEY}."\
42
+ "No secrets will be created.")
43
+ return
44
+ end
45
+
46
+ secrets.each do |secret_name, secret_spec|
47
+ validate_secret_spec(secret_name, secret_spec)
48
+ create_or_update_secret(secret_name, secret_spec["_type"], secret_spec["data"])
49
+ end
50
+ end
51
+ end
52
+
53
+ def prune_managed_secrets
54
+ ejson_secret_names = encrypted_ejson.fetch(MANAGED_SECRET_EJSON_KEY, {}).keys
55
+ live_secrets = @kubeclient.get_secrets(namespace: @namespace)
56
+
57
+ live_secrets.each do |secret|
58
+ secret_name = secret.metadata.name
59
+ next unless secret_managed?(secret)
60
+ next if ejson_secret_names.include?(secret_name)
61
+
62
+ KubernetesDeploy.logger.info("Pruning secret #{secret_name}")
63
+ @kubeclient.delete_secret(secret_name, @namespace)
64
+ end
65
+ end
66
+
67
+ def managed_secrets_exist?
68
+ all_secrets = @kubeclient.get_secrets(namespace: @namespace)
69
+ all_secrets.any? { |secret| secret_managed?(secret) }
70
+ end
71
+
72
+ def secret_managed?(secret)
73
+ secret.metadata.annotations.to_h.stringify_keys.key?(MANAGEMENT_ANNOTATION)
74
+ end
75
+
76
+ def encrypted_ejson
77
+ @encrypted_ejson ||= load_ejson_from_file
78
+ end
79
+
80
+ def public_key
81
+ encrypted_ejson["_public_key"]
82
+ end
83
+
84
+ def private_key
85
+ @private_key ||= fetch_private_key_from_secret
86
+ end
87
+
88
+ def validate_secret_spec(secret_name, spec)
89
+ errors = []
90
+ errors << "secret type unspecified" if spec["_type"].blank?
91
+ errors << "no data provided" if spec["data"].blank?
92
+
93
+ unless errors.empty?
94
+ raise EjsonSecretError, "Ejson incomplete for secret #{secret_name}: #{errors.join(', ')}"
95
+ end
96
+ end
97
+
98
+ def create_or_update_secret(secret_name, secret_type, data)
99
+ metadata = {
100
+ name: secret_name,
101
+ labels: { "name" => secret_name },
102
+ namespace: @namespace,
103
+ annotations: { MANAGEMENT_ANNOTATION => "true" }
104
+ }
105
+ secret = Kubeclient::Secret.new(type: secret_type, stringData: data, metadata: metadata)
106
+ if secret_exists?(secret)
107
+ KubernetesDeploy.logger.info("Updating secret #{secret_name}")
108
+ @kubeclient.update_secret(secret)
109
+ else
110
+ KubernetesDeploy.logger.info("Creating secret #{secret_name}")
111
+ @kubeclient.create_secret(secret)
112
+ end
113
+ rescue KubeException => e
114
+ raise unless e.error_code == 400
115
+ raise EjsonSecretError, "Data for secret #{secret_name} was invalid: #{e}"
116
+ end
117
+
118
+ def secret_exists?(secret)
119
+ @kubeclient.get_secret(secret.metadata.name, @namespace)
120
+ true
121
+ rescue KubeException => error
122
+ raise unless error.error_code == 404
123
+ false
124
+ end
125
+
126
+ def load_ejson_from_file
127
+ return {} unless File.exist?(@ejson_file)
128
+ JSON.parse(File.read(@ejson_file))
129
+ rescue JSON::ParserError => e
130
+ raise EjsonSecretError, "Failed to parse encrypted ejson:\n #{e}"
131
+ end
132
+
133
+ def with_decrypted_ejson
134
+ return unless File.exist?(@ejson_file)
135
+
136
+ Dir.mktmpdir("ejson_keydir") do |key_dir|
137
+ File.write(File.join(key_dir, public_key), private_key)
138
+ decrypted = decrypt_ejson(key_dir)
139
+ yield decrypted
140
+ end
141
+ end
142
+
143
+ def decrypt_ejson(key_dir)
144
+ KubernetesDeploy.logger.info("Decrypting #{EJSON_SECRETS_FILE}")
145
+ # ejson seems to dump both errors and output to STDOUT
146
+ out_err, st = Open3.capture2e("EJSON_KEYDIR=#{key_dir} ejson decrypt #{@ejson_file}")
147
+ raise EjsonSecretError, out_err unless st.success?
148
+ JSON.parse(out_err)
149
+ rescue JSON::ParserError => e
150
+ raise EjsonSecretError, "Failed to parse decrypted ejson:\n #{e}"
151
+ end
152
+
153
+ def fetch_private_key_from_secret
154
+ KubernetesDeploy.logger.info("Fetching ejson private key from secret #{EJSON_KEYS_SECRET}")
155
+ secret = @kubeclient.get_secret(EJSON_KEYS_SECRET, @namespace)
156
+ encoded_private_key = secret["data"][public_key]
157
+ unless encoded_private_key
158
+ raise EjsonSecretError, "Private key for #{public_key} not found in #{EJSON_KEYS_SECRET} secret"
159
+ end
160
+
161
+ Base64.decode64(encoded_private_key)
162
+ rescue KubeException => error
163
+ raise unless error.error_code == 404
164
+ secret_missing_err = "Failed to decrypt ejson: secret #{EJSON_KEYS_SECRET} not found in namespace #{@namespace}."
165
+ raise EjsonSecretError, secret_missing_err
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ module KubernetesDeploy
3
+ class FatalDeploymentError < StandardError; end
4
+
5
+ class NamespaceNotFoundError < FatalDeploymentError
6
+ def initialize(name, context)
7
+ super("Namespace `#{name}` not found in context `#{context}`. Aborting the task.")
8
+ end
9
+ end
10
+ end
@@ -23,6 +23,7 @@ module KubernetesDeploy
23
23
  end
24
24
 
25
25
  HTTP_OK_RANGE = 200..299
26
+ ANNOTATION = "shipit.shopify.io/restart"
26
27
 
27
28
  def initialize(context:, namespace:, logger: KubernetesDeploy.logger)
28
29
  @context = context
@@ -32,22 +33,33 @@ module KubernetesDeploy
32
33
  @v1beta1_kubeclient = build_v1beta1_kubeclient(context)
33
34
  end
34
35
 
35
- def perform(deployments_names)
36
+ def perform(deployments_names = nil)
36
37
  verify_namespace
37
38
 
38
- if deployments_names.empty?
39
- raise ArgumentError, "#perform takes at least one deployment to restart"
39
+ if deployments_names
40
+ deployments = fetch_deployments(deployments_names.uniq)
41
+
42
+ if deployments.none?
43
+ raise ArgumentError, "no deployments with names #{deployments_names} found in namespace #{@namespace}"
44
+ end
45
+ else
46
+ deployments = @v1beta1_kubeclient
47
+ .get_deployments(namespace: @namespace)
48
+ .select { |d| d.metadata.annotations[ANNOTATION] }
49
+
50
+ if deployments.none?
51
+ raise ArgumentError, "no deployments found in namespace #{@namespace} with #{ANNOTATION} annotation available"
52
+ end
40
53
  end
41
54
 
42
55
  phase_heading("Triggering restart by touching ENV[RESTARTED_AT]")
43
- deployments = fetch_deployments(deployments_names.uniq)
44
56
  patch_kubeclient_deployments(deployments)
45
57
 
46
58
  phase_heading("Waiting for rollout")
47
59
  wait_for_rollout(deployments)
48
60
 
49
61
  names = deployments.map { |d| "`#{d.metadata.name}`" }
50
- @logger.info "Restart of #{names.join(', ')} deployments succeeded"
62
+ @logger.info "Restart of #{names.sort.join(', ')} deployments succeeded"
51
63
  end
52
64
 
53
65
  private
@@ -23,10 +23,13 @@ end
23
23
  require 'kubernetes-deploy/resource_watcher'
24
24
  require "kubernetes-deploy/ui_helpers"
25
25
  require 'kubernetes-deploy/kubectl'
26
+ require 'kubernetes-deploy/kubeclient_builder'
27
+ require 'kubernetes-deploy/ejson_secret_provisioner'
26
28
 
27
29
  module KubernetesDeploy
28
30
  class Runner
29
31
  include UIHelpers
32
+ include KubeclientBuilder
30
33
 
31
34
  PREDEPLOY_SEQUENCE = %w(
32
35
  Cloudsql
@@ -39,6 +42,7 @@ module KubernetesDeploy
39
42
  PROTECTED_NAMESPACES = %w(
40
43
  default
41
44
  kube-system
45
+ kube-public
42
46
  )
43
47
 
44
48
  # Things removed from default prune whitelist:
@@ -109,6 +113,16 @@ MSG
109
113
  phase_heading("Checking initial resource statuses")
110
114
  resources.each(&:sync)
111
115
 
116
+ ejson = EjsonSecretProvisioner.new(
117
+ namespace: @namespace,
118
+ template_dir: @template_dir,
119
+ client: build_v1_kubeclient(@context)
120
+ )
121
+ if ejson.secret_changes_required?
122
+ phase_heading("Deploying kubernetes secrets from #{EjsonSecretProvisioner::EJSON_SECRETS_FILE}")
123
+ ejson.run
124
+ end
125
+
112
126
  phase_heading("Predeploying priority resources")
113
127
  predeploy_priority_resources(resources)
114
128
 
@@ -1,3 +1,3 @@
1
1
  module KubernetesDeploy
2
- VERSION = "0.6.1"
2
+ VERSION = "0.6.2"
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.6.1
4
+ version: 0.6.2
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-05-01 00:00:00.000000000 Z
13
+ date: 2017-05-08 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: activesupport
@@ -54,6 +54,20 @@ dependencies:
54
54
  - - ">="
55
55
  - !ruby/object:Gem::Version
56
56
  version: '0.5'
57
+ - !ruby/object:Gem::Dependency
58
+ name: ejson
59
+ requirement: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - '='
62
+ - !ruby/object:Gem::Version
63
+ version: 1.0.1
64
+ type: :runtime
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - '='
69
+ - !ruby/object:Gem::Version
70
+ version: 1.0.1
57
71
  - !ruby/object:Gem::Dependency
58
72
  name: bundler
59
73
  requirement: !ruby/object:Gem::Requirement
@@ -124,6 +138,20 @@ dependencies:
124
138
  - - "~>"
125
139
  - !ruby/object:Gem::Version
126
140
  version: '3.0'
141
+ - !ruby/object:Gem::Dependency
142
+ name: mocha
143
+ requirement: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - "~>"
146
+ - !ruby/object:Gem::Version
147
+ version: '1.1'
148
+ type: :development
149
+ prerelease: false
150
+ version_requirements: !ruby/object:Gem::Requirement
151
+ requirements:
152
+ - - "~>"
153
+ - !ruby/object:Gem::Version
154
+ version: '1.1'
127
155
  description: Kubernetes deploy scripts
128
156
  email:
129
157
  - ops-accounts+shipit@shopify.com
@@ -147,6 +175,8 @@ files:
147
175
  - exe/kubernetes-run
148
176
  - kubernetes-deploy.gemspec
149
177
  - lib/kubernetes-deploy.rb
178
+ - lib/kubernetes-deploy/ejson_secret_provisioner.rb
179
+ - lib/kubernetes-deploy/errors.rb
150
180
  - lib/kubernetes-deploy/kubeclient_builder.rb
151
181
  - lib/kubernetes-deploy/kubeclient_builder/google_friendly_config.rb
152
182
  - lib/kubernetes-deploy/kubectl.rb
@@ -188,7 +218,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
218
  version: '0'
189
219
  requirements: []
190
220
  rubyforge_project:
191
- rubygems_version: 2.4.5.1
221
+ rubygems_version: 2.5.1
192
222
  signing_key:
193
223
  specification_version: 4
194
224
  summary: Kubernetes deploy scripts