Class: RKSeal::Secret

Inherits:
Object
  • Object
show all
Defined in:
lib/rkseal/secret.rb

Overview

Domain model for the Kubernetes Secret that sits at the center of every rkseal flow.

The editor buffer is a full Kubernetes Secret manifest (not a custom key->value format): the user controls ‘data` vs `stringData`, `type`, and `metadata` (labels/annotations). This class is the single place that knows how to:

- build the seed manifest shown when authoring a new Secret (`create`);
- turn the live cluster representation (`kubectl get secret -o json`) into
  an editable buffer, keeping `data` as *base64* (deliberately NOT decoded
  to plaintext) and stripping controller/runtime metadata that must not be
  re-sealed;
- parse the saved buffer back into a Secret, accepting both `data`
  (base64, verbatim) and `stringData` (plaintext) and normalizing both
  into a single base64 `data` map, with `stringData` winning per key;
- render a Secret to the exact YAML that gets piped into `kubeseal`;
- merge a `--from-file` value into the manifest under a chosen key.

Why the canonical form is base64, not plaintext

A SealedSecret cannot be decrypted client-side. The only source of current values for ‘edit` is the unsealed cluster Secret, whose `.data` is base64. rkseal deliberately surfaces that base64 verbatim rather than decoding it to plaintext: showing decoded plaintext in an on-screen/RAM buffer is a wider exposure than the operator already accepts by running `kubectl get secret`, and it lets binary/TLS payloads round-trip losslessly. The convenience of plaintext entry is preserved through `stringData`, which the operator may add in the buffer and which is folded into `data` on parse.

It is a rich domain object: encoding, validation, and conversion live here, on the data they operate on – not in external “builder”/“converter” verb classes.

No method in this class shells out, touches disk, or talks to a cluster; it is pure data transformation and is trivially unit-testable.

rubocop:disable Metrics/ClassLength – this is the gem’s single rich domain object: by design it owns all Secret encoding, parsing, validation, and the buffer/manifest conversions, on the data they operate on. Splitting it into verb classes (the anti-pattern this gem avoids) would scatter that cohesion for no gain; the extra lines are docstrings and small, focused helpers.

Constant Summary collapse

API_VERSION =

Kubernetes apiVersion/kind this model represents.

"v1"
KIND =
"Secret"
DEFAULT_TYPE =
"Opaque"
RUNTIME_METADATA_KEYS =

‘metadata` keys that the apiserver/controller populate at runtime and that must be stripped before a Secret is re-sealed, so the buffer shows only author-owned fields.

%w[
  creationTimestamp resourceVersion uid generation selfLink managedFields
  ownerReferences deletionTimestamp deletionGracePeriodSeconds finalizers
].freeze
LAST_APPLIED_ANNOTATION =

Annotation kubectl injects that embeds the previous object (including its data) – must be dropped so a stale copy of the secret is never re-sealed.

"kubectl.kubernetes.io/last-applied-configuration"
REQUIRED_KEYS_BY_TYPE =

Required data keys per well-known Secret type. kubeseal does not validate these (the failure would only surface on-cluster), so rkseal fails fast.

{
  "kubernetes.io/tls" => %w[tls.crt tls.key],
  "kubernetes.io/dockerconfigjson" => %w[.dockerconfigjson]
}.freeze
DNS_NAME_PATTERN =

Kubernetes DNS-1123 subdomain: lowercase alphanumerics, ‘-` and `.` internally, must start and end alphanumeric. Anchored so a leading `-` (argument injection into kubectl/kubeseal), a `/` or `..` (path traversal into the output filename), and uppercase are all rejected.

/\A[a-z0-9]([-a-z0-9.]*[a-z0-9])?\z/
DNS_NAME_MAX_LENGTH =

Maximum length of a DNS-1123 subdomain.

253
SCOPE_ANNOTATIONS =

SealedSecret scope annotations -> rkseal scope symbol. Absence of both means the default, strict scope.

{
  "sealedsecrets.bitnami.com/cluster-wide" => :cluster_wide,
  "sealedsecrets.bitnami.com/namespace-wide" => :namespace_wide
}.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, namespace:, data: {}, type: DEFAULT_TYPE, metadata: {}) ⇒ Secret

