rkseal 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/exe/rkseal +9 -0
- data/lib/rkseal/cli.rb +471 -0
- data/lib/rkseal/commands/create.rb +121 -0
- data/lib/rkseal/commands/edit.rb +183 -0
- data/lib/rkseal/commands/edit_local.rb +302 -0
- data/lib/rkseal/commands/list.rb +130 -0
- data/lib/rkseal/commands/reencrypt.rb +118 -0
- data/lib/rkseal/commands/result.rb +22 -0
- data/lib/rkseal/commands/validate.rb +78 -0
- data/lib/rkseal/commands/view.rb +58 -0
- data/lib/rkseal/context_guard.rb +57 -0
- data/lib/rkseal/editor.rb +141 -0
- data/lib/rkseal/errors.rb +74 -0
- data/lib/rkseal/kubectl.rb +168 -0
- data/lib/rkseal/kubeseal.rb +382 -0
- data/lib/rkseal/sealed_secret.rb +204 -0
- data/lib/rkseal/secret.rb +534 -0
- data/lib/rkseal/secure_workspace.rb +432 -0
- data/lib/rkseal/version.rb +5 -0
- data/lib/rkseal.rb +89 -0
- data/rkseal.gemspec +53 -0
- metadata +127 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module RKSeal
|
|
7
|
+
# Domain model for the Kubernetes Secret that sits at the center of every
|
|
8
|
+
# rkseal flow.
|
|
9
|
+
#
|
|
10
|
+
# The editor buffer is a *full* Kubernetes Secret manifest (not a custom
|
|
11
|
+
# key->value format): the user controls `data` vs `stringData`, `type`, and
|
|
12
|
+
# `metadata` (labels/annotations). This class is the single place that knows
|
|
13
|
+
# how to:
|
|
14
|
+
#
|
|
15
|
+
# - build the seed manifest shown when authoring a new Secret (`create`);
|
|
16
|
+
# - turn the live cluster representation (`kubectl get secret -o json`) into
|
|
17
|
+
# an editable buffer, keeping `data` as *base64* (deliberately NOT decoded
|
|
18
|
+
# to plaintext) and stripping controller/runtime metadata that must not be
|
|
19
|
+
# re-sealed;
|
|
20
|
+
# - parse the saved buffer back into a Secret, accepting both `data`
|
|
21
|
+
# (base64, verbatim) and `stringData` (plaintext) and normalizing both
|
|
22
|
+
# into a single base64 `data` map, with `stringData` winning per key;
|
|
23
|
+
# - render a Secret to the exact YAML that gets piped into `kubeseal`;
|
|
24
|
+
# - merge a `--from-file` value into the manifest under a chosen key.
|
|
25
|
+
#
|
|
26
|
+
# == Why the canonical form is base64, not plaintext
|
|
27
|
+
#
|
|
28
|
+
# A SealedSecret cannot be decrypted client-side. The only source of current
|
|
29
|
+
# values for `edit` is the unsealed cluster Secret, whose `.data` is base64.
|
|
30
|
+
# rkseal deliberately surfaces that base64 verbatim rather than decoding it to
|
|
31
|
+
# plaintext: showing decoded plaintext in an on-screen/RAM buffer is a wider
|
|
32
|
+
# exposure than the operator already accepts by running `kubectl get secret`,
|
|
33
|
+
# and it lets binary/TLS payloads round-trip losslessly. The convenience of
|
|
34
|
+
# plaintext entry is preserved through `stringData`, which the operator may
|
|
35
|
+
# add in the buffer and which is folded into `data` on parse.
|
|
36
|
+
#
|
|
37
|
+
# It is a rich domain object: encoding, validation, and conversion live here,
|
|
38
|
+
# on the data they operate on -- not in external "builder"/"converter" verb
|
|
39
|
+
# classes.
|
|
40
|
+
#
|
|
41
|
+
# No method in this class shells out, touches disk, or talks to a cluster;
|
|
42
|
+
# it is pure data transformation and is trivially unit-testable.
|
|
43
|
+
#
|
|
44
|
+
# rubocop:disable Metrics/ClassLength -- this is the gem's single rich domain
|
|
45
|
+
# object: by design it owns all Secret encoding, parsing, validation, and the
|
|
46
|
+
# buffer/manifest conversions, on the data they operate on. Splitting it into
|
|
47
|
+
# verb classes (the anti-pattern this gem avoids) would scatter that cohesion
|
|
48
|
+
# for no gain; the extra lines are docstrings and small, focused helpers.
|
|
49
|
+
class Secret
|
|
50
|
+
# Kubernetes apiVersion/kind this model represents.
|
|
51
|
+
API_VERSION = "v1"
|
|
52
|
+
KIND = "Secret"
|
|
53
|
+
DEFAULT_TYPE = "Opaque"
|
|
54
|
+
|
|
55
|
+
# `metadata` keys that the apiserver/controller populate at runtime and that
|
|
56
|
+
# must be stripped before a Secret is re-sealed, so the buffer shows only
|
|
57
|
+
# author-owned fields.
|
|
58
|
+
RUNTIME_METADATA_KEYS = %w[
|
|
59
|
+
creationTimestamp resourceVersion uid generation selfLink managedFields
|
|
60
|
+
ownerReferences deletionTimestamp deletionGracePeriodSeconds finalizers
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
# Annotation kubectl injects that embeds the previous object (including its
|
|
64
|
+
# data) -- must be dropped so a stale copy of the secret is never re-sealed.
|
|
65
|
+
LAST_APPLIED_ANNOTATION = "kubectl.kubernetes.io/last-applied-configuration"
|
|
66
|
+
|
|
67
|
+
# Required data keys per well-known Secret type. kubeseal does not validate
|
|
68
|
+
# these (the failure would only surface on-cluster), so rkseal fails fast.
|
|
69
|
+
REQUIRED_KEYS_BY_TYPE = {
|
|
70
|
+
"kubernetes.io/tls" => %w[tls.crt tls.key],
|
|
71
|
+
"kubernetes.io/dockerconfigjson" => %w[.dockerconfigjson]
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
# Kubernetes DNS-1123 subdomain: lowercase alphanumerics, `-` and `.`
|
|
75
|
+
# internally, must start and end alphanumeric. Anchored so a leading `-`
|
|
76
|
+
# (argument injection into kubectl/kubeseal), a `/` or `..` (path traversal
|
|
77
|
+
# into the output filename), and uppercase are all rejected.
|
|
78
|
+
DNS_NAME_PATTERN = /\A[a-z0-9]([-a-z0-9.]*[a-z0-9])?\z/
|
|
79
|
+
# Maximum length of a DNS-1123 subdomain.
|
|
80
|
+
DNS_NAME_MAX_LENGTH = 253
|
|
81
|
+
|
|
82
|
+
# SealedSecret scope annotations -> rkseal scope symbol. Absence of both
|
|
83
|
+
# means the default, strict scope.
|
|
84
|
+
SCOPE_ANNOTATIONS = {
|
|
85
|
+
"sealedsecrets.bitnami.com/cluster-wide" => :cluster_wide,
|
|
86
|
+
"sealedsecrets.bitnami.com/namespace-wide" => :namespace_wide
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
# @return [String] the Secret name (from the CLI positional arg).
|
|
90
|
+
attr_reader :name
|
|
91
|
+
# @return [String] the namespace (from the CLI positional arg).
|
|
92
|
+
attr_reader :namespace
|
|
93
|
+
# @return [String] the Secret `type` (e.g. "Opaque", "kubernetes.io/tls").
|
|
94
|
+
attr_reader :type
|
|
95
|
+
# @return [Hash{String=>String}] data items keyed by data key, values held
|
|
96
|
+
# as *base64* (the canonical in-memory form), whether they originated from
|
|
97
|
+
# `data` (verbatim) or `stringData` (encoded on parse).
|
|
98
|
+
attr_reader :data
|
|
99
|
+
# @return [Hash] author-owned metadata (labels, annotations, ...) with
|
|
100
|
+
# runtime keys already stripped.
|
|
101
|
+
attr_reader :metadata
|
|
102
|
+
|
|
103
|
+
class << self
|
|
104
|
+
# Build the seed manifest for `rkseal create`: a minimal, valid Secret
|
|
105
|
+
# skeleton (correct apiVersion/kind/type, name + namespace filled in, no
|
|
106
|
+
# data) intended to be rendered to a commented template the user fills in.
|
|
107
|
+
#
|
|
108
|
+
# @param name [String]
|
|
109
|
+
# @param namespace [String]
|
|
110
|
+
# @param type [String] defaults to {DEFAULT_TYPE}.
|
|
111
|
+
# @return [RKSeal::Secret]
|
|
112
|
+
def seed(name:, namespace:, type: DEFAULT_TYPE)
|
|
113
|
+
new(name: name, namespace: namespace, type: type)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Build a Secret from the JSON `kubectl get secret -o json` returns.
|
|
117
|
+
#
|
|
118
|
+
# Keeps `.data` as base64 (no decode), folds any `.stringData` in (encoded
|
|
119
|
+
# to base64, winning per key), and strips {RUNTIME_METADATA_KEYS}, the
|
|
120
|
+
# last-applied-configuration annotation, and `status` so the result
|
|
121
|
+
# reflects only what the author controls. Entry point for the `edit` flow.
|
|
122
|
+
#
|
|
123
|
+
# @param json [String, Hash] raw JSON string or parsed Hash from kubectl.
|
|
124
|
+
# @return [RKSeal::Secret]
|
|
125
|
+
# @raise [RKSeal::InvalidInputError] if the JSON is malformed, not a
|
|
126
|
+
# Secret, or carries non-decodable base64 in `.data`.
|
|
127
|
+
def from_kubectl_json(json)
|
|
128
|
+
doc = json.is_a?(Hash) ? json : parse_json(json)
|
|
129
|
+
from_document(doc, data_is_base64: true)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Parse a saved editor buffer (full Secret manifest as YAML) back into a
|
|
133
|
+
# Secret. Folds `data` (base64, verbatim) and `stringData` (plaintext) into
|
|
134
|
+
# the canonical base64 {#data} map -- `stringData` wins per key -- and
|
|
135
|
+
# validates required fields.
|
|
136
|
+
#
|
|
137
|
+
# @param yaml [String] the raw buffer contents the editor returned.
|
|
138
|
+
# @return [RKSeal::Secret]
|
|
139
|
+
# @raise [RKSeal::InvalidInputError] on empty buffer, YAML syntax errors,
|
|
140
|
+
# wrong kind/apiVersion, missing name/namespace, or non-decodable base64
|
|
141
|
+
# under `data`.
|
|
142
|
+
def from_buffer(yaml)
|
|
143
|
+
raise InvalidInputError, "the edit buffer is empty" if yaml.nil? || yaml.strip.empty?
|
|
144
|
+
|
|
145
|
+
doc = parse_yaml(yaml)
|
|
146
|
+
unless doc.is_a?(Hash)
|
|
147
|
+
raise InvalidInputError, "the buffer is not a YAML mapping (expected a Secret manifest)"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
from_document(doc, data_is_base64: false)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Validate a CLI-supplied identifier (Secret name or namespace) against the
|
|
154
|
+
# Kubernetes DNS-1123 subdomain rules. This is a security boundary: a value
|
|
155
|
+
# such as `../../etc/foo`, `-ojson`, or one containing `/` must be rejected
|
|
156
|
+
# *before* it reaches the editor, the cluster, kubectl/kubeseal argv, or the
|
|
157
|
+
# `<name>.yaml` output path.
|
|
158
|
+
#
|
|
159
|
+
# @param field [String] human label for the value ("name" / "namespace").
|
|
160
|
+
# @param value [String] the value to check.
|
|
161
|
+
# @return [String] the validated value (for chaining).
|
|
162
|
+
# @raise [RKSeal::InvalidInputError] if it is not a valid DNS-1123 subdomain.
|
|
163
|
+
def validate_identifier!(field:, value:)
|
|
164
|
+
raise InvalidInputError, "#{field} must not be empty" if value.nil? || value.empty?
|
|
165
|
+
if value.length > DNS_NAME_MAX_LENGTH
|
|
166
|
+
raise InvalidInputError,
|
|
167
|
+
"#{field} #{value.inspect} is too long (max #{DNS_NAME_MAX_LENGTH} characters)"
|
|
168
|
+
end
|
|
169
|
+
return value if DNS_NAME_PATTERN.match?(value)
|
|
170
|
+
|
|
171
|
+
raise InvalidInputError,
|
|
172
|
+
"#{field} #{value.inspect} is not a valid Kubernetes name " \
|
|
173
|
+
"(lowercase letters, digits, '-' and '.', must start and end alphanumeric)"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Derive the sealing scope from a SealedSecret by inspecting its
|
|
177
|
+
# `metadata.annotations`. Used by `edit` to preserve the existing scope of a
|
|
178
|
+
# secret unless the operator overrides it. Accepts both the JSON kubectl
|
|
179
|
+
# prints and the YAML of a local `<name>.yaml` (YAML is a JSON superset, so
|
|
180
|
+
# one parser handles both). Unknown/absent annotations -> the default
|
|
181
|
+
# :strict scope. Malformed input is tolerated (returns :strict) so a scope
|
|
182
|
+
# probe never aborts the flow -- the caller has its own fallbacks.
|
|
183
|
+
#
|
|
184
|
+
# @param document [String, Hash] the SealedSecret as JSON/YAML text or a
|
|
185
|
+
# pre-parsed Hash.
|
|
186
|
+
# @return [Symbol] :strict, :namespace_wide, or :cluster_wide.
|
|
187
|
+
def scope_from_sealed_json(document)
|
|
188
|
+
doc = document.is_a?(Hash) ? document : safe_parse(document)
|
|
189
|
+
annotations = doc.is_a?(Hash) ? doc.dig("metadata", "annotations") : nil
|
|
190
|
+
return :strict unless annotations.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
SCOPE_ANNOTATIONS.find(-> { [nil, :strict] }) do |annotation, _|
|
|
193
|
+
truthy_annotation?(annotations[annotation])
|
|
194
|
+
end.last
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# Parse a SealedSecret document (JSON from kubectl or YAML from a local
|
|
200
|
+
# file) into a Hash, returning nil rather than raising on malformed input.
|
|
201
|
+
# YAML.safe_load parses JSON too, so a single call covers both sources. The
|
|
202
|
+
# scope probe is best-effort and must not abort the edit flow.
|
|
203
|
+
def safe_parse(text)
|
|
204
|
+
parsed = YAML.safe_load(text, permitted_classes: [], aliases: false)
|
|
205
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
206
|
+
rescue Psych::SyntaxError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# k8s treats the scope annotation as set when its value is the string
|
|
211
|
+
# "true" (the controller writes exactly that); be lenient about casing.
|
|
212
|
+
def truthy_annotation?(value)
|
|
213
|
+
value.to_s.strip.casecmp?("true")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Shared construction for both cluster JSON and editor YAML. `data_is_base64`
|
|
217
|
+
# toggles whether the parser-provided `data` values are trusted as base64
|
|
218
|
+
# already (kubectl) or must merely be normalized/validated as base64 (buffer);
|
|
219
|
+
# `stringData` is always plaintext and is encoded here.
|
|
220
|
+
def from_document(doc, data_is_base64:)
|
|
221
|
+
validate_kind!(doc)
|
|
222
|
+
metadata = extract_metadata(doc)
|
|
223
|
+
name = fetch_name(metadata, doc)
|
|
224
|
+
namespace = metadata["namespace"] || doc.dig("metadata", "namespace")
|
|
225
|
+
type = doc["type"] || DEFAULT_TYPE
|
|
226
|
+
|
|
227
|
+
new(
|
|
228
|
+
name: name,
|
|
229
|
+
namespace: namespace,
|
|
230
|
+
type: type,
|
|
231
|
+
data: fold_data(doc["data"], doc["stringData"], data_is_base64: data_is_base64),
|
|
232
|
+
metadata: strip_metadata(metadata)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def parse_json(text)
|
|
237
|
+
require "json"
|
|
238
|
+
JSON.parse(text)
|
|
239
|
+
rescue JSON::ParserError => e
|
|
240
|
+
raise InvalidInputError, "kubectl did not return valid JSON: #{e.message}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def parse_yaml(text)
|
|
244
|
+
YAML.safe_load(text, permitted_classes: [], aliases: false)
|
|
245
|
+
rescue Psych::SyntaxError => e
|
|
246
|
+
raise InvalidInputError, "the buffer is not valid YAML: #{e.message}"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def validate_kind!(doc)
|
|
250
|
+
kind = doc["kind"]
|
|
251
|
+
api = doc["apiVersion"]
|
|
252
|
+
unless kind == KIND
|
|
253
|
+
raise InvalidInputError,
|
|
254
|
+
"not a Kubernetes Secret (kind: #{kind.inspect})"
|
|
255
|
+
end
|
|
256
|
+
return if api == API_VERSION
|
|
257
|
+
|
|
258
|
+
raise InvalidInputError,
|
|
259
|
+
"unexpected apiVersion #{api.inspect} (expected #{API_VERSION.inspect})"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def extract_metadata(doc)
|
|
263
|
+
metadata = doc["metadata"]
|
|
264
|
+
return {} if metadata.nil?
|
|
265
|
+
raise InvalidInputError, "metadata must be a mapping" unless metadata.is_a?(Hash)
|
|
266
|
+
|
|
267
|
+
metadata
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def fetch_name(metadata, _doc)
|
|
271
|
+
name = metadata["name"]
|
|
272
|
+
raise InvalidInputError, "the Secret is missing metadata.name" if blank?(name)
|
|
273
|
+
|
|
274
|
+
name
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Merge base64 `data` and plaintext `stringData` into one base64 map.
|
|
278
|
+
# stringData wins per key (Kubernetes semantics). data values are validated
|
|
279
|
+
# as base64 regardless of source so a malformed buffer fails fast.
|
|
280
|
+
def fold_data(raw_data, raw_string_data, data_is_base64:)
|
|
281
|
+
data = normalize_data_map(raw_data, data_is_base64: data_is_base64)
|
|
282
|
+
normalize_string_data(raw_string_data).each do |key, value|
|
|
283
|
+
data[key] = Base64.strict_encode64(value)
|
|
284
|
+
end
|
|
285
|
+
data
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def normalize_data_map(raw, data_is_base64:)
|
|
289
|
+
return {} if raw.nil?
|
|
290
|
+
unless raw.is_a?(Hash)
|
|
291
|
+
raise InvalidInputError,
|
|
292
|
+
"`data` must be a mapping of key to base64 value"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
raw.each_with_object({}) do |(key, value), acc|
|
|
296
|
+
acc[key.to_s] = validated_base64(key, value, trusted: data_is_base64)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def normalize_string_data(raw)
|
|
301
|
+
return {} if raw.nil?
|
|
302
|
+
unless raw.is_a?(Hash)
|
|
303
|
+
raise InvalidInputError, "`stringData` must be a mapping of key to plaintext value"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
raw.transform_keys(&:to_s).transform_values { |value| stringify(value) }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# A `data` value must be valid base64. We strip surrounding whitespace (a
|
|
310
|
+
# stray newline a user may introduce in the editor) before validating, and
|
|
311
|
+
# re-emit canonical strict base64 so the in-memory form is consistent.
|
|
312
|
+
def validated_base64(key, value, trusted:)
|
|
313
|
+
text = stringify(value).strip
|
|
314
|
+
decoded = Base64.strict_decode64(text)
|
|
315
|
+
trusted ? text : Base64.strict_encode64(decoded)
|
|
316
|
+
rescue ArgumentError
|
|
317
|
+
raise InvalidInputError, "value for data key #{key.to_s.inspect} is not valid base64"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def strip_metadata(metadata)
|
|
321
|
+
cleaned = metadata.except(*RUNTIME_METADATA_KEYS, "name", "namespace", "status")
|
|
322
|
+
scrub_annotations(cleaned)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def scrub_annotations(metadata)
|
|
326
|
+
annotations = metadata["annotations"]
|
|
327
|
+
return metadata unless annotations.is_a?(Hash)
|
|
328
|
+
|
|
329
|
+
remaining = annotations.except(LAST_APPLIED_ANNOTATION)
|
|
330
|
+
return metadata.except("annotations") if remaining.empty?
|
|
331
|
+
|
|
332
|
+
metadata.merge("annotations" => remaining)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def stringify(value)
|
|
336
|
+
value.is_a?(String) ? value : value.to_s
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def blank?(value)
|
|
340
|
+
value.nil? || (value.respond_to?(:strip) && value.strip.empty?)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# @param name [String]
|
|
345
|
+
# @param namespace [String]
|
|
346
|
+
# @param data [Hash{String=>String}] base64-encoded items.
|
|
347
|
+
# @param type [String]
|
|
348
|
+
# @param metadata [Hash] author-owned metadata (labels/annotations); name
|
|
349
|
+
# and namespace are tracked separately and need not be duplicated here.
|
|
350
|
+
def initialize(name:, namespace:, data: {}, type: DEFAULT_TYPE, metadata: {})
|
|
351
|
+
@name = name
|
|
352
|
+
@namespace = namespace
|
|
353
|
+
@data = data.freeze
|
|
354
|
+
@type = type
|
|
355
|
+
@metadata = metadata.freeze
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Render this Secret to the editor/view buffer representation: a complete
|
|
359
|
+
# Secret manifest as a YAML string.
|
|
360
|
+
#
|
|
361
|
+
# By default the values are presented as `data` (base64): emitted verbatim
|
|
362
|
+
# when present (the canonical, never-decoded form used by `edit` and `view`),
|
|
363
|
+
# or as an empty `data` block for the `create` seed.
|
|
364
|
+
#
|
|
365
|
+
# In *plaintext mode* (`string_data:` for the editors, `reveal:` for
|
|
366
|
+
# `view`) the value block is `stringData` instead: an empty `stringData`
|
|
367
|
+
# block for the seed, or the base64 `data` decoded to readable plaintext for
|
|
368
|
+
# a populated Secret. Decoding a populated Secret is an opt-in plaintext
|
|
369
|
+
# exposure (folded back to `data` on parse / read-only for `view`).
|
|
370
|
+
#
|
|
371
|
+
# @param commented [Boolean] include the explanatory header/comments
|
|
372
|
+
# (true for the `create` seed; typically false for round-tripping).
|
|
373
|
+
# @param reveal [Boolean] `view`'s plaintext switch (decode to `stringData`).
|
|
374
|
+
# @param string_data [Boolean] the editors' plaintext switch; same effect as
|
|
375
|
+
# `reveal`. Default false -> the `data` (base64) block.
|
|
376
|
+
# @return [String] YAML suitable to hand to {RKSeal::Editor} or to print.
|
|
377
|
+
def to_buffer(commented: false, reveal: false, string_data: false)
|
|
378
|
+
body = base_manifest
|
|
379
|
+
apply_data_block(body, plaintext: reveal || string_data)
|
|
380
|
+
|
|
381
|
+
yaml = dump_yaml(body)
|
|
382
|
+
commented ? "#{buffer_header}#{yaml}" : yaml
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Render this Secret to the canonical manifest YAML piped into `kubeseal`.
|
|
386
|
+
#
|
|
387
|
+
# Emits a clean Secret with base64 `data` only (stringData has already been
|
|
388
|
+
# folded in on parse). The scope annotation is NOT injected here -- scope is
|
|
389
|
+
# applied by {RKSeal::Kubeseal#seal} via `--scope`. This method only
|
|
390
|
+
# validates the scope argument and serializes the Secret.
|
|
391
|
+
#
|
|
392
|
+
# @param scope [Symbol] one of :strict, :namespace_wide, :cluster_wide.
|
|
393
|
+
# @return [String] manifest YAML for kubeseal's stdin.
|
|
394
|
+
# @raise [RKSeal::InvalidInputError] if scope is unknown.
|
|
395
|
+
def to_manifest(scope: :strict)
|
|
396
|
+
validate_scope!(scope)
|
|
397
|
+
body = base_manifest
|
|
398
|
+
body["data"] = data.dup unless data.empty?
|
|
399
|
+
dump_yaml(body)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Return a copy of this Secret with one item set from a file's contents
|
|
403
|
+
# (for the `--from-file` feature). Binary-safe: the file's bytes are base64
|
|
404
|
+
# encoded into the data map (consistent with the base64 canonical form).
|
|
405
|
+
#
|
|
406
|
+
# @param key [String] data key to set.
|
|
407
|
+
# @param contents [String] the file's bytes (read by the caller).
|
|
408
|
+
# @return [RKSeal::Secret] a new Secret with the item merged in.
|
|
409
|
+
def with_value(key:, contents:)
|
|
410
|
+
merged = data.merge(key.to_s => Base64.strict_encode64(contents))
|
|
411
|
+
self.class.new(
|
|
412
|
+
name: name, namespace: namespace, type: type, data: merged, metadata: metadata
|
|
413
|
+
)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# @return [Boolean] true when there are no data items -- used to reject an
|
|
417
|
+
# empty edit buffer (fail fast).
|
|
418
|
+
def empty?
|
|
419
|
+
data.empty?
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Assert this Secret satisfies the required-key contract for its `type`.
|
|
423
|
+
# Opaque imposes no requirement; TLS/dockerconfigjson do. kubeseal does not
|
|
424
|
+
# check this, so rkseal fails fast before sealing an on-cluster-broken Secret.
|
|
425
|
+
#
|
|
426
|
+
# @return [void]
|
|
427
|
+
# @raise [RKSeal::InvalidInputError] if a required key for {#type} is absent.
|
|
428
|
+
def validate!
|
|
429
|
+
raise InvalidInputError, "the Secret has no data items" if empty?
|
|
430
|
+
|
|
431
|
+
missing = REQUIRED_KEYS_BY_TYPE.fetch(type, []).reject { |key| data.key?(key) }
|
|
432
|
+
return if missing.empty?
|
|
433
|
+
|
|
434
|
+
raise InvalidInputError,
|
|
435
|
+
"Secret type #{type.inspect} requires #{missing.join(", ")} " \
|
|
436
|
+
"(present: #{data.keys.sort.join(", ")})"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Value equality over the author-owned fields (name, namespace, type, data,
|
|
440
|
+
# metadata). Lets the `edit` flow detect an unchanged buffer and skip work.
|
|
441
|
+
# Because `data` is the canonical base64 form, equal data means equal
|
|
442
|
+
# plaintext regardless of whether it was entered via `data` or `stringData`.
|
|
443
|
+
#
|
|
444
|
+
# @param other [Object]
|
|
445
|
+
# @return [Boolean]
|
|
446
|
+
def ==(other)
|
|
447
|
+
other.is_a?(Secret) &&
|
|
448
|
+
name == other.name &&
|
|
449
|
+
namespace == other.namespace &&
|
|
450
|
+
type == other.type &&
|
|
451
|
+
data == other.data &&
|
|
452
|
+
metadata == other.metadata
|
|
453
|
+
end
|
|
454
|
+
alias eql? ==
|
|
455
|
+
|
|
456
|
+
# @return [Integer] hash consistent with {#==}.
|
|
457
|
+
def hash
|
|
458
|
+
[self.class, name, namespace, type, data, metadata].hash
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
private
|
|
462
|
+
|
|
463
|
+
# The shared apiVersion/kind/type/metadata envelope, without any data block.
|
|
464
|
+
def base_manifest
|
|
465
|
+
{
|
|
466
|
+
"apiVersion" => API_VERSION,
|
|
467
|
+
"kind" => KIND,
|
|
468
|
+
"metadata" => manifest_metadata,
|
|
469
|
+
"type" => type
|
|
470
|
+
}
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Attach the right value block for a buffer. Default (base64) shows `data`:
|
|
474
|
+
# an empty block for a brand-new Secret, or the verbatim map otherwise. In
|
|
475
|
+
# plaintext mode it shows `stringData`: an empty block for the seed, or the
|
|
476
|
+
# base64 `data` decoded to readable plaintext.
|
|
477
|
+
def apply_data_block(body, plaintext:)
|
|
478
|
+
if data.empty?
|
|
479
|
+
body[plaintext ? "stringData" : "data"] = {}
|
|
480
|
+
elsif plaintext
|
|
481
|
+
body["stringData"] = revealed_data
|
|
482
|
+
else
|
|
483
|
+
body["data"] = data.dup
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Decode the base64 data map to plaintext for `view --reveal`. Values
|
|
488
|
+
# normally come from the cluster (already valid base64); we still map a
|
|
489
|
+
# malformed value to InvalidInputError for consistency with the rest of the
|
|
490
|
+
# model rather than letting a raw ArgumentError escape.
|
|
491
|
+
def revealed_data
|
|
492
|
+
data.transform_values do |value|
|
|
493
|
+
Base64.strict_decode64(value)
|
|
494
|
+
rescue ArgumentError
|
|
495
|
+
raise InvalidInputError, "stored data value is not valid base64; cannot reveal"
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def manifest_metadata
|
|
500
|
+
{ "name" => name, "namespace" => namespace }.merge(metadata)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def validate_scope!(scope)
|
|
504
|
+
return if %i[strict namespace_wide cluster_wide].include?(scope)
|
|
505
|
+
|
|
506
|
+
raise InvalidInputError,
|
|
507
|
+
"unknown scope #{scope.inspect} " \
|
|
508
|
+
"(expected :strict, :namespace_wide, or :cluster_wide)"
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Dump without the leading "---" document marker for a cleaner buffer.
|
|
512
|
+
def dump_yaml(body)
|
|
513
|
+
YAML.dump(body).delete_prefix("---\n")
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def buffer_header
|
|
517
|
+
<<~HEADER
|
|
518
|
+
# rkseal: edit this Kubernetes Secret, then save and quit.
|
|
519
|
+
#
|
|
520
|
+
# `data:` values are base64, shown VERBATIM -- they are never decoded to
|
|
521
|
+
# plaintext. To set a value as readable plaintext, add it under a
|
|
522
|
+
# `stringData:` block; on save, stringData is folded into data and wins
|
|
523
|
+
# per key. For example, to change the value of `password` you would add:
|
|
524
|
+
#
|
|
525
|
+
# stringData:
|
|
526
|
+
# password: my-new-plaintext-secret
|
|
527
|
+
#
|
|
528
|
+
# `type`, labels, and annotations under `metadata` are yours to edit.
|
|
529
|
+
# An empty buffer (no data and no stringData) is rejected.
|
|
530
|
+
HEADER
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
# rubocop:enable Metrics/ClassLength
|
|
534
|
+
end
|