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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ebc10a4204a042490b3ad908bed3d1cfd625ee03743f21f147f1d80b21fb306f
4
+ data.tar.gz: ba5ffd88f83075193e57357edadfd0539f64e9587b76664d8f1fd0c2a89b95b0
5
+ SHA512:
6
+ metadata.gz: 04d897666453e1e63e870c0c4c31f5dc8adb2a00d4b7167b5859c5f3adb0e73366fad81d6be29843d7e35ed44fc365f793529d45161cead4857800bea963bcbc
7
+ data.tar.gz: 56ff59be8191ec4c27d612422766f174f92d393417f8d3e47f6a8185d26feccde71bea6da8353a5e511c15f4e140a7ed7bea2bb894f7da213e2bfcfd752f5533
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Piotr Wojcieszonek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # rkseal
2
+
3
+ Interactively create and edit Kubernetes [SealedSecrets](https://github.com/bitnami-labs/sealed-secrets)
4
+ from your terminal, in the spirit of `knife vault create/edit`.
5
+
6
+ `rkseal` wraps the `kubeseal` CLI. You edit a **full Kubernetes Secret manifest** in
7
+ `$EDITOR`; `rkseal` seals it with the controller's public key and writes the resulting
8
+ `SealedSecret` to the current directory. The plaintext buffer lives only on a RAM-backed
9
+ path and is destroyed when you are done — it never touches persistent disk.
10
+
11
+ ## Commands
12
+
13
+ ```sh
14
+ rkseal create <namespace> <secret-name> # author a new sealed secret
15
+ rkseal edit <namespace> <secret-name> # edit an existing one
16
+ rkseal reencrypt <namespace> <secret-name> # rotate to the controller's newest key
17
+ rkseal validate <namespace> <secret-name> # check a SealedSecret with the controller
18
+ rkseal view <namespace> <secret-name> # print the live Secret (read-only)
19
+ rkseal list [namespace] # list SealedSecrets (metadata only)
20
+ rkseal version # print the installed rkseal version
21
+ ```
22
+
23
+ - `create` opens an empty, commented Secret template for you to fill in.
24
+ - `edit` reads the **live** unsealed Secret from the cluster
25
+ (`kubectl get secret … -o json`) — the only way to recover current values — opens it for
26
+ editing, then re-seals. If the Secret is absent from the cluster but a local
27
+ `<secret-name>.yaml` exists (e.g. you ran `create` but never deployed), `rkseal` switches
28
+ **automatically to an offline local edit** (see [`edit --local`](#edit---local-offline)).
29
+ Only if **neither** the cluster Secret nor a local file exists does it fail fast and point
30
+ you at `create`.
31
+ - `reencrypt` rotates an existing SealedSecret onto the controller's current sealing key
32
+ (`kubeseal --re-encrypt`) without exposing plaintext. It reads the local
33
+ `<secret-name>.yaml` if present, otherwise the live SealedSecret; if neither exists it
34
+ points you at `create`. The result is written back to `<secret-name>.yaml`.
35
+ - `validate` asks the controller whether a SealedSecret is well-formed and decryptable for
36
+ its target (`kubeseal --validate`) — a safe pre-flight check. It validates the local
37
+ `<secret-name>.yaml`, or any file via `--file <path>`. Prints `valid` and exits 0, or
38
+ prints the reason and exits non-zero.
39
+ - `view` prints the live unsealed Secret manifest to **STDOUT, read-only** — no editor, no
40
+ RAM workspace, no file written.
41
+ - `list` prints a table of the SealedSecret objects in the cluster (columns **NAMESPACE,
42
+ NAME, SCOPE, AGE**). Give a `[namespace]` to scope it to one namespace; omit it to list all.
43
+ **Read-only and metadata-only** — it never prints encrypted data (not even the data keys).
44
+ - `create`, `edit`, and `reencrypt` write `<secret-name>.yaml` into the current working
45
+ directory. `edit` and `reencrypt` can deploy with `kubectl apply`, but **only** with an
46
+ explicit opt-in flag, and only after confirming the active kube context.
47
+
48
+ In the `edit` buffer, `data:` values are shown as **base64, verbatim** — they are never
49
+ decoded to plaintext. To change a value readably, add it under a `stringData:` block; on
50
+ save it is folded into `data` (and wins per key). The seeded buffer includes a worked
51
+ example to make this obvious. Pass `--string-data` to decode the **whole** buffer to plaintext
52
+ `stringData` up front (an opt-in plaintext exposure).
53
+
54
+ ### `edit` flags & behaviour
55
+
56
+ - `--scope strict|namespace-wide|cluster-wide` — by default `edit` **preserves the existing
57
+ scope**: it reads the SealedSecret's scope annotation from the cluster, falling back to the
58
+ local `<secret-name>.yaml`, then to `strict`. Pass `--scope` to override.
59
+ - `--deploy` — after writing, `kubectl apply` the result. Surfaces the active kube context
60
+ and asks you to confirm first. Never the default.
61
+ - `--yes` — skip the interactive deploy confirmation (only meaningful together with
62
+ `--deploy`), for non-interactive pipelines.
63
+ - `--local` — force the offline local edit without contacting the cluster at all (see below).
64
+ - `--string-data` — decode the live Secret's `data` into plaintext `stringData` for editing,
65
+ instead of showing it as raw base64.
66
+ - `--cert`, `--controller-name`, `--controller-namespace`, `--refresh-cert` — control which
67
+ controller certificate is used to re-seal (same as `create`).
68
+ - **No-op short-circuit:** if you save the buffer without changing anything, `rkseal` writes
69
+ no file (re-sealing identical input would only produce a spurious ciphertext diff). Because
70
+ nothing new is produced, a `--deploy` on an unchanged secret deploys **nothing**.
71
+
72
+ #### `edit --local` (offline)
73
+
74
+ When a SealedSecret was authored with `create` but **never deployed**, there is no unsealed
75
+ Secret in the cluster to recover values from. `rkseal` then edits the local
76
+ `<secret-name>.yaml` **offline** — reached automatically when the cluster Secret is absent
77
+ but the local file exists, or forced with `--local` (which never contacts the cluster, useful
78
+ when it is unreachable).
79
+
80
+ Because a SealedSecret cannot be decrypted, every existing key is shown as `<redacted>`:
81
+
82
+ - **leave it `<redacted>`** — the existing ciphertext is kept byte-for-byte (no re-seal),
83
+ - **replace the value** (or add a new key) — that key is re-sealed and merged in,
84
+ - **delete the line** — that key is removed.
85
+
86
+ Scope is **fixed** in this mode (`--scope` is rejected) and `name`/`namespace` cannot change —
87
+ kept ciphertext binds them and cannot be re-sealed without its plaintext. The automatic
88
+ fallback fires only on a definitive "not found"; an **unreachable** cluster surfaces as an
89
+ error instead (use `--local` to force offline). `--deploy` / `--yes` behave exactly as for the
90
+ online `edit`.
91
+
92
+ ### `create` flags
93
+
94
+ - `--scope`, `--type`, `--cert`, `--controller-name`, `--controller-namespace`.
95
+ - `--from-file key=path` (repeatable) — pre-seed a value from a file (binary-safe, stored as
96
+ base64) before the editor opens.
97
+ - `--no-edit` — seal the pre-seeded Secret directly, without opening `$EDITOR` (handy for
98
+ TLS / dockerconfig / binary payloads).
99
+ - `--string-data` — seed the buffer with a plaintext `stringData` block instead of base64
100
+ `data`, so you type values in clear (folded into `data` on save).
101
+ - `--refresh-cert` — bypass the cached controller cert and re-fetch it for this run.
102
+ - The controller certificate is resolved up front, so an unreachable controller fails fast
103
+ **before** you start editing.
104
+
105
+ ### `reencrypt` flags
106
+
107
+ - `--deploy` / `--yes` — same deploy semantics as `edit` (opt-in, context-confirmed; `--yes`
108
+ skips the prompt).
109
+ - `--cert`, `--controller-name`, `--controller-namespace`, `--refresh-cert`.
110
+
111
+ ### `validate` flags
112
+
113
+ - `--file <path>` — validate an arbitrary SealedSecret manifest instead of the local
114
+ `<secret-name>.yaml` (then `NAMESPACE`/`NAME` are optional).
115
+ - `--cert`, `--controller-name`, `--controller-namespace`, `--refresh-cert`.
116
+
117
+ ### `view` flags
118
+
119
+ - `--reveal` — decode `data` and print values as plaintext `stringData` (default shows raw
120
+ base64, consistent with `edit`). Read-only: `view` never writes a file or opens an editor.
121
+
122
+ ### `--refresh-cert`
123
+
124
+ `create`, `edit`, `reencrypt`, and `validate` accept `--refresh-cert` to bypass the cached
125
+ controller certificate and re-fetch it from the live controller for that run.
126
+
127
+ ## Requirements
128
+
129
+ - Ruby **4.0.2** (managed with `rvm` + a dedicated `rkseal` gemset).
130
+ - `kubeseal` (developed against **v0.36.6**) and `kubectl` on your `PATH`.
131
+ - Access to a cluster running the sealed-secrets controller (for `--fetch-cert`, `edit`,
132
+ and deploys).
133
+
134
+ ## Development
135
+
136
+ ```sh
137
+ rvm use 4.0.2@rkseal --create # isolated gemset — never install gems globally
138
+ bundle install
139
+ bundle exec rspec # unit suite (adapters stubbed; no cluster needed)
140
+ bundle exec rubocop # lint
141
+ bundle exec exe/rkseal create my-namespace my-secret
142
+ ```
143
+
144
+ Unit tests stub the `kubeseal` / `kubectl` / `$EDITOR` adapters, so the suite runs without
145
+ a cluster. Real cluster operations are reserved for explicit integration tests gated on the
146
+ `docker-desktop` context.
147
+
148
+ ## Security model
149
+
150
+ - **Plaintext never hits persistent disk.** The edit buffer is RAM-backed
151
+ (`tmpfs`/`/dev/shm` on Linux, an ephemeral `hdiutil` RAM disk on macOS) and is shredded
152
+ and torn down on exit, including on error or signal.
153
+ - **Deploys confirm the active context.** Applying to the wrong cluster is the dangerous
154
+ operation, so deploy is never the default: `rkseal` surfaces the current kube context and
155
+ asks you to confirm before `kubectl apply`. There is no in-code allow-list — `rkseal` uses
156
+ whatever context is active — so switch context deliberately before deploying (`--yes`
157
+ bypasses only the prompt, not the `--deploy` opt-in).
158
+ - **Names are validated at the boundary.** `<namespace>` and `<secret-name>` must be valid
159
+ Kubernetes DNS-1123 names; anything else (path traversal like `../`, a leading `-` that
160
+ could be read as a `kubectl`/`kubeseal` flag, `/`, uppercase, …) is rejected up front,
161
+ before any editor, cluster call, or file write.
162
+
163
+ ## License
164
+
165
+ MIT — see [LICENSE.txt](LICENSE.txt).
data/exe/rkseal ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rkseal"
5
+
6
+ # Thin entry point: hand the raw ARGV to the Thor CLI and translate the gem's
7
+ # fail-fast errors into a clean non-zero exit with a single-line message, rather
8
+ # than dumping a Ruby backtrace on the user. Implementation lives in RKSeal::CLI.
9
+ RKSeal::CLI.dispatch(ARGV)
data/lib/rkseal/cli.rb ADDED
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module RKSeal
6
+ # Thor-based command-line interface: parses ARGV, validates options, and
7
+ # dispatches to the orchestration commands. It is intentionally thin -- it
8
+ # maps flags/positionals onto {RKSeal::Commands::Create} /
9
+ # {RKSeal::Commands::Edit}, prints their {RKSeal::Commands::Result}, and turns
10
+ # the gem's fail-fast {RKSeal::Error}s into a single clean line + non-zero
11
+ # exit. No business logic lives here.
12
+ #
13
+ # rubocop:disable Metrics/ClassLength -- length here is Thor's declarative
14
+ # `method_option` surface (every flag for both subcommands plus their
15
+ # long_desc help text), not logic. The two command bodies stay thin and
16
+ # delegate straight to the orchestration classes.
17
+ class CLI < Thor
18
+ # kubeseal's `--scope` strings, as exposed on the CLI, mapped to the symbols
19
+ # the command/adapter layers expect. Thor does not underscore enum values.
20
+ SCOPE_SYMBOLS = {
21
+ "strict" => :strict,
22
+ "namespace-wide" => :namespace_wide,
23
+ "cluster-wide" => :cluster_wide
24
+ }.freeze
25
+
26
+ # Make argument/usage errors (and our rescued errors) exit non-zero rather
27
+ # than return 0, so the CLI is shell-script friendly.
28
+ def self.exit_on_failure?
29
+ true
30
+ end
31
+
32
+ class << self
33
+ # `dispatch` is also Thor's own internal 4-arg command router, which
34
+ # {Thor.start} calls. Preserve it under an alias so our public 1-arg entry
35
+ # point can reuse the name (as the gem's contract requires) without
36
+ # clobbering Thor's routing.
37
+ alias thor_dispatch dispatch
38
+
39
+ # Entry point used by `exe/rkseal`, and Thor's internal command router.
40
+ #
41
+ # Dual-role on arity:
42
+ # - called as `dispatch(argv)` (a single Array, from `exe/rkseal`): run
43
+ # {Thor.start} and translate any deliberately-raised {RKSeal::Error}
44
+ # into a one-line stderr message with a non-zero exit -- no backtrace.
45
+ # Thor's own parse errors keep their {exit_on_failure?} handling;
46
+ # unexpected exceptions propagate.
47
+ # - called by Thor internally (`dispatch(meth, args, opts, config)`):
48
+ # delegate to Thor's preserved router unchanged.
49
+ #
50
+ # @param args [Array] either `[argv]` (public) or Thor's four router args.
51
+ # @return [void]
52
+ def dispatch(*args)
53
+ return thor_dispatch(*args) unless args.length == 1 && args.first.is_a?(Array)
54
+
55
+ begin
56
+ start(args.first)
57
+ rescue RKSeal::Error => e
58
+ warn(e.message)
59
+ exit(1)
60
+ end
61
+ end
62
+ end
63
+
64
+ desc "create NAMESPACE NAME", "Author a new SealedSecret and write <NAME>.yaml"
65
+ long_desc <<~LONGDESC
66
+ Opens an empty, commented Kubernetes Secret manifest in $EDITOR on a
67
+ RAM-backed buffer. After you save, the Secret is sealed with the
68
+ controller's public key and written as <NAME>.yaml in the current
69
+ directory. The plaintext buffer never touches persistent disk.
70
+
71
+ Pre-seed values with --from-file key=path (repeatable; binary-safe, stored
72
+ as base64). Pass --no-edit to seal the pre-seeded Secret directly without
73
+ opening an editor (useful for TLS/dockerconfig/binary payloads).
74
+ LONGDESC
75
+ method_option :scope, type: :string, default: "strict",
76
+ enum: %w[strict namespace-wide cluster-wide],
77
+ desc: "Sealing scope bound into the ciphertext"
78
+ method_option :type, type: :string, default: Secret::DEFAULT_TYPE,
79
+ desc: "Secret type (e.g. Opaque, kubernetes.io/tls)"
80
+ method_option :cert, type: :string,
81
+ desc: "Controller certificate (file or URL); else --fetch-cert/env is used"
82
+ method_option :"controller-name", type: :string,
83
+ desc: "sealed-secrets controller name"
84
+ method_option :"controller-namespace", type: :string,
85
+ desc: "controller namespace"
86
+ method_option :"refresh-cert", type: :boolean, default: false,
87
+ desc: "Bypass the cert cache and re-fetch from the controller"
88
+ method_option :"from-file", type: :array,
89
+ desc: "Pre-seed key=path value(s) into the buffer before editing"
90
+ method_option :"no-edit", type: :boolean, default: false,
91
+ desc: "Seal the pre-seeded Secret directly, without opening $EDITOR"
92
+ method_option :"string-data", type: :boolean, default: false,
93
+ desc: "Edit values as plaintext stringData instead of base64 data"
94
+ # Author a new SealedSecret.
95
+ #
96
+ # @param namespace [String] target namespace.
97
+ # @param name [String] Secret name (also the output filename stem).
98
+ # @return [void]
99
+ def create(namespace, name)
100
+ validate_identifiers!(namespace, name)
101
+ result = Commands::Create.new(
102
+ namespace: namespace, name: name,
103
+ scope: scope_symbol, type: options["type"],
104
+ from_file: parsed_from_file, no_edit: options["no-edit"],
105
+ string_data: options["string-data"],
106
+ kubeseal: build_kubeseal
107
+ ).call
108
+ report(result)
109
+ end
110
+
111
+ desc "edit NAMESPACE NAME", "Edit an existing SealedSecret and write <NAME>.yaml"
112
+ long_desc <<~LONGDESC
113
+ Reads the live unsealed Secret from the cluster (kubectl get secret -o
114
+ json) -- the only way to recover current values -- and opens it in $EDITOR
115
+ on a RAM-backed buffer, with `data` shown as base64 (verbatim, not decoded
116
+ to plaintext). Add plaintext under `stringData` to change values readably.
117
+ After you save, it re-seals and writes <NAME>.yaml in the current
118
+ directory.
119
+
120
+ If the Secret is absent from the cluster but a local <NAME>.yaml exists
121
+ (e.g. you ran `create` but never deployed), rkseal switches automatically
122
+ to an OFFLINE local edit -- no flag needed. There the existing values
123
+ cannot be decrypted, so each key is shown as <redacted>: leave it to keep
124
+ the sealed value, replace it to re-seal that key, add lines for new keys,
125
+ or delete lines to remove keys. Scope is fixed (cannot be changed offline).
126
+ If neither the cluster Secret nor a local file exists, rkseal fails fast
127
+ and points you at `create`.
128
+
129
+ Pass --local to force the offline path without contacting the cluster at
130
+ all (useful when the cluster is unreachable). The automatic fallback only
131
+ fires on a definitive "not found"; an unreachable cluster is surfaced as an
132
+ error instead, since rkseal cannot then tell whether the secret exists
133
+ remotely.
134
+
135
+ Scope is preserved automatically: rkseal reads the existing SealedSecret's
136
+ scope annotation from the cluster (falling back to the local <NAME>.yaml,
137
+ then to strict). Pass --scope to override (cluster edits only; an offline
138
+ edit cannot change scope).
139
+
140
+ If you save without changing anything, rkseal writes no file -- and because
141
+ there is nothing new to apply, a --deploy on an unchanged secret is a no-op
142
+ (nothing is deployed).
143
+
144
+ Deploy is opt-in only: pass --deploy to `kubectl apply` the result, which
145
+ first surfaces the active kube context and asks you to confirm. In a
146
+ non-interactive pipeline, add --yes to skip the prompt (still requires
147
+ --deploy).
148
+ LONGDESC
149
+ method_option :scope, type: :string,
150
+ enum: %w[strict namespace-wide cluster-wide],
151
+ desc: "Sealing scope (overrides the secret's existing scope)"
152
+ method_option :local, type: :boolean, default: false,
153
+ desc: "Force offline edit of the local file (auto-detected otherwise)"
154
+ method_option :"string-data", type: :boolean, default: false,
155
+ desc: "Edit values as plaintext stringData instead of base64 data"
156
+ method_option :deploy, type: :boolean, default: false,
157
+ desc: "Apply the result to the cluster after writing (opt-in)"
158
+ method_option :yes, type: :boolean, default: false,
159
+ desc: "Skip the deploy confirmation prompt (only with --deploy)"
160
+ method_option :cert, type: :string,
161
+ desc: "Controller certificate (file or URL); else --fetch-cert/env is used"
162
+ method_option :"controller-name", type: :string,
163
+ desc: "sealed-secrets controller name"
164
+ method_option :"controller-namespace", type: :string,
165
+ desc: "controller namespace"
166
+ method_option :"refresh-cert", type: :boolean, default: false,
167
+ desc: "Bypass the cert cache and re-fetch from the controller"
168
+ # Edit an existing SealedSecret. Reads current values from the cluster; if
169
+ # the Secret is absent there but a local <NAME>.yaml exists, automatically
170
+ # falls back to the offline local edit. `--local` forces the offline path.
171
+ #
172
+ # @param namespace [String] target namespace.
173
+ # @param name [String] Secret name (also the output filename stem).
174
+ # @return [void]
175
+ def edit(namespace, name)
176
+ validate_identifiers!(namespace, name)
177
+ result = options["local"] ? edit_local(namespace, name) : edit_auto(namespace, name)
178
+ report(result)
179
+ end
180
+
181
+ desc "reencrypt NAMESPACE NAME", "Re-encrypt a SealedSecret to the controller's newest key"
182
+ long_desc <<~LONGDESC
183
+ Rotates an existing SealedSecret onto the controller's current sealing key
184
+ (`kubeseal --re-encrypt`) without ever exposing plaintext. The input is the
185
+ SealedSecret itself: rkseal reads the local <NAME>.yaml if present,
186
+ otherwise the live SealedSecret from the cluster. If neither exists, it
187
+ fails fast and points you at `create`. The result is written back to
188
+ <NAME>.yaml.
189
+
190
+ Deploy works exactly like `edit`: pass --deploy to `kubectl apply`, which
191
+ surfaces the active context and asks you to confirm (--yes skips the prompt
192
+ in non-interactive pipelines).
193
+ LONGDESC
194
+ method_option :deploy, type: :boolean, default: false,
195
+ desc: "Apply the result to the cluster after writing (opt-in)"
196
+ method_option :yes, type: :boolean, default: false,
197
+ desc: "Skip the deploy confirmation prompt (only with --deploy)"
198
+ method_option :cert, type: :string,
199
+ desc: "Controller certificate (file or URL); else --fetch-cert/env is used"
200
+ method_option :"controller-name", type: :string,
201
+ desc: "sealed-secrets controller name"
202
+ method_option :"controller-namespace", type: :string,
203
+ desc: "controller namespace"
204
+ method_option :"refresh-cert", type: :boolean, default: false,
205
+ desc: "Bypass the cert cache and re-fetch from the controller"
206
+ # Re-encrypt an existing SealedSecret to the newest controller key.
207
+ #
208
+ # @param namespace [String] target namespace.
209
+ # @param name [String] Secret name (also the output filename stem).
210
+ # @return [void]
211
+ def reencrypt(namespace, name)
212
+ validate_identifiers!(namespace, name)
213
+ result = Commands::Reencrypt.new(
214
+ namespace: namespace, name: name,
215
+ deploy: options["deploy"], assume_yes: options["yes"],
216
+ kubectl: Kubectl.new, kubeseal: build_kubeseal
217
+ ).call
218
+ report(result)
219
+ end
220
+
221
+ desc "validate [NAMESPACE NAME]", "Check a SealedSecret with the controller"
222
+ long_desc <<~LONGDESC
223
+ Asks the controller whether a SealedSecret is well-formed and decryptable
224
+ for its target (`kubeseal --validate`). Nothing is decrypted or revealed --
225
+ it is a safe pre-flight check before you commit or apply.
226
+
227
+ By default it validates the local <NAME>.yaml for the given namespace/name.
228
+ Pass --file <path> to validate an arbitrary SealedSecret manifest instead
229
+ (NAMESPACE/NAME are then optional). On success it prints a "valid" line and
230
+ exits 0; if the controller rejects the secret, it prints the reason and
231
+ exits non-zero.
232
+ LONGDESC
233
+ method_option :file, type: :string,
234
+ desc: "Validate this SealedSecret file instead of <NAME>.yaml"
235
+ method_option :cert, type: :string,
236
+ desc: "Controller certificate (file or URL); else --fetch-cert/env is used"
237
+ method_option :"controller-name", type: :string,
238
+ desc: "sealed-secrets controller name"
239
+ method_option :"controller-namespace", type: :string,
240
+ desc: "controller namespace"
241
+ method_option :"refresh-cert", type: :boolean, default: false,
242
+ desc: "Bypass the cert cache and re-fetch from the controller"
243
+ # Validate a SealedSecret (local <NAME>.yaml, or --file <path>).
244
+ #
245
+ # @param namespace [String, nil] target namespace (omit with --file).
246
+ # @param name [String, nil] Secret name (omit with --file).
247
+ # @return [void]
248
+ def validate(namespace = nil, name = nil)
249
+ file = options["file"]
250
+ raise InvalidInputError, "give NAMESPACE NAME or --file <path>" if file.nil? && name.nil?
251
+
252
+ validate_identifiers!(namespace, name) unless file
253
+ path = Commands::Validate.new(
254
+ namespace: namespace, name: name, file: file, kubeseal: build_kubeseal
255
+ ).call
256
+ say("SealedSecret #{path} is valid.")
257
+ end
258
+
259
+ desc "view NAMESPACE NAME", "Print the live Secret for a SealedSecret (read-only)"
260
+ long_desc <<~LONGDESC
261
+ Reads the live unsealed Secret from the cluster and prints the full Secret
262
+ manifest to STDOUT. Strictly read-only: no editor, no RAM workspace, no
263
+ file is written.
264
+
265
+ By default `data` is shown as raw base64 (verbatim, like `edit`). Pass
266
+ --reveal to decode the values and print them as plaintext `stringData`.
267
+ If the Secret is not present in the cluster, rkseal fails fast and points
268
+ you at `create`.
269
+ LONGDESC
270
+ method_option :reveal, type: :boolean, default: false,
271
+ desc: "Decode data and show values as plaintext stringData"
272
+ # Print the live Secret for a SealedSecret (read-only).
273
+ #
274
+ # @param namespace [String] target namespace.
275
+ # @param name [String] Secret name.
276
+ # @return [void]
277
+ def view(namespace, name)
278
+ validate_identifiers!(namespace, name)
279
+ manifest = Commands::View.new(
280
+ namespace: namespace, name: name, reveal: options["reveal"], kubectl: Kubectl.new
281
+ ).call
282
+ say(manifest)
283
+ end
284
+
285
+ desc "list [NAMESPACE]", "List SealedSecrets (metadata only, read-only)"
286
+ long_desc <<~LONGDESC
287
+ Lists the SealedSecret objects in the cluster as a table with columns
288
+ NAMESPACE, NAME, SCOPE, and AGE. Give a NAMESPACE to scope the listing to
289
+ one namespace; omit it to list across all namespaces.
290
+
291
+ Read-only and metadata-only: rkseal prints only each object's
292
+ name/namespace/scope/age -- never any encrypted data. No editor, no file is
293
+ written.
294
+ LONGDESC
295
+ # List SealedSecrets (read-only, metadata only).
296
+ #
297
+ # @param namespace [String, nil] limit to this namespace; omit for all.
298
+ # @return [void]
299
+ def list(namespace = nil)
300
+ Secret.validate_identifier!(field: "namespace", value: namespace) if namespace
301
+ say(Commands::List.new(namespace: namespace, kubectl: Kubectl.new).call)
302
+ end
303
+
304
+ desc "version", "Print the rkseal version"
305
+ long_desc "Print the installed rkseal gem version and exit."
306
+ # @return [void]
307
+ def version
308
+ say("rkseal #{RKSeal::VERSION}")
309
+ end
310
+
311
+ private
312
+
313
+ # The default `edit`. The local <NAME>.yaml is the working copy: when it is
314
+ # absent from the cluster or carries un-deployed changes (its sealed payload
315
+ # differs from the deployed SealedSecret), editing continues on it offline so
316
+ # those changes are never silently discarded. Otherwise -- no local file, or
317
+ # it matches what is deployed -- rkseal seeds the editor from the live cluster
318
+ # Secret (the only way to show decrypted values). After a deploy the file
319
+ # matches the cluster again, so full values come back. An unreachable cluster
320
+ # is surfaced as an error (not silently taken offline); use --local to force
321
+ # offline then.
322
+ def edit_auto(namespace, name)
323
+ if local_manifest?(name) && (reason = offline_reason(namespace, name))
324
+ say(reason)
325
+ return edit_local(namespace, name)
326
+ end
327
+
328
+ edit_cluster(namespace, name)
329
+ rescue NotFoundError
330
+ raise unless local_manifest?(name)
331
+
332
+ say("Secret not found in the cluster; editing the local #{name}.yaml offline.")
333
+ edit_local(namespace, name)
334
+ end
335
+
336
+ # When a local <NAME>.yaml exists, decide whether to edit it offline rather
337
+ # than seed from the cluster. Returns the message to print when going offline
338
+ # (the file is absent from the cluster, or diverges from the deployed
339
+ # SealedSecret), or nil to seed from the cluster. A `NotFound` cluster
340
+ # SealedSecret means it was never deployed -> offline; other kubectl errors
341
+ # (e.g. unreachable) propagate.
342
+ def offline_reason(namespace, name)
343
+ cluster = Kubectl.new.get_sealedsecret(name: name, namespace: namespace)
344
+ return nil unless SealedSecret.diverged?(read_local_manifest(name), cluster)
345
+
346
+ "Local #{name}.yaml has changes not deployed to the cluster; editing it offline " \
347
+ "(values shown as <redacted> -- a SealedSecret cannot be decrypted). " \
348
+ "Deploy to make the cluster authoritative again."
349
+ rescue NotFoundError
350
+ "#{name} is not deployed to the cluster; editing the local #{name}.yaml offline."
351
+ end
352
+
353
+ # Recover current values from the live cluster Secret and re-seal.
354
+ def edit_cluster(namespace, name)
355
+ Commands::Edit.new(
356
+ namespace: namespace, name: name,
357
+ scope: scope_symbol, deploy: options["deploy"], assume_yes: options["yes"],
358
+ string_data: options["string-data"],
359
+ kubectl: Kubectl.new, kubeseal: build_kubeseal
360
+ ).call
361
+ end
362
+
363
+ # The offline local edit: operate on the local <NAME>.yaml. Scope cannot be
364
+ # overridden -- kept ciphertext cannot be re-sealed under a new scope.
365
+ def edit_local(namespace, name)
366
+ if options["scope"]
367
+ raise InvalidInputError,
368
+ "scope cannot be changed when editing a local-only SealedSecret " \
369
+ "(kept values cannot be re-sealed under a new scope)"
370
+ end
371
+
372
+ Commands::EditLocal.new(
373
+ namespace: namespace, name: name,
374
+ deploy: options["deploy"], assume_yes: options["yes"],
375
+ string_data: options["string-data"],
376
+ kubectl: Kubectl.new, kubeseal: build_kubeseal
377
+ ).call
378
+ end
379
+
380
+ # Whether a local <NAME>.yaml exists in the working directory (the same path
381
+ # the commands read/write), making an offline fallback possible.
382
+ def local_manifest?(name)
383
+ File.file?(manifest_path(name))
384
+ end
385
+
386
+ # Read the local <NAME>.yaml (only called when {#local_manifest?} is true).
387
+ def read_local_manifest(name)
388
+ File.read(manifest_path(name))
389
+ end
390
+
391
+ def manifest_path(name)
392
+ File.join(Dir.pwd, "#{name}.yaml")
393
+ end
394
+
395
+ # Validate the positional identifiers at the CLI boundary, before any editor,
396
+ # cluster, or filesystem work -- this is the security gate against path
397
+ # traversal and argument injection (see {RKSeal::Secret.validate_identifier!}).
398
+ def validate_identifiers!(namespace, name)
399
+ Secret.validate_identifier!(field: "namespace", value: namespace)
400
+ Secret.validate_identifier!(field: "name", value: name)
401
+ end
402
+
403
+ # Translate the dashed CLI scope string into the symbol the command expects.
404
+ # Thor's enum has already constrained it to a known value. Returns nil when
405
+ # `--scope` was not given (only `edit` omits the default, so it can preserve
406
+ # the secret's existing scope).
407
+ def scope_symbol
408
+ value = options["scope"]
409
+ value.nil? ? nil : SCOPE_SYMBOLS.fetch(value)
410
+ end
411
+
412
+ # Parse repeatable `--from-file key=path` tokens into a {key => path} Hash.
413
+ # Splitting on the first "=" only keeps paths that contain "=" intact.
414
+ #
415
+ # @return [Hash{String=>String}, nil] nil when the flag was not given.
416
+ def parsed_from_file
417
+ entries = options["from-file"]
418
+ return nil if entries.nil?
419
+
420
+ entries.each_with_object({}) do |entry, acc|
421
+ key, path = entry.split("=", 2)
422
+ if key.nil? || key.empty? || path.nil? || path.empty?
423
+ raise InvalidInputError, "--from-file expects key=path, got #{entry.inspect}"
424
+ end
425
+
426
+ acc[key] = path
427
+ end
428
+ end
429
+
430
+ # Build the kubeseal adapter from the cert/controller options. Dashed option
431
+ # names are string keys (Thor does not auto-underscore them). `--refresh-cert`
432
+ # bypasses the cert cache (`Kubeseal.new(refresh_cert:)`); it defaults to
433
+ # false for every command that builds a kubeseal adapter.
434
+ #
435
+ # The controller name/namespace are Kubernetes identifiers that flow into the
436
+ # on-disk cert-cache path, so they are validated as DNS-1123 here (same gate
437
+ # as the positional args) -- this prevents `../`, `/`, a leading `-`, or NUL
438
+ # from escaping the cache directory before any path is built.
439
+ def build_kubeseal
440
+ controller_name = validated_controller("controller-name")
441
+ controller_namespace = validated_controller("controller-namespace")
442
+ Kubeseal.new(
443
+ cert: options["cert"],
444
+ controller_name: controller_name,
445
+ controller_namespace: controller_namespace,
446
+ refresh_cert: options.fetch("refresh-cert", false)
447
+ )
448
+ end
449
+
450
+ # Validate a controller flag as a DNS-1123 name when present; pass nil through
451
+ # untouched (the adapter falls back to its own defaults).
452
+ def validated_controller(flag)
453
+ value = options[flag]
454
+ return nil if value.nil?
455
+
456
+ Secret.validate_identifier!(field: "--#{flag}", value: value)
457
+ end
458
+
459
+ # Print a one-line outcome. A nil output_path means the edit was a no-op.
460
+ def report(result)
461
+ if result.output_path.nil?
462
+ say("No changes; nothing written.")
463
+ return
464
+ end
465
+
466
+ say("Wrote #{result.output_path}")
467
+ say("Deployed #{result.secret_name} to the cluster.") if result.deployed
468
+ end
469
+ end
470
+ # rubocop:enable Metrics/ClassLength
471
+ end