Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use kubectl in EjsonSecretProvisioner #91

Merged
merged 2 commits into from
May 9, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ inherit_from:
- http://shopify.github.io/ruby-style-guide/rubocop.yml

AllCops:
TargetRubyVersion: 2.3
TargetRubyVersion: 2.1
99 changes: 65 additions & 34 deletions lib/kubernetes-deploy/ejson_secret_provisioner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require 'base64'
require 'open3'
require 'kubernetes-deploy/logger'
require 'kubernetes-deploy/kubectl'

module KubernetesDeploy
class EjsonSecretError < FatalDeploymentError
Expand All @@ -17,10 +18,13 @@ class EjsonSecretProvisioner
EJSON_SECRETS_FILE = "secrets.ejson"
EJSON_KEYS_SECRET = "ejson-keys"

def initialize(namespace:, template_dir:, client:)
def initialize(namespace:, context:, template_dir:)
@namespace = namespace
@context = context
@ejson_file = "#{template_dir}/#{EJSON_SECRETS_FILE}"
@kubeclient = client

raise FatalDeploymentError, "Cannot create secrets without a namespace" if @namespace.blank?
raise FatalDeploymentError, "Cannot create secrets without a context" if @context.blank?
end

def secret_changes_required?
Expand Down Expand Up @@ -52,25 +56,27 @@ def create_secrets

def prune_managed_secrets
ejson_secret_names = encrypted_ejson.fetch(MANAGED_SECRET_EJSON_KEY, {}).keys
live_secrets = @kubeclient.get_secrets(namespace: @namespace)
live_secrets = run_kubectl_json("get", "secrets")

live_secrets.each do |secret|
secret_name = secret.metadata.name
secret_name = secret["metadata"]["name"]
next unless secret_managed?(secret)
next if ejson_secret_names.include?(secret_name)

KubernetesDeploy.logger.info("Pruning secret #{secret_name}")
@kubeclient.delete_secret(secret_name, @namespace)
out, err, st = run_kubectl("delete", "secret", secret_name)
KubernetesDeploy.logger.debug(out)
raise EjsonSecretError, err unless st.success?
end
end

def managed_secrets_exist?
all_secrets = @kubeclient.get_secrets(namespace: @namespace)
all_secrets = run_kubectl_json("get", "secrets")
all_secrets.any? { |secret| secret_managed?(secret) }
end

def secret_managed?(secret)
secret.metadata.annotations.to_h.stringify_keys.key?(MANAGEMENT_ANNOTATION)
secret["metadata"].fetch("annotations", {}).key?(MANAGEMENT_ANNOTATION)
end

def encrypted_ejson
Expand All @@ -96,31 +102,47 @@ def validate_secret_spec(secret_name, spec)
end

def create_or_update_secret(secret_name, secret_type, data)
metadata = {
name: secret_name,
labels: { "name" => secret_name },
namespace: @namespace,
annotations: { MANAGEMENT_ANNOTATION => "true" }
}
secret = Kubeclient::Secret.new(type: secret_type, stringData: data, metadata: metadata)
if secret_exists?(secret)
KubernetesDeploy.logger.info("Updating secret #{secret_name}")
@kubeclient.update_secret(secret)
else
KubernetesDeploy.logger.info("Creating secret #{secret_name}")
@kubeclient.create_secret(secret)
msg = secret_exists?(secret_name) ? "Updating secret #{secret_name}" : "Creating secret #{secret_name}"
KubernetesDeploy.logger.info(msg)

secret_yaml = generate_secret_yaml(secret_name, secret_type, data)
file = Tempfile.new(secret_name)
file.write(secret_yaml)
file.close

