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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +165 -0
- data/exe/rkseal +9 -0
- data/lib/rkseal/cli.rb +471 -0
- data/lib/rkseal/commands/create.rb +121 -0
- data/lib/rkseal/commands/edit.rb +183 -0
- data/lib/rkseal/commands/edit_local.rb +302 -0
- data/lib/rkseal/commands/list.rb +130 -0
- data/lib/rkseal/commands/reencrypt.rb +118 -0
- data/lib/rkseal/commands/result.rb +22 -0
- data/lib/rkseal/commands/validate.rb +78 -0
- data/lib/rkseal/commands/view.rb +58 -0
- data/lib/rkseal/context_guard.rb +57 -0
- data/lib/rkseal/editor.rb +141 -0
- data/lib/rkseal/errors.rb +74 -0
- data/lib/rkseal/kubectl.rb +168 -0
- data/lib/rkseal/kubeseal.rb +382 -0
- data/lib/rkseal/sealed_secret.rb +204 -0
- data/lib/rkseal/secret.rb +534 -0
- data/lib/rkseal/secure_workspace.rb +432 -0
- data/lib/rkseal/version.rb +5 -0
- data/lib/rkseal.rb +89 -0
- data/rkseal.gemspec +53 -0
- metadata +127 -0
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
|