Returns a new instance of Secret.

Parameters:

  • name (String)
  • namespace (String)
  • data (Hash{String=>String}) (defaults to: {})

    base64-encoded items.

  • type (String) (defaults to: DEFAULT_TYPE)
  • metadata (Hash) (defaults to: {})

    author-owned metadata (labels/annotations); name and namespace are tracked separately and need not be duplicated here.



350
351
352
353
354
355
356
# File 'lib/rkseal/secret.rb', line 350

def initialize(name:, namespace:, data: {}, type: DEFAULT_TYPE, metadata: {})
  @name = name
  @namespace = namespace
  @data = data.freeze
  @type = type
  @metadata = .freeze
end

Instance Attribute Details

#dataHash{String=>String} (readonly)

Returns data items keyed by data key, values held as base64 (the canonical in-memory form), whether they originated from ‘data` (verbatim) or `stringData` (encoded on parse).

Returns:

  • (Hash{String=>String})

    data items keyed by data key, values held as base64 (the canonical in-memory form), whether they originated from ‘data` (verbatim) or `stringData` (encoded on parse).



98
99
100
# File 'lib/rkseal/secret.rb', line 98

def data
  @data
end

#metadataHash (readonly)

Returns author-owned metadata (labels, annotations, …) with runtime keys already stripped.

Returns:

  • (Hash)

    author-owned metadata (labels, annotations, …) with runtime keys already stripped.



101
102
103
# File 'lib/rkseal/secret.rb', line 101

def 
  @metadata
end

#nameString (readonly)

Returns the Secret name (from the CLI positional arg).

Returns:

  • (String)

    the Secret name (from the CLI positional arg).



90
91
92
# File 'lib/rkseal/secret.rb', line 90

def name
  @name
end

#namespaceString (readonly)

Returns the namespace (from the CLI positional arg).

Returns:

  • (String)

    the namespace (from the CLI positional arg).



92
93
94
# File 'lib/rkseal/secret.rb', line 92

def namespace
  @namespace
end

#typeString (readonly)

Returns the Secret ‘type` (e.g. “Opaque”, “kubernetes.io/tls”).

Returns:

  • (String)

    the Secret ‘type` (e.g. “Opaque”, “kubernetes.io/tls”).



94
95
96
# File 'lib/rkseal/secret.rb', line 94

def type
  @type
end

Class Method Details

.from_buffer(yaml) ⇒ RKSeal::Secret

Parse a saved editor buffer (full Secret manifest as YAML) back into a Secret. Folds ‘data` (base64, verbatim) and `stringData` (plaintext) into the canonical base64 #data map – `stringData` wins per key – and validates required fields.

Parameters:

  • yaml (String)

    the raw buffer contents the editor returned.

Returns:

Raises:

  • (RKSeal::InvalidInputError)

    on empty buffer, YAML syntax errors, wrong kind/apiVersion, missing name/namespace, or non-decodable base64 under ‘data`.



142
143
144
145
146
147
148
149
150
151
# File 'lib/rkseal/secret.rb', line 142

def from_buffer(yaml)
  raise InvalidInputError, "the edit buffer is empty" if yaml.nil? || yaml.strip.empty?

  doc = parse_yaml(yaml)
  unless doc.is_a?(Hash)
    raise InvalidInputError, "the buffer is not a YAML mapping (expected a Secret manifest)"
  end

  from_document(doc, data_is_base64: false)
end

.from_kubectl_json(json) ⇒ RKSeal::Secret

Build a Secret from the JSON ‘kubectl get secret -o json` returns.

Keeps ‘.data` as base64 (no decode), folds any `.stringData` in (encoded to base64, winning per key), and strips RUNTIME_METADATA_KEYS, the last-applied-configuration annotation, and `status` so the result reflects only what the author controls. Entry point for the `edit` flow.

Parameters:

  • json (String, Hash)

    raw JSON string or parsed Hash from kubectl.

Returns:

Raises:



127
128
129
130
# File 'lib/rkseal/secret.rb', line 127

