Class: RKSeal::Secret
- Inherits:
-
Object
- Object
- RKSeal::Secret
- 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
-
#data ⇒ Hash{String=>String}
readonly
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).
-
#metadata ⇒ Hash
readonly
Author-owned metadata (labels, annotations, …) with runtime keys already stripped.
-
#name ⇒ String
readonly
The Secret name (from the CLI positional arg).
-
#namespace ⇒ String
readonly
The namespace (from the CLI positional arg).
-
#type ⇒ String
readonly
The Secret ‘type` (e.g. “Opaque”, “kubernetes.io/tls”).
Class Method Summary collapse
-
.from_buffer(yaml) ⇒ RKSeal::Secret
Parse a saved editor buffer (full Secret manifest as YAML) back into a Secret.
-
.from_kubectl_json(json) ⇒ RKSeal::Secret
Build a Secret from the JSON ‘kubectl get secret -o json` returns.
-
.scope_from_sealed_json(document) ⇒ Symbol
Derive the sealing scope from a SealedSecret by inspecting its ‘metadata.annotations`.
-
.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.
-
.validate_identifier!(field:, value:) ⇒ String
Validate a CLI-supplied identifier (Secret name or namespace) against the Kubernetes DNS-1123 subdomain rules.
Instance Method Summary collapse
-
#==(other) ⇒ Boolean
(also: #eql?)
Value equality over the author-owned fields (name, namespace, type, data, metadata).
-
#empty? ⇒ Boolean
True when there are no data items – used to reject an empty edit buffer (fail fast).
-
#hash ⇒ Integer
Hash consistent with #==.
-
#initialize(name:, namespace:, data: {}, type: DEFAULT_TYPE, metadata: {}) ⇒ Secret
constructor
A new instance of Secret.
-
#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.
-
#to_manifest(scope: :strict) ⇒ String
Render this Secret to the canonical manifest YAML piped into ‘kubeseal`.
-
#validate! ⇒ void
Assert this Secret satisfies the required-key contract for its ‘type`.
-
#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).
Constructor Details
#initialize(name:, namespace:, data: {}, type: DEFAULT_TYPE, metadata: {}) ⇒ Secret
Returns a new instance of Secret.
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
#data ⇒ Hash{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).
98 99 100 |
# File 'lib/rkseal/secret.rb', line 98 def data @data end |
#metadata ⇒ Hash (readonly)
Returns author-owned metadata (labels, annotations, …) with runtime keys already stripped.
101 102 103 |
# File 'lib/rkseal/secret.rb', line 101 def @metadata end |
#name ⇒ String (readonly)
Returns the Secret name (from the CLI positional arg).
90 91 92 |
# File 'lib/rkseal/secret.rb', line 90 def name @name end |
#namespace ⇒ String (readonly)
Returns the namespace (from the CLI positional arg).
92 93 94 |
# File 'lib/rkseal/secret.rb', line 92 def namespace @namespace end |
#type ⇒ String (readonly)
Returns 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.
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.
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.
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.
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.
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`.
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).
418 419 420 |
# File 'lib/rkseal/secret.rb', line 418 def empty? data.empty? end |
#hash ⇒ Integer
Returns 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`).
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.
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.
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).
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 |