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.
@@ -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