out, err, st = run_kubectl("apply", "--filename=#{file.path}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make sure the file is deleted too, would Tempfile.open work for this?

KubernetesDeploy.logger.debug(out)
raise EjsonSecretError, err unless st.success?
ensure
file.unlink if file
end

def generate_secret_yaml(secret_name, secret_type, data)
unless data.is_a?(Hash) && data.values.all? { |v| v.is_a?(String) } # Secret data is map[string]string
raise EjsonSecretError, "Data for secret #{secret_name} was invalid. Only key-value pairs are permitted."
end
encoded_data = data.each_with_object({}) do |(key, value), encoded|
encoded[key] = Base64.encode64(value)
end
rescue KubeException => e
raise unless e.error_code == 400
raise EjsonSecretError, "Data for secret #{secret_name} was invalid: #{e}"

secret = {
'kind' => 'Secret',
'apiVersion' => 'v1',
'type' => secret_type,
'metadata' => {
"name" => secret_name,
"labels" => { "name" => secret_name },
"namespace" => @namespace,
"annotations" => { MANAGEMENT_ANNOTATION => "true" }
},
"data" => encoded_data
}
secret.to_yaml
end

def secret_exists?(secret)
@kubeclient.get_secret(secret.metadata.name, @namespace)
true
rescue KubeException => error
raise unless error.error_code == 404
false
def secret_exists?(secret_name)
_out, _err, st = run_kubectl("get", "secret", secret_name)
st.success?
end

def load_ejson_from_file
Expand Down Expand Up @@ -152,17 +174,26 @@ def decrypt_ejson(key_dir)

def fetch_private_key_from_secret
KubernetesDeploy.logger.info("Fetching ejson private key from secret #{EJSON_KEYS_SECRET}")
secret = @kubeclient.get_secret(EJSON_KEYS_SECRET, @namespace)

secret = run_kubectl_json("get", "secret", EJSON_KEYS_SECRET)
encoded_private_key = secret["data"][public_key]
unless encoded_private_key
raise EjsonSecretError, "Private key for #{public_key} not found in #{EJSON_KEYS_SECRET} secret"
end

Base64.decode64(encoded_private_key)
rescue KubeException => error
raise unless error.error_code == 404
secret_missing_err = "Failed to decrypt ejson: secret #{EJSON_KEYS_SECRET} not found in namespace #{@namespace}."
raise EjsonSecretError, secret_missing_err
end

def run_kubectl_json(*args)
args += ["--output=json"]
out, err, st = run_kubectl(*args)
raise EjsonSecretError, err unless st.success?
result = JSON.parse(out)
result.fetch('items', result)
end

def run_kubectl(*args)
Kubectl.run_kubectl(*args, namespace: @namespace, context: @context)
end
end
end
1 change: 1 addition & 0 deletions lib/kubernetes-deploy/errors.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true
module KubernetesDeploy
class FatalDeploymentError < StandardError; end
class KubectlError < StandardError; end

class NamespaceNotFoundError < FatalDeploymentError
def initialize(name, context)
Expand Down
6 changes: 1 addition & 5 deletions lib/kubernetes-deploy/runner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,7 @@ def run
phase_heading("Checking initial resource statuses")
resources.each(&:sync)

ejson = EjsonSecretProvisioner.new(
namespace: @namespace,
template_dir: @template_dir,
client: build_v1_kubeclient(@context)
)
ejson = EjsonSecretProvisioner.new(namespace: @namespace, context: @context, template_dir: @template_dir)
if ejson.secret_changes_required?
phase_heading("Deploying kubernetes secrets from #{EjsonSecretProvisioner::EJSON_SECRETS_FILE}")
ejson.run
Expand Down
91 changes: 63 additions & 28 deletions test/unit/kubernetes-deploy/ejson_secret_provisioner_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,29 @@
require 'test_helper'

class EjsonSecretProvisionerTest < KubernetesDeploy::TestCase
def setup
KubernetesDeploy::Kubectl.expects(:run_kubectl).never
super
end

def test_secret_changes_required_based_on_ejson_file_existence
mock_kubeclient.expects(:get_secrets).with(namespace: 'test').returns([])
stub_kubectl_response("get", "secrets", "--output=json", resp: { items: [dummy_ejson_secret] })

refute build_provisioner(fixture_path('hello-cloud')).secret_changes_required?
assert build_provisioner(fixture_path('ejson-cloud')).secret_changes_required?
end

def test_secret_changes_required_based_on_managed_secret_existence
metadata = {
annotations: { KubernetesDeploy::EjsonSecretProvisioner::MANAGEMENT_ANNOTATION => "true" },
name: 'foo'
}
managed_secret = Kubeclient::Secret.new(type: 'Opaque', metadata: metadata)
mock_kubeclient.expects(:get_secrets).with(namespace: 'test').returns([managed_secret])
stub_kubectl_response(
"get", "secrets", "--output=json",
resp: { items: [dummy_secret_hash(managed: true), dummy_ejson_secret] }
)
assert build_provisioner(fixture_path('hello-cloud')).secret_changes_required?
end

def test_run_with_no_secrets_file_or_managed_secrets_no_ops
# nothing raised, no unexpected kubeclient calls
mock_kubeclient.expects(:get_secrets).with(namespace: 'test').returns([])
# nothing raised, no unexpected kubectl calls
stub_kubectl_response("get", "secrets", "--output=json", resp: { items: [] })
build_provisioner(fixture_path('hello-cloud')).run
end

Expand All @@ -33,39 +37,41 @@ def test_run_with_secrets_file_invalid_json
end

def test_run_with_ejson_keypair_mismatch
wrong_data = {
wrong_public = {
"2200e55f22dd0c93fac3832ba14842cc75fa5a99a2e01696daa30e188d465036" =>
"MTM5ZDVjMmEzMDkwMWRkOGFlMTg2YmU1ODJjY2MwYTg4MmMxNmY4ZTBiYjU0Mjk4ODRkYmM3Mjk2ZTgwNjY5ZQo="
"139d5c2a30901dd8ae186be582ccc0a882c16f8e0bb5429884dbc7296e80669e"
}
mock_kubeclient.expects(:get_secret).with('ejson-keys', 'test').returns("data" => wrong_data)
stub_kubectl_response("get", "secret", "ejson-keys", "--output=json", resp: dummy_ejson_secret(wrong_public))

msg = "Private key for 65f79806388144edf800bf9fa683c98d3bc9484768448a275a35d398729c892a not found"
msg = "Private key for #{fixture_public_key} not found"
assert_raises_message(KubernetesDeploy::EjsonSecretError, msg) do
build_provisioner.run
end
end

def test_run_with_bad_private_key_in_cloud_keys
wrong_private = {
"65f79806388144edf800bf9fa683c98d3bc9484768448a275a35d398729c892a" =>
"MTM5ZDVjMmEzMDkwMWRkOGFlMTg2YmU1ODJjY2MwYTg4MmMxNmY4ZTBiYjU0Mjk4ODRkYmM3Mjk2ZTgwNjY5ZQo="
}
mock_kubeclient.expects(:get_secret).with('ejson-keys', 'test').returns("data" => wrong_private)
wrong_private = { fixture_public_key => "139d5c2a30901dd8ae186be582ccc0a882c16f8e0bb5429884dbc7296e80669e" }
stub_kubectl_response("get", "secret", "ejson-keys", "--output=json", resp: dummy_ejson_secret(wrong_private))

assert_raises_message(KubernetesDeploy::EjsonSecretError, /Decryption failed/) do
build_provisioner.run
end
end

def test_run_with_cloud_keys_secret_missing
mock_kubeclient.expects(:get_secret).with('ejson-keys', 'test').raises(KubeException.new(404, "not found", nil))
assert_raises_message(KubernetesDeploy::EjsonSecretError, /secret ejson-keys not found in namespace test/) do
realistic_err = "Error from server (NotFound): secrets \"ejson-keys\" not found"
stub_kubectl_response("get", "secret", "ejson-keys", "--output=json", resp: "", err: realistic_err, success: false)
assert_raises_message(KubernetesDeploy::EjsonSecretError, /secrets "ejson-keys" not found/) do
build_provisioner.run
end
end

def test_run_with_file_missing_section_for_managed_secrets_logs_warning
mock_kubeclient.expects(:get_secret).with('ejson-keys', 'test').returns("data" => correct_ejson_key_secret_data)
mock_kubeclient.expects(:get_secrets).with(namespace: 'test').returns([])
stub_kubectl_response("get", "secret", "ejson-keys", "--output=json", resp: dummy_ejson_secret)
stub_kubectl_response(
"get", "secrets", "--output=json",
resp: { items: [dummy_ejson_secret, dummy_secret_hash(managed: false)] }
)
new_content = { "_public_key" => fixture_public_key, "not_the_right_key" => [] }

with_ejson_file(new_content.to_json) do |target_dir|
Expand All @@ -75,7 +81,7 @@ def test_run_with_file_missing_section_for_managed_secrets_logs_warning
end

def test_run_with_incomplete_secret_spec
mock_kubeclient.expects(:get_secret).with('ejson-keys', 'test').returns("data" => correct_ejson_key_secret_data)
stub_kubectl_response("get", "secret", "ejson-keys", "--output=json", resp: dummy_ejson_secret)
new_content = {
"_public_key" => fixture_public_key,
"kubernetes_secrets" => { "foobar" => {} }
Expand All @@ -93,7 +99,7 @@ def test_run_with_incomplete_secret_spec

def correct_ejson_key_secret_data
{
fixture_public_key => "ZmVkY2M5NTEzMmU5YjM5OWVlMWY0MDQzNjRmZGJjODFiZGJlNGZlYjViODI5MmIwNjFmMTAyMjQ4MTE1N2Q1YQ=="
fixture_public_key => "fedcc95132e9b399ee1f404364fdbc81bdbe4feb5b8292b061f1022481157d5a"
}
end

Expand All @@ -108,16 +114,45 @@ def with_ejson_file(content)
end
end

def mock_kubeclient
@mock_kubeclient ||= mock('kubeclient')
def stub_kubectl_response(*args, resp:, err: "", success: true)
response = [resp.to_json, err, stub(success?: success)]
KubernetesDeploy::Kubectl.expects(:run_kubectl)
.with(*args, namespace: 'test', context: 'minikube')
.returns(response)
end

def dummy_ejson_secret(data = correct_ejson_key_secret_data)
dummy_secret_hash(data: data, name: 'ejson-keys', managed: false)
end

def dummy_secret_hash(name: SecureRandom.hex(4), data: {}, managed: true)
encoded_data = data.each_with_object({}) do |(key, value), encoded|
encoded[key] = Base64.encode64(value)
end

secret = {
'kind' => 'Secret',
'apiVersion' => 'v1',
'type' => 'Opaque',
'metadata' => {
"name" => name,
"labels" => { "name" => name },
"namespace" => 'test'
},
"data" => encoded_data
}
if managed
secret['metadata']['annotations'] = { KubernetesDeploy::EjsonSecretProvisioner::MANAGEMENT_ANNOTATION => true }
end
secret
end

def build_provisioner(dir = nil)
dir ||= fixture_path('ejson-cloud')
KubernetesDeploy::EjsonSecretProvisioner.new(
namespace: 'test',
template_dir: dir,
client: mock_kubeclient
context: KubeclientHelper::MINIKUBE_CONTEXT,
template_dir: dir
)
end
end