def from_kubectl_json(json)
  doc = json.is_a?(Hash) ? json : parse_json(json)
  from_document(doc, data_is_base64: true)
end

.scope_from_sealed_json(document) ⇒ Symbol

Derive the sealing scope from a SealedSecret by inspecting its ‘metadata.annotations`. Used by `edit` to preserve the existing scope of a secret unless the operator overrides it. Accepts both the JSON kubectl prints and the YAML of a local `<name>.yaml` (YAML is a JSON superset, so one parser handles both). Unknown/absent annotations -> the default :strict scope. Malformed input is tolerated (returns :strict) so a scope probe never aborts the flow – the caller has its own fallbacks.

Parameters:

  • document (String, Hash)

    the SealedSecret as JSON/YAML text or a pre-parsed Hash.

Returns:

  • (Symbol)

    :strict, :namespace_wide, or :cluster_wide.



187
188
189
190
191
192
193
194
195
# File 'lib/rkseal/secret.rb', line 187

def scope_from_sealed_json(document)
  doc = document.is_a?(Hash) ? document : safe_parse(document)
  annotations = doc.is_a?(Hash) ? doc.dig("metadata", "annotations") : nil
  return :strict unless annotations.is_a?(Hash)

  SCOPE_ANNOTATIONS.find(-> { [nil, :strict] }) do |annotation, _|
    truthy_annotation?(annotations[annotation])
  end.last
end

.seed(name:, namespace:, type: DEFAULT_TYPE) ⇒ RKSeal::Secret

Build the seed manifest for ‘rkseal create`: a minimal, valid Secret skeleton (correct apiVersion/kind/type, name + namespace filled in, no data) intended to be rendered to a commented template the user fills in.

Parameters:

  • name (String)
  • namespace (String)
  • type (String) (defaults to: DEFAULT_TYPE)

    defaults to DEFAULT_TYPE.

Returns:



112
113
114
# File 'lib/rkseal/secret.rb', line 112

def seed(name:, namespace:, type: DEFAULT_TYPE)
  new(name: name, namespace: namespace, type: type)
end

.validate_identifier!(field:, value:) ⇒ String

Validate a CLI-supplied identifier (Secret name or namespace) against the Kubernetes DNS-1123 subdomain rules. This is a security boundary: a value such as ‘../../etc/foo`, `-ojson`, or one containing `/` must be rejected before it reaches the editor, the cluster, kubectl/kubeseal argv, or the `<name>.yaml` output path.

Parameters:

  • field (String)

    human label for the value (“name” / “namespace”).

  • value (String)

    the value to check.

Returns:

  • (String)

    the validated value (for chaining).

Raises:



163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/rkseal/secret.rb', line 163

def validate_identifier!(field:, value:)
  raise InvalidInputError, "#{field} must not be empty" if value.nil? || value.empty?
  if value.length > DNS_NAME_MAX_LENGTH
    raise InvalidInputError,
          "#{field} #{value.inspect} is too long (max #{DNS_NAME_MAX_LENGTH} characters)"
  end
  return value if DNS_NAME_PATTERN.match?(value)

  raise InvalidInputError,
        "#{field} #{value.inspect} is not a valid Kubernetes name " \
        "(lowercase letters, digits, '-' and '.', must start and end alphanumeric)"
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Value equality over the author-owned fields (name, namespace, type, data, metadata). Lets the ‘edit` flow detect an unchanged buffer and skip work. Because `data` is the canonical base64 form, equal data means equal plaintext regardless of whether it was entered via `data` or `stringData`.

Parameters:

  • other (Object)

Returns:

  • (Boolean)


446
447
448
449
450
451
452
453
# File 'lib/rkseal/secret.rb', line 446

def ==(other)
  other.is_a?(Secret) &&
    name == other.name &&
    namespace == other.namespace &&
    type == other.type &&
    data == other.data &&
     == other.
end

#empty?Boolean

Returns true when there are no data items – used to reject an empty edit buffer (fail fast).

Returns:

  • (Boolean)

    true when there are no data items – used to reject an empty edit buffer (fail fast).



