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
|
@@ -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
|