/
ejson_secret_provisioner.rb
207 lines (175 loc) · 6.71 KB
/
ejson_secret_provisioner.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# frozen_string_literal: true
require 'json'
require 'base64'
require 'open3'
require 'kubernetes-deploy/kubectl'
module KubernetesDeploy
class EjsonSecretError < FatalDeploymentError
def initialize(msg)
super("Creation of Kubernetes secrets from ejson failed: #{msg}")
end
end
class EjsonSecretProvisioner
MANAGEMENT_ANNOTATION = "kubernetes-deploy.shopify.io/ejson-secret"
MANAGED_SECRET_EJSON_KEY = "kubernetes_secrets"
EJSON_SECRETS_FILE = "secrets.ejson"
EJSON_KEYS_SECRET = "ejson-keys"
def initialize(namespace:, context:, template_dir:, logger:, prune: true)
@namespace = namespace
@context = context
@ejson_file = "#{template_dir}/#{EJSON_SECRETS_FILE}"
@logger = logger
@prune = prune
@kubectl = Kubectl.new(
namespace: @namespace,
context: @context,
logger: @logger,
log_failure_by_default: false,
output_is_sensitive: true # output may contain ejson secrets
)
end
def secret_changes_required?
File.exist?(@ejson_file) || managed_secrets_exist?
end
def run
create_secrets
prune_managed_secrets if @prune
end
private
def create_secrets
with_decrypted_ejson do |decrypted|
secrets = decrypted[MANAGED_SECRET_EJSON_KEY]
unless secrets.present?
@logger.warn("#{EJSON_SECRETS_FILE} does not have key #{MANAGED_SECRET_EJSON_KEY}."\
"No secrets will be created.")
return
end
secrets.each do |secret_name, secret_spec|
validate_secret_spec(secret_name, secret_spec)
create_or_update_secret(secret_name, secret_spec["_type"], secret_spec["data"])
end
@logger.summary.add_action("created/updated #{secrets.length} #{'secret'.pluralize(secrets.length)}")
end
end
def prune_managed_secrets
ejson_secret_names = encrypted_ejson.fetch(MANAGED_SECRET_EJSON_KEY, {}).keys
live_secrets = run_kubectl_json("get", "secrets")
prune_count = 0
live_secrets.each do |secret|
secret_name = secret["metadata"]["name"]
next unless secret_managed?(secret)
next if ejson_secret_names.include?(secret_name)
@logger.info("Pruning secret #{secret_name}")
prune_count += 1
out, err, st = @kubectl.run("delete", "secret", secret_name)
@logger.debug(out)
raise EjsonSecretError, "Failed to prune secrets" unless st.success?
end
@logger.summary.add_action("pruned #{prune_count} #{'secret'.pluralize(prune_count)}") if prune_count > 0
end
def managed_secrets_exist?
all_secrets = run_kubectl_json("get", "secrets")
all_secrets.any? { |secret| secret_managed?(secret) }
end
def secret_managed?(secret)
secret["metadata"].fetch("annotations", {}).key?(MANAGEMENT_ANNOTATION)
end
def encrypted_ejson
@encrypted_ejson ||= load_ejson_from_file
end
def public_key
encrypted_ejson["_public_key"]
end
def private_key
@private_key ||= fetch_private_key_from_secret
end
def validate_secret_spec(secret_name, spec)
errors = []
errors << "secret type unspecified" if spec["_type"].blank?
errors << "no data provided" if spec["data"].blank?
unless errors.empty?
raise EjsonSecretError, "Ejson incomplete for secret #{secret_name}: #{errors.join(', ')}"
end
end
def create_or_update_secret(secret_name, secret_type, data)
msg = secret_exists?(secret_name) ? "Updating secret #{secret_name}" : "Creating secret #{secret_name}"
@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 = @kubectl.run("apply", "--filename=#{file.path}")
@logger.debug(out)
raise EjsonSecretError, "Failed to create or update secrets" unless st.success?
ensure
file&.unlink
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|
# Leading underscores in ejson keys are used to skip encryption of the associated value
# To support this ejson feature, we need to exclude these leading underscores from the secret's keys
secret_key = key.sub(/\A_/, '')
encoded[secret_key] = Base64.strict_encode64(value)
end
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_name)
_out, _err, st = @kubectl.run("get", "secret", secret_name)
st.success?
end
def load_ejson_from_file
return {} unless File.exist?(@ejson_file)
JSON.parse(File.read(@ejson_file))
rescue JSON::ParserError => e
raise EjsonSecretError, "Failed to parse encrypted ejson:\n #{e}"
end
def with_decrypted_ejson
return unless File.exist?(@ejson_file)
Dir.mktmpdir("ejson_keydir") do |key_dir|
File.write(File.join(key_dir, public_key), private_key)
decrypted = decrypt_ejson(key_dir)
yield decrypted
end
end
def decrypt_ejson(key_dir)
@logger.info("Decrypting #{EJSON_SECRETS_FILE}")
# ejson seems to dump both errors and output to STDOUT
out_err, st = Open3.capture2e("EJSON_KEYDIR=#{key_dir} ejson decrypt #{@ejson_file}")
raise EjsonSecretError, out_err unless st.success?
JSON.parse(out_err)
rescue JSON::ParserError => e
raise EjsonSecretError, "Failed to parse decrypted ejson"
end
def fetch_private_key_from_secret
@logger.info("Fetching ejson private key from secret #{EJSON_KEYS_SECRET}")
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)
end
def run_kubectl_json(*args)
args += ["--output=json"]
out, err, st = @kubectl.run(*args)
raise EjsonSecretError, err unless st.success?
result = JSON.parse(out)
result.fetch('items', result)
end
end
end