418
419
420
# File 'lib/rkseal/secret.rb', line 418

def empty?
  data.empty?
end

#hashInteger

Returns hash consistent with #==.

Returns:

  • (Integer)

    hash consistent with #==.



457
458
459
# File 'lib/rkseal/secret.rb', line 457

def hash
  [self.class, name, namespace, type, data, ].hash
end

#to_buffer(commented: false, reveal: false, string_data: false) ⇒ String

Render this Secret to the editor/view buffer representation: a complete Secret manifest as a YAML string.

By default the values are presented as ‘data` (base64): emitted verbatim when present (the canonical, never-decoded form used by `edit` and `view`), or as an empty `data` block for the `create` seed.

In *plaintext mode* (‘string_data:` for the editors, `reveal:` for `view`) the value block is `stringData` instead: an empty `stringData` block for the seed, or the base64 `data` decoded to readable plaintext for a populated Secret. Decoding a populated Secret is an opt-in plaintext exposure (folded back to `data` on parse / read-only for `view`).

Parameters:

  • commented (Boolean) (defaults to: false)

    include the explanatory header/comments (true for the ‘create` seed; typically false for round-tripping).

  • reveal (Boolean) (defaults to: false)

    ‘view`’s plaintext switch (decode to ‘stringData`).

  • string_data (Boolean) (defaults to: false)

    the editors’ plaintext switch; same effect as ‘reveal`. Default false -> the `data` (base64) block.

Returns:

  • (String)

    YAML suitable to hand to Editor or to print.



377
378
379
380
381
382
383
# File 'lib/rkseal/secret.rb', line 377

def to_buffer(commented: false, reveal: false, string_data: false)
  body = base_manifest
  apply_data_block(body, plaintext: reveal || string_data)

  yaml = dump_yaml(body)
  commented ? "#{buffer_header}#{yaml}" : yaml
end

#to_manifest(scope: :strict) ⇒ String

Render this Secret to the canonical manifest YAML piped into ‘kubeseal`.

Emits a clean Secret with base64 ‘data` only (stringData has already been folded in on parse). The scope annotation is NOT injected here – scope is applied by Kubeseal#seal via `–scope`. This method only validates the scope argument and serializes the Secret.

Parameters:

  • scope (Symbol) (defaults to: :strict)

    one of :strict, :namespace_wide, :cluster_wide.

Returns:

  • (String)

    manifest YAML for kubeseal’s stdin.

Raises:



395
396
397
398
399
400
# File 'lib/rkseal/secret.rb', line 395

def to_manifest(scope: :strict)
  validate_scope!(scope)
  body = base_manifest
  body["data"] = data.dup unless data.empty?
  dump_yaml(body)
end

#validate!void

This method returns an undefined value.

Assert this Secret satisfies the required-key contract for its ‘type`. Opaque imposes no requirement; TLS/dockerconfigjson do. kubeseal does not check this, so rkseal fails fast before sealing an on-cluster-broken Secret.

Raises:



428
429
430
431
432
433
434
435
436
437
# File 'lib/rkseal/secret.rb', line 428

def validate!
  raise InvalidInputError, "the Secret has no data items" if empty?

  missing = REQUIRED_KEYS_BY_TYPE.fetch(type, []).reject { |key| data.key?(key) }
  return if missing.empty?

  raise InvalidInputError,
        "Secret type #{type.inspect} requires #{missing.join(", ")} " \
        "(present: #{data.keys.sort.join(", ")})"
end

#with_value(key:, contents:) ⇒ RKSeal::Secret

Return a copy of this Secret with one item set from a file’s contents (for the ‘–from-file` feature). Binary-safe: the file’s bytes are base64 encoded into the data map (consistent with the base64 canonical form).

Parameters:

  • key (String)

    data key to set.

  • contents (String)

    the file’s bytes (read by the caller).

Returns:



409
410
411
412
413
414
# File 'lib/rkseal/secret.rb', line 409

def with_value(key:, contents:)
  merged = data.merge(key.to_s => Base64.strict_encode64(contents))
  self.class.new(
    name: name, namespace: namespace, type: type, data: merged, metadata: 
  )
end