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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RKSeal
6
+ # Domain model for the *SealedSecret* resource -- the encrypted, on-disk
7
+ # counterpart of {RKSeal::Secret}.
8
+ #
9
+ # Unlike a Secret, a SealedSecret cannot be decrypted client-side: its
10
+ # `spec.encryptedData` values are opaque ciphertext. What *is* readable is the
11
+ # set of data **keys** (the map keys are plaintext), the sealing **scope**
12
+ # (a metadata annotation), and the **template** `type`. That readable surface
13
+ # is exactly what powers the offline `edit --local` flow: rkseal can show the
14
+ # user which keys exist and let them keep / replace / add / remove keys
15
+ # without ever seeing the current values.
16
+ #
17
+ # This model is the single place that knows how to:
18
+ #
19
+ # - parse a local `<name>.yaml` SealedSecret into that readable surface;
20
+ # - render a *redacted* editor buffer -- a Secret manifest in which every
21
+ # existing key is shown under `stringData` as {REDACTED_PLACEHOLDER}, so a
22
+ # value left untouched means "keep the current ciphertext".
23
+ #
24
+ # No method here shells out, touches the cluster, or decrypts anything; it is
25
+ # pure data transformation and trivially unit-testable.
26
+ class SealedSecret
27
+ # apiVersion/kind this model represents.
28
+ API_VERSION = "bitnami.com/v1alpha1"
29
+ KIND = "SealedSecret"
30
+
31
+ # Placeholder shown for every existing key in the `edit --local` buffer.
32
+ # Because the ciphertext cannot be decrypted, the current value is never
33
+ # revealed; leaving this token in place means "keep the sealed value as-is".
34
+ REDACTED_PLACEHOLDER = "<redacted>"
35
+
36
+ # @return [String] the SealedSecret name.
37
+ attr_reader :name
38
+ # @return [String, nil] the namespace.
39
+ attr_reader :namespace
40
+ # @return [Symbol] sealing scope (:strict, :namespace_wide, :cluster_wide),
41
+ # derived from the metadata annotation (see {RKSeal::Secret.scope_from_sealed_json}).
42
+ attr_reader :scope
43
+ # @return [String] the template Secret `type` (e.g. "Opaque").
44
+ attr_reader :type
45
+ # @return [Array<String>] the data keys present in `spec.encryptedData`
46
+ # (plaintext keys; the values are ciphertext and are not held here).
47
+ attr_reader :encrypted_keys
48
+
49
+ class << self
50
+ # Parse a SealedSecret manifest (the local `<name>.yaml`) into the model.
51
+ #
52
+ # @param yaml [String, Hash] raw YAML/JSON text or a pre-parsed Hash.
53
+ # @return [RKSeal::SealedSecret]
54
+ # @raise [RKSeal::InvalidInputError] if the document is empty, not valid
55
+ # YAML, not a SealedSecret, or carries a non-mapping `encryptedData`.
56
+ def parse(yaml)
57
+ doc = yaml.is_a?(Hash) ? yaml : load_yaml(yaml)
58
+ unless doc.is_a?(Hash)
59
+ raise InvalidInputError, "not a SealedSecret manifest (expected a YAML mapping)"
60
+ end
61
+
62
+ validate_kind!(doc)
63
+ new(
64
+ name: fetch_name(doc),
65
+ namespace: doc.dig("metadata", "namespace"),
66
+ scope: Secret.scope_from_sealed_json(doc),
67
+ type: doc.dig("spec", "template", "type") || Secret::DEFAULT_TYPE,
68
+ encrypted_keys: encrypted_keys(doc)
69
+ )
70
+ end
71
+
72
+ # Whether the sealed payload (`spec.encryptedData` + `spec.template`) of two
73
+ # SealedSecret documents differs. `kubectl apply` stores the manifest
74
+ # verbatim, so right after a deploy the local `<name>.yaml` and the cluster
75
+ # object share an identical payload; an unequal payload therefore means the
76
+ # local file is *ahead of* (or absent from) the cluster -- i.e. it carries
77
+ # un-deployed changes. Re-sealing is non-deterministic, so equal payload is
78
+ # only ever produced by the exact same applied file -- there are no false
79
+ # "equal" verdicts that could mask drift. Tolerant of JSON (kubectl) and
80
+ # YAML (the local file) alike, and of malformed input (treated as drift, so
81
+ # the user's local file is never silently overwritten).
82
+ #
83
+ # @param local [String, Hash] the local SealedSecret (YAML text or Hash).
84
+ # @param cluster [String, Hash] the cluster SealedSecret (JSON/YAML or Hash).
85
+ # @return [Boolean]
86
+ def diverged?(local, cluster)
87
+ sealed_payload(local) != sealed_payload(cluster)
88
+ end
89
+
90
+ private
91
+
92
+ # The comparable sealed payload of a SealedSecret document: its
93
+ # `encryptedData` and `template`. Anything unparseable collapses to a
94
+ # sentinel that compares unequal to a real payload (so drift wins).
95
+ def sealed_payload(document)
96
+ doc = document.is_a?(Hash) ? document : safe_parse(document)
97
+ spec = doc.is_a?(Hash) ? doc["spec"] : nil
98
+ spec.is_a?(Hash) ? [spec["encryptedData"], spec["template"]] : [:unparseable]
99
+ end
100
+
101
+ # Best-effort parse for the divergence check: never raises, returns nil on
102
+ # empty/invalid input (YAML.safe_load parses JSON too).
103
+ def safe_parse(text)
104
+ return nil if text.nil? || text.strip.empty?
105
+
106
+ YAML.safe_load(text, permitted_classes: [], aliases: false)
107
+ rescue Psych::SyntaxError
108
+ nil
109
+ end
110
+
111
+ def load_yaml(text)
112
+ raise InvalidInputError, "the SealedSecret file is empty" if text.nil? || text.strip.empty?
113
+
114
+ YAML.safe_load(text, permitted_classes: [], aliases: false)
115
+ rescue Psych::SyntaxError => e
116
+ raise InvalidInputError, "the SealedSecret file is not valid YAML: #{e.message}"
117
+ end
118
+
119
+ def validate_kind!(doc)
120
+ kind = doc["kind"]
121
+ return if kind == KIND
122
+
123
+ raise InvalidInputError, "not a SealedSecret (kind: #{kind.inspect})"
124
+ end
125
+
126
+ def fetch_name(doc)
127
+ name = doc.dig("metadata", "name")
128
+ return name unless name.nil? || (name.respond_to?(:strip) && name.strip.empty?)
129
+
130
+ raise InvalidInputError, "the SealedSecret is missing metadata.name"
131
+ end
132
+
133
+ def encrypted_keys(doc)
134
+ encrypted = doc.dig("spec", "encryptedData")
135
+ return [] if encrypted.nil?
136
+ unless encrypted.is_a?(Hash)
137
+ raise InvalidInputError, "spec.encryptedData must be a mapping of key to ciphertext"
138
+ end
139
+
140
+ encrypted.keys.map(&:to_s)
141
+ end
142
+ end
143
+
144
+ # @param name [String]
145
+ # @param namespace [String, nil]
146
+ # @param scope [Symbol]
147
+ # @param type [String]
148
+ # @param encrypted_keys [Array<String>]
149
+ def initialize(name:, namespace:, scope:, type:, encrypted_keys:)
150
+ @name = name
151
+ @namespace = namespace
152
+ @scope = scope
153
+ @type = type
154
+ @encrypted_keys = encrypted_keys.freeze
155
+ end
156
+
157
+ # Render the redacted editor buffer for the offline local edit: a Kubernetes
158
+ # Secret manifest in which every existing key is shown as
159
+ # {REDACTED_PLACEHOLDER}. The operator keeps a value by leaving the
160
+ # placeholder, replaces it by typing a new value, adds keys by adding lines,
161
+ # and removes keys by deleting lines.
162
+ #
163
+ # By default the keys sit under `data` (so replacements are base64, matching
164
+ # the rest of the tool). With `string_data: true` they sit under
165
+ # `stringData`, so replacements/new keys are entered as plaintext.
166
+ #
167
+ # @param commented [Boolean] include the explanatory header comment.
168
+ # @param string_data [Boolean] place the keys under `stringData` (plaintext)
169
+ # instead of `data` (base64).
170
+ # @return [String] YAML suitable to hand to {RKSeal::Editor}.
171
+ def to_buffer(commented: true, string_data: false)
172
+ body = {
173
+ "apiVersion" => Secret::API_VERSION,
174
+ "kind" => Secret::KIND,
175
+ "metadata" => { "name" => name, "namespace" => namespace },
176
+ "type" => type,
177
+ (string_data ? "stringData" : "data") =>
178
+ encrypted_keys.to_h { |key| [key, REDACTED_PLACEHOLDER] }
179
+ }
180
+
181
+ yaml = YAML.dump(body).delete_prefix("---\n")
182
+ commented ? "#{buffer_header(string_data)}#{yaml}" : yaml
183
+ end
184
+
185
+ private
186
+
187
+ def buffer_header(string_data)
188
+ entry = string_data ? "plaintext" : "base64"
189
+ <<~HEADER
190
+ # rkseal: LOCAL edit of a SealedSecret (offline -- cluster state is NOT read).
191
+ #
192
+ # Existing values cannot be decrypted, so each key shows #{REDACTED_PLACEHOLDER}.
193
+ # On save:
194
+ # - leave a value as #{REDACTED_PLACEHOLDER} to KEEP its current sealed value untouched;
195
+ # - replace #{REDACTED_PLACEHOLDER} with a new #{entry} value to RE-SEAL that key (rehash);
196
+ # - add a new `key: value` line to seal a NEW key (#{entry} value);
197
+ # - delete a key's line to REMOVE it from the SealedSecret.
198
+ #
199
+ # Scope (#{scope}) is preserved and cannot be changed here: kept values
200
+ # cannot be re-sealed under a new scope. name/namespace are fixed too.
201
+ HEADER
202
+ end
203
+ end
204
+ end