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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RKSeal
4
+ module Commands
5
+ # Orchestrates the `rkseal create <namespace> <secret-name>` flow.
6
+ #
7
+ # Pulls together the collaborators (workspace, editor, kubeseal, secret
8
+ # model) to: seed an empty Secret template, optionally pre-seed
9
+ # `--from-file` values, edit it in `$EDITOR` on a RAM-backed buffer, parse
10
+ # and validate the result, seal it, and write `<secret-name>.yaml` to the
11
+ # current working directory. Holds no business rules of its own beyond
12
+ # sequencing -- each step's logic lives in the collaborator it delegates to.
13
+ #
14
+ # Collaborators are injected (defaulting to real implementations) so the
15
+ # whole flow is unit-testable with stubbed adapters and no cluster.
16
+ #
17
+ # @example
18
+ # RKSeal::Commands::Create.new(namespace: "app", name: "db", scope: :strict).call
19
+ class Create
20
+ # @return [String]
21
+ attr_reader :namespace
22
+ # @return [String]
23
+ attr_reader :name
24
+ # @return [Symbol] sealing scope (:strict, :namespace_wide, :cluster_wide).
25
+ attr_reader :scope
26
+
27
+ # @param namespace [String] target namespace (positional CLI arg).
28
+ # @param name [String] Secret name (positional CLI arg).
29
+ # @param scope [Symbol] sealing scope; defaults to :strict.
30
+ # @param type [String] Secret type for the seed (e.g. "kubernetes.io/tls").
31
+ # @param from_file [Hash{String=>String}, nil] optional key => file-path
32
+ # pairs to pre-seed into the buffer before editing.
33
+ # @param no_edit [Boolean] skip the editor and seal the seeded/from-file
34
+ # Secret directly (for binary/TLS/dockerconfig payloads).
35
+ # @param string_data [Boolean] seed an empty `stringData` (plaintext) block
36
+ # instead of `data` (base64); defaults to false.
37
+ # @param kubeseal [RKSeal::Kubeseal] sealing adapter.
38
+ # @param editor [RKSeal::Editor] editor launcher.
39
+ # @param workspace [#with] RAM-backed scratch provider (block-scoped).
40
+ # @param output_dir [String] directory the manifest is written to (CWD).
41
+ def initialize(namespace:, name:, scope: :strict, type: Secret::DEFAULT_TYPE,
42
+ from_file: nil, no_edit: false, string_data: false,
43
+ kubeseal: Kubeseal.new, editor: Editor.new,
44
+ workspace: SecureWorkspace, output_dir: Dir.pwd)
45
+ @namespace = namespace
46
+ @name = name
47
+ @scope = scope
48
+ @type = type
49
+ @from_file = from_file || {}
50
+ @no_edit = no_edit
51
+ @string_data = string_data
52
+ @kubeseal = kubeseal
53
+ @editor = editor
54
+ @workspace = workspace
55
+ @output_dir = output_dir
56
+ end
57
+
58
+ # Run the create flow end to end.
59
+ #
60
+ # Side effects: spawns `$EDITOR` (unless --no-edit); provisions and tears
61
+ # down a RAM-backed workspace; shells out to `kubeseal`; writes
62
+ # `<name>.yaml` into the output directory.
63
+ #
64
+ # @return [RKSeal::Commands::Result] outcome (written path, deployed: false).
65
+ # @raise [RKSeal::InvalidInputError] empty/malformed buffer, bad scope, or
66
+ # missing `--from-file` source.
67
+ # @raise [RKSeal::EditorError] editor unavailable or aborted.
68
+ # @raise [RKSeal::WorkspaceError] RAM-backed scratch could not be provided.
69
+ # @raise [RKSeal::CommandError] kubeseal failed, or the controller is
70
+ # unreachable with no offline cert (surfaced up front, before editing).
71
+ def call
72
+ @kubeseal.ensure_available!
73
+ # Resolve the cert before the editor/workspace open: an unreachable
74
+ # controller (and no offline cert) must fail fast, not after the user has
75
+ # spent time editing a buffer that can never be sealed.
76
+ @kubeseal.ensure_cert!
77
+
78
+ secret = preseeded_secret
79
+ secret = edit(secret) unless @no_edit
80
+ secret.validate!
81
+
82
+ path = write_manifest(@kubeseal.seal(secret.to_manifest(scope: @scope), scope: @scope))
83
+ Result.new(secret_name: @name, namespace: @namespace, output_path: path, deployed: false)
84
+ end
85
+
86
+ private
87
+
88
+ # Seed an empty Secret and fold every `--from-file` value into it. Reading
89
+ # the file lives here (not in the adapter or model): the model stays pure,
90
+ # and a missing path fails fast with an actionable message.
91
+ def preseeded_secret
92
+ @from_file.reduce(Secret.seed(name: @name, namespace: @namespace,
93
+ type: @type)) do |secret, (key, path)|
94
+ secret.with_value(key: key, contents: read_source(key, path))
95
+ end
96
+ end
97
+
98
+ def read_source(key, path)
99
+ File.binread(path)
100
+ rescue SystemCallError => e
101
+ raise InvalidInputError, "--from-file #{key}=#{path}: #{e.message}"
102
+ end
103
+
104
+ # Run the editor on the seed buffer inside the RAM-backed workspace so the
105
+ # plaintext never lands on persistent disk, then parse the saved buffer.
106
+ def edit(secret)
107
+ edited = @workspace.with(basename: @name) do |path|
108
+ @editor.edit(content: secret.to_buffer(commented: true, string_data: @string_data),
109
+ path: path)
110
+ end
111
+ Secret.from_buffer(edited)
112
+ end
113
+
114
+ def write_manifest(sealed_yaml)
115
+ path = File.join(@output_dir, "#{@name}.yaml")
116
+ File.write(path, sealed_yaml)
117
+ File.expand_path(path)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module RKSeal
6
+ module Commands
7
+ # Orchestrates the `rkseal edit <namespace> <secret-name>` flow.
8
+ #
9
+ # Recovers the *current* state from the live cluster Secret (the only source
10
+ # of truth -- a SealedSecret cannot be decrypted client-side), shows it in
11
+ # `$EDITOR` on a RAM-backed buffer with `data` kept as base64, re-seals, and
12
+ # writes `<secret-name>.yaml` to the current working directory.
13
+ #
14
+ # Three behaviours distinguish this flow:
15
+ # - **scope preservation:** the existing SealedSecret's scope is read from
16
+ # the cluster (annotation), falling back to the local `<name>.yaml`, then
17
+ # to :strict. An explicit `scope:` always overrides.
18
+ # - **no-op:** if the saved buffer is equivalent to the cluster Secret,
19
+ # nothing is written and no fresh ciphertext is produced (re-sealing
20
+ # identical input still yields new ciphertext, which would create spurious
21
+ # diffs); the flow exits cleanly with a "no changes" Result -- and, since
22
+ # there is nothing new to apply, a requested deploy is skipped too.
23
+ # - **deploy:** opt-in only. When requested, {RKSeal::ContextGuard} surfaces
24
+ # the active context and asks the operator to confirm before
25
+ # `kubectl apply` (unless `assume_yes`). If the Secret is absent from the
26
+ # cluster, the flow fails fast and points the user at `create`.
27
+ #
28
+ # Collaborators are injected so the flow is unit-testable without a cluster.
29
+ #
30
+ # @example write-only (default)
31
+ # RKSeal::Commands::Edit.new(namespace: "app", name: "db").call
32
+ # @example deploy after editing (explicit opt-in)
33
+ # RKSeal::Commands::Edit.new(namespace: "app", name: "db", deploy: true).call
34
+ class Edit
35
+ # @return [String]
36
+ attr_reader :namespace
37
+ # @return [String]
38
+ attr_reader :name
39
+ # @return [Symbol, nil] explicit sealing scope override, or nil to preserve
40
+ # the secret's existing scope.
41
+ attr_reader :scope
42
+ # @return [Boolean] whether to deploy after writing the manifest.
43
+ attr_reader :deploy
44
+
45
+ # @param namespace [String] target namespace (positional CLI arg).
46
+ # @param name [String] Secret name (positional CLI arg).
47
+ # @param scope [Symbol, nil] explicit scope override; nil preserves the
48
+ # secret's existing scope (read from cluster / local file, else :strict).
49
+ # @param deploy [Boolean] opt-in deploy after writing; defaults to false.
50
+ # @param assume_yes [Boolean] skip the interactive deploy confirmation
51
+ # (only meaningful with deploy:); for non-interactive pipelines.
52
+ # @param string_data [Boolean] present values as decoded plaintext
53
+ # `stringData` instead of base64 `data`; defaults to false (an opt-in
54
+ # plaintext exposure of the cluster Secret).
55
+ # @param kubectl [RKSeal::Kubectl] cluster adapter (read + apply).
56
+ # @param kubeseal [RKSeal::Kubeseal] sealing adapter.
57
+ # @param editor [RKSeal::Editor] editor launcher.
58
+ # @param context_guard [RKSeal::ContextGuard, nil] deploy gatekeeper; built
59
+ # from the kubectl adapter + prompt when nil and a deploy is requested.
60
+ # @param prompt [Thor::Shell::Basic] shell used for the deploy confirmation
61
+ # (passed to the ContextGuard when one is built here).
62
+ # @param workspace [#with] RAM-backed scratch provider (block-scoped).
63
+ # @param output_dir [String] directory the manifest is written to (CWD).
64
+ def initialize(namespace:, name:, scope: nil, deploy: false, assume_yes: false,
65
+ string_data: false,
66
+ kubectl: Kubectl.new, kubeseal: Kubeseal.new, editor: Editor.new,
67
+ context_guard: nil, prompt: Thor::Shell::Basic.new,
68
+ workspace: SecureWorkspace, output_dir: Dir.pwd)
69
+ @namespace = namespace
70
+ @name = name
71
+ @scope = scope
72
+ @deploy = deploy
73
+ @assume_yes = assume_yes
74
+ @string_data = string_data
75
+ @kubectl = kubectl
76
+ @kubeseal = kubeseal
77
+ @editor = editor
78
+ @context_guard = context_guard
79
+ @prompt = prompt
80
+ @workspace = workspace
81
+ @output_dir = output_dir
82
+ end
83
+
84
+ # Run the edit flow end to end.
85
+ #
86
+ # Side effects: reads the cluster Secret (and SealedSecret scope) via
87
+ # `kubectl`; spawns `$EDITOR`; provisions/tears down a RAM-backed workspace;
88
+ # shells out to `kubeseal`; writes `<name>.yaml` (unless unchanged); and,
89
+ # only when {#deploy} is true and the operator confirms, runs `kubectl
90
+ # apply`.
91
+ #
92
+ # @return [RKSeal::Commands::Result] outcome (written path or nil when
93
+ # unchanged, deployed?).
94
+ # @raise [RKSeal::NotFoundError] the Secret is absent from the cluster.
95
+ # @raise [RKSeal::InvalidInputError] empty/malformed buffer, or bad scope.
96
+ # @raise [RKSeal::EditorError] editor unavailable or aborted.
97
+ # @raise [RKSeal::WorkspaceError] RAM-backed scratch could not be provided.
98
+ # @raise [RKSeal::CommandError] kubectl/kubeseal failed.
99
+ def call
100
+ ensure_dependencies!
101
+
102
+ cluster_secret = Secret.from_kubectl_json(@kubectl.get_secret(name: @name,
103
+ namespace: @namespace))
104
+ edited = edit(cluster_secret)
105
+
106
+ return unchanged_result if edited == cluster_secret
107
+
108
+ edited.validate!
109
+ effective_scope = @scope || resolve_scope
110
+ path = write_manifest(@kubeseal.seal(edited.to_manifest(scope: effective_scope),
111
+ scope: effective_scope))
112
+ deployed = @deploy && deploy_confirmed?
113
+ @kubectl.apply(file: path) if deployed
114
+ Result.new(secret_name: @name, namespace: @namespace, output_path: path, deployed: deployed)
115
+ end
116
+
117
+ private
118
+
119
+ def ensure_dependencies!
120
+ @kubectl.ensure_available!
121
+ @kubeseal.ensure_available!
122
+ end
123
+
124
+ # Determine the scope to seal with when no explicit override was given:
125
+ # preserve the secret's existing scope. Source of truth is the cluster
126
+ # SealedSecret's annotation; if it is unreachable or absent, fall back to
127
+ # the local `<name>.yaml` from a previous run; if neither, default :strict.
128
+ def resolve_scope
129
+ scope_from_cluster || scope_from_local_file || :strict
130
+ end
131
+
132
+ def scope_from_cluster
133
+ Secret.scope_from_sealed_json(@kubectl.get_sealedsecret(name: @name, namespace: @namespace))
134
+ rescue NotFoundError, CommandError
135
+ nil
136
+ end
137
+
138
+ def scope_from_local_file
139
+ path = manifest_path
140
+ return nil unless File.file?(path)
141
+
142
+ Secret.scope_from_sealed_json(File.read(path))
143
+ end
144
+
145
+ # Show the cluster Secret (base64 data) in the editor on a RAM-backed path,
146
+ # then parse the saved buffer back into a Secret.
147
+ def edit(cluster_secret)
148
+ buffer = cluster_secret.to_buffer(commented: true, string_data: @string_data)
149
+ edited = @workspace.with(basename: @name) do |path|
150
+ @editor.edit(content: buffer, path: path)
151
+ end
152
+ Secret.from_buffer(edited)
153
+ end
154
+
155
+ # Nothing changed: per the no-op contract we write no file and produce no
156
+ # fresh ciphertext. output_path is nil so the CLI can print "no changes".
157
+ def unchanged_result
158
+ Result.new(secret_name: @name, namespace: @namespace, output_path: nil, deployed: false)
159
+ end
160
+
161
+ def write_manifest(sealed_yaml)
162
+ File.write(manifest_path, sealed_yaml)
163
+ File.expand_path(manifest_path)
164
+ end
165
+
166
+ def manifest_path
167
+ File.join(@output_dir, "#{@name}.yaml")
168
+ end
169
+
170
+ # Whether to proceed with the deploy: --yes skips the prompt outright,
171
+ # otherwise the ContextGuard surfaces the active context and confirms.
172
+ def deploy_confirmed?
173
+ return true if @assume_yes
174
+
175
+ context_guard.confirm_deploy(secret_name: @name, namespace: @namespace)
176
+ end
177
+
178
+ def context_guard
179
+ @context_guard ||= ContextGuard.new(kubectl: @kubectl, prompt: @prompt)
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "yaml"
5
+ require "base64"
6
+
7
+ module RKSeal
8
+ module Commands
9
+ # Orchestrates the offline `rkseal edit --local <namespace> <secret-name>`
10
+ # flow: edit a SealedSecret that exists only as a local `<name>.yaml` and was
11
+ # never deployed, so there is no unsealed cluster Secret to recover values
12
+ # from.
13
+ #
14
+ # A SealedSecret cannot be decrypted client-side, so this flow never shows
15
+ # current values. Instead {RKSeal::SealedSecret} renders a *redacted* buffer
16
+ # (every existing key shown as {RKSeal::SealedSecret::REDACTED_PLACEHOLDER}),
17
+ # and the saved buffer is classified per key:
18
+ #
19
+ # - **keep:** value left as the placeholder -> the existing ciphertext is
20
+ # left byte-for-byte untouched (no rehash, no plaintext needed);
21
+ # - **reseal:** value replaced, or a brand-new key added -> the new value
22
+ # is sealed and merged in via `kubeseal --merge-into`;
23
+ # - **remove:** an existing key deleted from the buffer -> dropped from
24
+ # `spec.encryptedData`.
25
+ #
26
+ # The `type` may also be edited (written to `spec.template.type`). Scope and
27
+ # name/namespace are fixed: kept ciphertext cannot be re-sealed under a
28
+ # different scope/identity without the plaintext rkseal does not have.
29
+ #
30
+ # The cluster is contacted only to obtain the controller's PUBLIC cert when a
31
+ # reseal is actually needed (offline if it is already cached) and, with an
32
+ # opt-in `--deploy`, to apply the result. Reading current state never hits
33
+ # the cluster.
34
+ #
35
+ # @example keep/replace/add keys offline, write only
36
+ # RKSeal::Commands::EditLocal.new(namespace: "app", name: "db").call
37
+ #
38
+ # rubocop:disable Metrics/ClassLength -- this flow is a single cohesive
39
+ # orchestration (read local -> redacted buffer -> classify keep/reseal/
40
+ # remove -> merge/rewrite -> optional deploy); the extra lines are docstrings
41
+ # and small, focused private helpers, each independently testable. Splitting
42
+ # it into verb classes is the anti-pattern this gem avoids.
43
+ class EditLocal
44
+ # @return [String]
45
+ attr_reader :namespace
46
+ # @return [String]
47
+ attr_reader :name
48
+ # @return [Boolean] whether to deploy after writing the manifest.
49
+ attr_reader :deploy
50
+
51
+ # @param namespace [String] target namespace (positional CLI arg).
52
+ # @param name [String] Secret name (positional CLI arg).
53
+ # @param deploy [Boolean] opt-in deploy after writing; defaults to false.
54
+ # @param assume_yes [Boolean] skip the deploy confirmation (with deploy:).
55
+ # @param string_data [Boolean] show the redacted keys under `stringData`
56
+ # (plaintext) instead of `data` (base64); defaults to false.
57
+ # @param kubectl [RKSeal::Kubectl] cluster adapter (apply only).
58
+ # @param kubeseal [RKSeal::Kubeseal] sealing adapter (merge_into).
59
+ # @param editor [RKSeal::Editor] editor launcher.
60
+ # @param context_guard [RKSeal::ContextGuard, nil] deploy gatekeeper.
61
+ # @param prompt [Thor::Shell::Basic] shell for the deploy confirmation.
62
+ # @param workspace [#with] RAM-backed scratch provider (block-scoped).
63
+ # @param output_dir [String] directory the manifest is read from / written
64
+ # to (CWD).
65
+ def initialize(namespace:, name:, deploy: false, assume_yes: false, string_data: false,
66
+ kubectl: Kubectl.new, kubeseal: Kubeseal.new, editor: Editor.new,
67
+ context_guard: nil, prompt: Thor::Shell::Basic.new,
68
+ workspace: SecureWorkspace, output_dir: Dir.pwd)
69
+ @namespace = namespace
70
+ @name = name
71
+ @deploy = deploy
72
+ @assume_yes = assume_yes
73
+ @string_data = string_data
74
+ @kubectl = kubectl
75
+ @kubeseal = kubeseal
76
+ @editor = editor
77
+ @context_guard = context_guard
78
+ @prompt = prompt
79
+ @workspace = workspace
80
+ @output_dir = output_dir
81
+ end
82
+
83
+ # Run the local edit flow end to end.
84
+ #
85
+ # @return [RKSeal::Commands::Result] outcome (written path or nil when
86
+ # unchanged, deployed?).
87
+ # @raise [RKSeal::NotFoundError] no local `<name>.yaml` (points at `create`).
88
+ # @raise [RKSeal::InvalidInputError] malformed/empty buffer, renamed
89
+ # identity, empty result, or a new key left as the placeholder.
90
+ # @raise [RKSeal::EditorError] editor unavailable or aborted.
91
+ # @raise [RKSeal::WorkspaceError] RAM-backed scratch could not be provided.
92
+ # @raise [RKSeal::CommandError] kubeseal/kubectl failed.
93
+ def call
94
+ @kubeseal.ensure_available!
95
+ @kubectl.ensure_available! if @deploy
96
+
97
+ sealed = SealedSecret.parse(read_local!)
98
+ plan = build_plan(sealed, edit(sealed))
99
+ return unchanged_result unless plan.changes?
100
+
101
+ ensure_nonempty!(sealed, plan)
102
+ apply(plan, scope: sealed.scope)
103
+
104
+ path = File.expand_path(manifest_path)
105
+ deployed = @deploy && deploy_confirmed?
106
+ @kubectl.apply(file: path) if deployed
107
+ Result.new(secret_name: @name, namespace: @namespace, output_path: path, deployed: deployed)
108
+ end
109
+
110
+ # The classified result of one local-edit buffer: which keys to reseal
111
+ # (from plaintext stringData or verbatim base64 data), which to remove, and
112
+ # whether the template type changed. Kept keys are absent by construction.
113
+ LocalPlan = Struct.new(
114
+ :reseal_string_data, :reseal_data, :removed_keys, :type, :type_changed,
115
+ keyword_init: true
116
+ ) do
117
+ # @return [Boolean] whether any key must be (re)sealed.
118
+ def reseal?
119
+ !reseal_string_data.empty? || !reseal_data.empty?
120
+ end
121
+
122
+ # @return [Boolean] whether the buffer changed anything at all.
123
+ def changes?
124
+ reseal? || removed_keys.any? || type_changed
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # The local SealedSecret is the only source: this flow is offline by
131
+ # design and never reads cluster state. A missing file points at `create`.
132
+ def read_local!
133
+ path = manifest_path
134
+ return File.read(path) if File.file?(path)
135
+
136
+ raise NotFoundError,
137
+ "No local #{@name}.yaml in #{@output_dir}. " \
138
+ "Run `rkseal create #{@namespace} #{@name}` first " \
139
+ "(local edit operates on the SealedSecret file, not the cluster)."
140
+ end
141
+
142
+ # Show the redacted buffer on a RAM-backed path, then parse the saved
143
+ # buffer into raw stringData/data maps and the chosen type.
144
+ def edit(sealed)
145
+ raw = @workspace.with(basename: @name) do |path|
146
+ @editor.edit(content: sealed.to_buffer(commented: true, string_data: @string_data),
147
+ path: path)
148
+ end
149
+ parse_buffer(raw)
150
+ end
151
+
152
+ def parse_buffer(raw)
153
+ raise InvalidInputError, "the edit buffer is empty" if raw.nil? || raw.strip.empty?
154
+
155
+ doc = YAML.safe_load(raw, permitted_classes: [], aliases: false)
156
+ unless doc.is_a?(Hash)
157
+ raise InvalidInputError,
158
+ "the buffer is not a YAML mapping (expected a Secret manifest)"
159
+ end
160
+
161
+ validate_identity!(doc)
162
+ {
163
+ string_data: string_map(doc["stringData"]),
164
+ data: string_map(doc["data"]),
165
+ type: doc["type"] || Secret::DEFAULT_TYPE
166
+ }
167
+ rescue Psych::SyntaxError => e
168
+ raise InvalidInputError, "the buffer is not valid YAML: #{e.message}"
169
+ end
170
+
171
+ # name/namespace are bound into strict ciphertext and shared with kept
172
+ # entries, so they cannot change in a local edit.
173
+ def validate_identity!(doc)
174
+ name = doc.dig("metadata", "name")
175
+ namespace = doc.dig("metadata", "namespace")
176
+ return if name == @name && namespace == @namespace
177
+
178
+ raise InvalidInputError,
179
+ "local edit cannot rename or move the secret " \
180
+ "(expected #{@name}/#{@namespace}, got #{name.inspect}/#{namespace.inspect})"
181
+ end
182
+
183
+ def string_map(raw)
184
+ return {} if raw.nil?
185
+ raise InvalidInputError, "`stringData`/`data` must be a mapping" unless raw.is_a?(Hash)
186
+
187
+ raw.transform_keys(&:to_s)
188
+ end
189
+
190
+ # Classify the saved buffer against the existing keys into a {LocalPlan}.
191
+ # Keys may sit under `stringData` (plaintext) or `data` (base64) regardless
192
+ # of which block the redacted buffer used, so both are classified the same
193
+ # way; the redacted placeholder is honoured in either.
194
+ def build_plan(sealed, buffer)
195
+ existing = sealed.encrypted_keys
196
+ reseal_string = reseal_values(buffer[:string_data], existing)
197
+ reseal_data = reseal_values(buffer[:data], existing)
198
+ present = buffer[:string_data].keys + buffer[:data].keys
199
+
200
+ LocalPlan.new(
201
+ reseal_string_data: reseal_string,
202
+ reseal_data: reseal_data,
203
+ removed_keys: existing - present,
204
+ type: buffer[:type],
205
+ type_changed: buffer[:type] != sealed.type
206
+ )
207
+ end
208
+
209
+ # Keys whose value is NOT the redacted placeholder are (re)seals; a
210
+ # placeholder on an existing key is kept (dropped here). A placeholder left
211
+ # on a brand-new key is meaningless -> fail fast.
212
+ def reseal_values(map, existing)
213
+ map.each_with_object({}) do |(key, value), acc|
214
+ if value == SealedSecret::REDACTED_PLACEHOLDER
215
+ next if existing.include?(key)
216
+
217
+ raise InvalidInputError,
218
+ "new key #{key.inspect} still has the #{SealedSecret::REDACTED_PLACEHOLDER} " \
219
+ "placeholder; give it a value or remove the line"
220
+ end
221
+ raise InvalidInputError, "key #{key.inspect} has an empty value" if blank?(value)
222
+
223
+ acc[key] = value
224
+ end
225
+ end
226
+
227
+ # The final key set must not be empty (a Secret with no data is invalid).
228
+ def ensure_nonempty!(sealed, plan)
229
+ remaining = (sealed.encrypted_keys - plan.removed_keys) +
230
+ plan.reseal_string_data.keys + plan.reseal_data.keys
231
+ return unless remaining.uniq.empty?
232
+
233
+ raise InvalidInputError, "the edit would leave the SealedSecret with no data items"
234
+ end
235
+
236
+ # Apply the plan to `<name>.yaml`: merge resealed items via kubeseal, then
237
+ # always normalize the file -- drop removed keys, update the template type,
238
+ # and re-emit YAML. The normalize pass is unconditional because
239
+ # `kubeseal --merge-into` (v0.36.6) rewrites the file as JSON regardless of
240
+ # the input format, so a `.yaml` would otherwise be left holding JSON.
241
+ def apply(plan, scope:)
242
+ path = manifest_path
243
+ if plan.reseal?
244
+ @kubeseal.ensure_cert!
245
+ @kubeseal.merge_into(reseal_secret(plan).to_manifest(scope: scope), file: path,
246
+ scope: scope)
247
+ end
248
+ normalize_file(path, plan)
249
+ end
250
+
251
+ # Build the partial Secret carrying only the (re)sealed items. Round-tripped
252
+ # through {RKSeal::Secret.from_buffer} to reuse its base64 validation and
253
+ # stringData folding (stringData wins per key, exactly like a normal seal).
254
+ def reseal_secret(plan)
255
+ manifest = {
256
+ "apiVersion" => Secret::API_VERSION,
257
+ "kind" => Secret::KIND,
258
+ "metadata" => { "name" => @name, "namespace" => @namespace },
259
+ "type" => plan.type
260
+ }
261
+ manifest["stringData"] = plan.reseal_string_data unless plan.reseal_string_data.empty?
262
+ manifest["data"] = plan.reseal_data unless plan.reseal_data.empty?
263
+ Secret.from_buffer(YAML.dump(manifest))
264
+ end
265
+
266
+ # Re-read the SealedSecret kubeseal just wrote (JSON or YAML -- YAML parses
267
+ # both), apply the removals and the optional type change, and write it back
268
+ # as YAML so a `.yaml` always holds YAML.
269
+ def normalize_file(path, plan)
270
+ doc = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
271
+ spec = doc["spec"] ||= {}
272
+ encrypted = spec["encryptedData"] ||= {}
273
+ plan.removed_keys.each { |key| encrypted.delete(key) }
274
+ (spec["template"] ||= {})["type"] = plan.type if plan.type_changed
275
+ File.write(path, YAML.dump(doc))
276
+ end
277
+
278
+ def unchanged_result
279
+ Result.new(secret_name: @name, namespace: @namespace, output_path: nil, deployed: false)
280
+ end
281
+
282
+ def manifest_path
283
+ File.join(@output_dir, "#{@name}.yaml")
284
+ end
285
+
286
+ def deploy_confirmed?
287
+ return true if @assume_yes
288
+
289
+ context_guard.confirm_deploy(secret_name: @name, namespace: @namespace)
290
+ end
291
+
292
+ def context_guard
293
+ @context_guard ||= ContextGuard.new(kubectl: @kubectl, prompt: @prompt)
294
+ end
295
+
296
+ def blank?(value)
297
+ value.nil? || (value.respond_to?(:strip) && value.to_s.strip.empty?)
298
+ end
299
+ end
300
+ # rubocop:enable Metrics/ClassLength
301
+ end
302
+ end