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,432 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "open3"
|
|
8
|
+
|
|
9
|
+
module RKSeal
|
|
10
|
+
# Provides a RAM-backed scratch path for the plaintext edit buffer and
|
|
11
|
+
# guarantees its destruction. This is the single enforcement point for the
|
|
12
|
+
# hard rule: **plaintext must never touch persistent disk.**
|
|
13
|
+
#
|
|
14
|
+
# The medium is chosen per-OS behind one interface:
|
|
15
|
+
# - Linux: a tmpfs path (`/dev/shm`, or `$XDG_RUNTIME_DIR`).
|
|
16
|
+
# - macOS: an ephemeral `hdiutil`-backed RAM disk, attached for the duration
|
|
17
|
+
# of the edit and detached afterwards (macOS has no tmpfs/`/dev/shm`).
|
|
18
|
+
#
|
|
19
|
+
# There is **no on-disk `mktemp` fallback**. If a RAM-backed medium cannot be
|
|
20
|
+
# provisioned, the workspace raises {RKSeal::WorkspaceError} rather than
|
|
21
|
+
# degrade the security guarantee.
|
|
22
|
+
#
|
|
23
|
+
# The public API is block-scoped so callers cannot forget teardown: the path
|
|
24
|
+
# exists only inside the block, and on block exit (normal, exception, or
|
|
25
|
+
# signal) the file is best-effort shredded/overwritten and unlinked and any
|
|
26
|
+
# RAM disk is detached. Signal handling and `at_exit` registration guard
|
|
27
|
+
# against a crash leaking a mounted RAM disk. Secret values are never logged.
|
|
28
|
+
#
|
|
29
|
+
# rubocop:disable Metrics/ClassLength -- this single class deliberately holds
|
|
30
|
+
# the workspace orchestration plus its two tightly-coupled per-OS medium
|
|
31
|
+
# strategies (LinuxMedium, MacosMedium). They are one cohesive unit and the
|
|
32
|
+
# gem keeps one layer per file, so splitting them out would scatter the
|
|
33
|
+
# "never on disk" guarantee rather than clarify it.
|
|
34
|
+
class SecureWorkspace
|
|
35
|
+
# Filesystem permissions for the scratch file: owner read/write only.
|
|
36
|
+
FILE_MODE = 0o600
|
|
37
|
+
|
|
38
|
+
# Permissions for the (Linux) scratch *directory*: owner only.
|
|
39
|
+
DIR_MODE = 0o700
|
|
40
|
+
|
|
41
|
+
# Size of the macOS RAM disk. A few MB is plenty for a Secret manifest; the
|
|
42
|
+
# buffer holds one small YAML document, never bulk data.
|
|
43
|
+
RAM_DISK_BYTES = 8 * 1024 * 1024
|
|
44
|
+
|
|
45
|
+
# Bytes per disk sector, used to convert {RAM_DISK_BYTES} into the sector
|
|
46
|
+
# count `hdiutil attach ram://<sectors>` expects.
|
|
47
|
+
SECTOR_BYTES = 512
|
|
48
|
+
|
|
49
|
+
# Signals whose default action would terminate the process before `ensure`
|
|
50
|
+
# blocks normally run; we trap them so teardown still fires.
|
|
51
|
+
TRAPPED_SIGNALS = %w[INT TERM].freeze
|
|
52
|
+
|
|
53
|
+
# Process-wide registry of live workspaces, swept by the `at_exit`/signal
|
|
54
|
+
# safety net so a crash or Ctrl-C cannot leak a mounted RAM disk. Guarded by
|
|
55
|
+
# a mutex because signal handlers can run concurrently with normal teardown.
|
|
56
|
+
@registry = []
|
|
57
|
+
@registry_mutex = Mutex.new
|
|
58
|
+
@safety_net_installed = false
|
|
59
|
+
|
|
60
|
+
class << self
|
|
61
|
+
attr_reader :registry, :registry_mutex
|
|
62
|
+
|
|
63
|
+
# Provision a RAM-backed scratch file, yield its path, and guarantee
|
|
64
|
+
# teardown when the block returns or raises.
|
|
65
|
+
#
|
|
66
|
+
# @param basename [String] hint for the scratch file name (no secret data).
|
|
67
|
+
# @yieldparam path [String] absolute path to the RAM-backed file.
|
|
68
|
+
# @yieldreturn [Object] whatever the block returns is returned to caller.
|
|
69
|
+
# @return [Object] the block's return value.
|
|
70
|
+
# @raise [RKSeal::WorkspaceError] if a RAM-backed medium cannot be
|
|
71
|
+
# provisioned or mounted (never falls back to plain on-disk temp).
|
|
72
|
+
def with(basename: "rkseal")
|
|
73
|
+
workspace = new(basename: basename)
|
|
74
|
+
path = workspace.provision
|
|
75
|
+
yield path
|
|
76
|
+
ensure
|
|
77
|
+
workspace&.teardown
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Register a workspace in the process-wide safety net and lazily install
|
|
81
|
+
# the `at_exit` hook and signal traps on first use.
|
|
82
|
+
#
|
|
83
|
+
# @param workspace [SecureWorkspace]
|
|
84
|
+
# @return [void]
|
|
85
|
+
def register(workspace)
|
|
86
|
+
@registry_mutex.synchronize do
|
|
87
|
+
install_safety_net unless @safety_net_installed
|
|
88
|
+
@registry << workspace unless @registry.include?(workspace)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Drop a workspace from the safety net once it has torn itself down.
|
|
93
|
+
#
|
|
94
|
+
# @param workspace [SecureWorkspace]
|
|
95
|
+
# @return [void]
|
|
96
|
+
def unregister(workspace)
|
|
97
|
+
@registry_mutex.synchronize { @registry.delete(workspace) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Wire up the crash-safety net exactly once: an `at_exit` hook for normal
|
|
103
|
+
# and exception exits, plus traps for INT/TERM that tear everything down
|
|
104
|
+
# and then re-raise the default behaviour so the process still dies.
|
|
105
|
+
def install_safety_net
|
|
106
|
+
at_exit { sweep_registry }
|
|
107
|
+
|
|
108
|
+
TRAPPED_SIGNALS.each do |signal|
|
|
109
|
+
previous = Signal.trap(signal) { handle_signal(signal) }
|
|
110
|
+
previous_handlers[signal] = previous
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@safety_net_installed = true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Tear down every still-live workspace. Best-effort: never raises, so it is
|
|
117
|
+
# safe to run from `at_exit` and signal contexts.
|
|
118
|
+
def sweep_registry
|
|
119
|
+
@registry.dup.each(&:teardown)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Saved prior signal handlers, so trapping INT/TERM chains to whatever was
|
|
123
|
+
# installed before instead of swallowing the signal.
|
|
124
|
+
def previous_handlers
|
|
125
|
+
@previous_handlers ||= {}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Signal handler: shred all workspaces, restore the previous disposition,
|
|
129
|
+
# and re-raise so the process terminates as the user expects.
|
|
130
|
+
def handle_signal(signal)
|
|
131
|
+
sweep_registry
|
|
132
|
+
restore_default(signal)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Restore a signal to its previously-installed handler (or DEFAULT) and
|
|
136
|
+
# re-send it to ourselves so termination proceeds.
|
|
137
|
+
def restore_default(signal)
|
|
138
|
+
previous = previous_handlers[signal]
|
|
139
|
+
Signal.trap(signal, previous || "DEFAULT")
|
|
140
|
+
Process.kill(signal, Process.pid)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param basename [String] hint for the scratch file name (no secret data).
|
|
145
|
+
def initialize(basename: "rkseal")
|
|
146
|
+
@basename = sanitize_basename(basename)
|
|
147
|
+
@medium = build_medium
|
|
148
|
+
@path = nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Provision the RAM-backed medium and return the usable scratch path. The
|
|
152
|
+
# caller is then responsible for invoking {#teardown} (prefer the
|
|
153
|
+
# block-scoped {.with} which does this automatically).
|
|
154
|
+
#
|
|
155
|
+
# @return [String] absolute path to the RAM-backed file.
|
|
156
|
+
# @raise [RKSeal::WorkspaceError] if provisioning/mounting fails.
|
|
157
|
+
def provision
|
|
158
|
+
return @path if @path
|
|
159
|
+
|
|
160
|
+
# Register in the crash-safety net BEFORE touching any RAM medium, so a
|
|
161
|
+
# SIGINT/SIGTERM landing mid-provision -- e.g. after `hdiutil attach`
|
|
162
|
+
# succeeds but during `newfs_hfs`/`mount` -- still sweeps this workspace
|
|
163
|
+
# and detaches the attached device. The sweep would otherwise miss a
|
|
164
|
+
# half-provisioned workspace and leak an orphaned RAM device. Teardown is
|
|
165
|
+
# idempotent and tolerates any partial state, so early registration is
|
|
166
|
+
# safe and `unregister` on the rescue/teardown path keeps it accurate.
|
|
167
|
+
self.class.register(self)
|
|
168
|
+
|
|
169
|
+
directory = @medium.provision
|
|
170
|
+
@path = File.join(directory, "#{@basename}-#{SecureRandom.hex(8)}")
|
|
171
|
+
create_scratch_file(@path)
|
|
172
|
+
@path
|
|
173
|
+
rescue WorkspaceError
|
|
174
|
+
teardown
|
|
175
|
+
raise
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
teardown
|
|
178
|
+
raise WorkspaceError, "failed to provision RAM-backed workspace: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Best-effort shred + unlink the scratch file and detach/teardown any RAM
|
|
182
|
+
# disk. Idempotent and must not raise on a partially-provisioned workspace
|
|
183
|
+
# (it runs from `ensure`/signal paths). Logs nothing sensitive.
|
|
184
|
+
#
|
|
185
|
+
# @return [void]
|
|
186
|
+
def teardown
|
|
187
|
+
shred_and_unlink(@path)
|
|
188
|
+
@path = nil
|
|
189
|
+
@medium.teardown
|
|
190
|
+
self.class.unregister(self)
|
|
191
|
+
nil
|
|
192
|
+
rescue StandardError
|
|
193
|
+
# Teardown is a safety net and must never raise. We have already nulled
|
|
194
|
+
# @path so a retry is harmless; the RAM medium's own teardown retries a
|
|
195
|
+
# transiently-busy detach internally.
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
attr_reader :basename
|
|
202
|
+
|
|
203
|
+
# Choose the RAM-backed medium for the current OS. Kept as a thin dispatch
|
|
204
|
+
# over {#os_family} so the selection logic is unit-testable in isolation.
|
|
205
|
+
def build_medium
|
|
206
|
+
case os_family
|
|
207
|
+
when :linux then LinuxMedium.new
|
|
208
|
+
when :macos then MacosMedium.new(bytes: RAM_DISK_BYTES)
|
|
209
|
+
else
|
|
210
|
+
raise WorkspaceError,
|
|
211
|
+
"no RAM-backed scratch medium for this platform " \
|
|
212
|
+
"(#{RbConfig::CONFIG["host_os"]}); refusing on-disk fallback"
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Classify the host OS from RbConfig. Returns :linux, :macos, or
|
|
217
|
+
# :unsupported. Isolated so specs can drive each branch deterministically.
|
|
218
|
+
def os_family
|
|
219
|
+
host = RbConfig::CONFIG["host_os"]
|
|
220
|
+
case host
|
|
221
|
+
when /linux/i then :linux
|
|
222
|
+
when /darwin|mac os/i then :macos
|
|
223
|
+
else :unsupported
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Strip anything that is not a safe filename fragment; the basename is a
|
|
228
|
+
# hint, never trusted input, and must not let a caller escape the directory.
|
|
229
|
+
def sanitize_basename(value)
|
|
230
|
+
cleaned = value.to_s.gsub(/[^A-Za-z0-9_.-]/, "_")
|
|
231
|
+
cleaned.empty? ? "rkseal" : cleaned
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Create the scratch file atomically with 0600 permissions. Using the
|
|
235
|
+
# explicit mode on open closes the brief window an open-then-chmod would
|
|
236
|
+
# leave the file world-readable.
|
|
237
|
+
def create_scratch_file(path)
|
|
238
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, FILE_MODE) { |file| file }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Overwrite the file contents with random bytes before unlinking, so the
|
|
242
|
+
# plaintext does not linger in freed RAM pages, then remove it. Best-effort:
|
|
243
|
+
# the file living on RAM-backed storage means even a skipped shred never
|
|
244
|
+
# reaches persistent disk.
|
|
245
|
+
def shred_and_unlink(path)
|
|
246
|
+
return if path.nil? || !File.exist?(path)
|
|
247
|
+
|
|
248
|
+
overwrite_with_random(path)
|
|
249
|
+
File.unlink(path)
|
|
250
|
+
rescue StandardError
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def overwrite_with_random(path)
|
|
255
|
+
size = File.size(path)
|
|
256
|
+
File.open(path, File::WRONLY) do |file|
|
|
257
|
+
file.write(SecureRandom.random_bytes(size)) if size.positive?
|
|
258
|
+
file.flush
|
|
259
|
+
file.fsync
|
|
260
|
+
end
|
|
261
|
+
rescue StandardError
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# RAM-backed medium for Linux: a 0700 directory on an existing tmpfs mount
|
|
266
|
+
# (`/dev/shm` or `$XDG_RUNTIME_DIR`). tmpfs is already RAM, so there is
|
|
267
|
+
# nothing to attach or detach -- provision makes a private subdirectory and
|
|
268
|
+
# teardown removes it.
|
|
269
|
+
class LinuxMedium
|
|
270
|
+
# tmpfs mount points to try, in order of preference.
|
|
271
|
+
CANDIDATES = ["/dev/shm", ENV.fetch("XDG_RUNTIME_DIR", nil)].freeze
|
|
272
|
+
|
|
273
|
+
def initialize
|
|
274
|
+
@dir = nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# @return [String] absolute path to a fresh 0700 scratch directory.
|
|
278
|
+
# @raise [RKSeal::WorkspaceError] if no tmpfs mount is writable.
|
|
279
|
+
def provision
|
|
280
|
+
base = CANDIDATES.compact.find { |candidate| usable?(candidate) }
|
|
281
|
+
unless base
|
|
282
|
+
raise WorkspaceError,
|
|
283
|
+
"no writable tmpfs mount (/dev/shm or $XDG_RUNTIME_DIR) for the scratch buffer"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
@dir = File.join(base, "rkseal-#{SecureRandom.hex(8)}")
|
|
287
|
+
FileUtils.mkdir(@dir, mode: DIR_MODE)
|
|
288
|
+
@dir
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @return [void]
|
|
292
|
+
def teardown
|
|
293
|
+
return if @dir.nil?
|
|
294
|
+
|
|
295
|
+
FileUtils.remove_entry_secure(@dir) if File.directory?(@dir)
|
|
296
|
+
@dir = nil
|
|
297
|
+
rescue StandardError
|
|
298
|
+
nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def usable?(path)
|
|
304
|
+
File.directory?(path) && File.writable?(path)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# RAM-backed medium for macOS: an ephemeral `hdiutil`-backed RAM disk.
|
|
309
|
+
#
|
|
310
|
+
# macOS has no tmpfs/`/dev/shm`, so the only way to keep plaintext off
|
|
311
|
+
# persistent disk is a RAM disk:
|
|
312
|
+
# 1. `hdiutil attach -nomount ram://<sectors>` allocates RAM and returns a
|
|
313
|
+
# raw device node (e.g. /dev/disk7) without mounting it.
|
|
314
|
+
# 2. `newfs_hfs -v <volname> <device>` lays down a tiny HFS+ filesystem.
|
|
315
|
+
# 3. mount it under a private 0700 directory in $TMPDIR (the *mount point*
|
|
316
|
+
# lives on disk but is empty; the *data* lives only on the RAM device).
|
|
317
|
+
# Teardown unmounts and `hdiutil detach`es the device (with a short retry in
|
|
318
|
+
# case it is transiently busy), then removes the empty mount point.
|
|
319
|
+
class MacosMedium
|
|
320
|
+
# How many times to retry a transiently-busy `hdiutil detach`.
|
|
321
|
+
DETACH_ATTEMPTS = 5
|
|
322
|
+
# Backoff between detach retries, in seconds.
|
|
323
|
+
DETACH_BACKOFF = 0.2
|
|
324
|
+
|
|
325
|
+
# @param bytes [Integer] RAM disk size; rounded up to whole sectors.
|
|
326
|
+
def initialize(bytes:)
|
|
327
|
+
@sectors = (bytes.to_f / SECTOR_BYTES).ceil
|
|
328
|
+
@device = nil
|
|
329
|
+
@mount_point = nil
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# @return [String] absolute path to the mounted RAM disk root.
|
|
333
|
+
# @raise [RKSeal::WorkspaceError] if attach/format/mount fails.
|
|
334
|
+
def provision
|
|
335
|
+
@device = attach_ram_device
|
|
336
|
+
format_device(@device)
|
|
337
|
+
@mount_point = make_mount_point
|
|
338
|
+
mount(@device, @mount_point)
|
|
339
|
+
@mount_point
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# @return [void]
|
|
343
|
+
def teardown
|
|
344
|
+
unmount(@mount_point) if @mount_point
|
|
345
|
+
detach_with_retry(@device) if @device
|
|
346
|
+
remove_mount_point(@mount_point) if @mount_point
|
|
347
|
+
@device = nil
|
|
348
|
+
@mount_point = nil
|
|
349
|
+
rescue StandardError
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
private
|
|
354
|
+
|
|
355
|
+
# `hdiutil attach -nomount ram://<sectors>` prints the new device node on
|
|
356
|
+
# stdout. We capture it and reject anything that does not look like a
|
|
357
|
+
# /dev/disk path so a malformed result never reaches `detach`.
|
|
358
|
+
def attach_ram_device
|
|
359
|
+
out, status = run("hdiutil", "attach", "-nomount", "ram://#{@sectors}")
|
|
360
|
+
device = out.to_s.strip.split.first
|
|
361
|
+
unless status&.success? && device&.start_with?("/dev/")
|
|
362
|
+
raise WorkspaceError, "hdiutil could not attach a RAM disk (status #{status&.exitstatus})"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
device
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def format_device(device)
|
|
369
|
+
_out, status = run("newfs_hfs", "-v", "rkseal", device)
|
|
370
|
+
return if status&.success?
|
|
371
|
+
|
|
372
|
+
# Leave nothing attached if formatting failed.
|
|
373
|
+
detach_with_retry(device)
|
|
374
|
+
@device = nil
|
|
375
|
+
raise WorkspaceError,
|
|
376
|
+
"newfs_hfs could not format the RAM disk (status #{status&.exitstatus})"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def make_mount_point
|
|
380
|
+
dir = File.join(Dir.tmpdir, "rkseal-mnt-#{SecureRandom.hex(8)}")
|
|
381
|
+
FileUtils.mkdir(dir, mode: DIR_MODE)
|
|
382
|
+
dir
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Mount the freshly-formatted device read-write at our private mount point.
|
|
386
|
+
def mount(device, mount_point)
|
|
387
|
+
_out, status = run("mount", "-t", "hfs", device, mount_point)
|
|
388
|
+
return if status&.success?
|
|
389
|
+
|
|
390
|
+
raise WorkspaceError, "could not mount the RAM disk (status #{status&.exitstatus})"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def unmount(mount_point)
|
|
394
|
+
run("umount", mount_point)
|
|
395
|
+
rescue StandardError
|
|
396
|
+
nil
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# `hdiutil detach` can fail with EBUSY if the volume was only just
|
|
400
|
+
# unmounted; retry a few times before giving up. Never raises.
|
|
401
|
+
def detach_with_retry(device)
|
|
402
|
+
detached = DETACH_ATTEMPTS.times.any? do |attempt|
|
|
403
|
+
_out, status = run("hdiutil", "detach", device)
|
|
404
|
+
break true if status&.success?
|
|
405
|
+
|
|
406
|
+
sleep(DETACH_BACKOFF) unless attempt == DETACH_ATTEMPTS - 1
|
|
407
|
+
false
|
|
408
|
+
end
|
|
409
|
+
# Last resort: force detach. Still best-effort; we do not raise from
|
|
410
|
+
# teardown.
|
|
411
|
+
run("hdiutil", "detach", "-force", device) unless detached
|
|
412
|
+
rescue StandardError
|
|
413
|
+
nil
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def remove_mount_point(mount_point)
|
|
417
|
+
FileUtils.remove_entry(mount_point) if File.directory?(mount_point)
|
|
418
|
+
rescue StandardError
|
|
419
|
+
nil
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Run an external command with no shell, capturing stdout. Returns
|
|
423
|
+
# [stdout, Process::Status]. Stderr is sent to /dev/null -- it could echo a
|
|
424
|
+
# device path but never secret contents, and we surface our own messages.
|
|
425
|
+
def run(*argv)
|
|
426
|
+
out, _err, status = Open3.capture3(*argv)
|
|
427
|
+
[out, status]
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
# rubocop:enable Metrics/ClassLength
|
|
432
|
+
end
|
data/lib/rkseal.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rkseal/version"
|
|
4
|
+
|
|
5
|
+
# Top-level namespace for the rkseal gem.
|
|
6
|
+
#
|
|
7
|
+
# rkseal wraps the `kubeseal` CLI to create and edit Kubernetes SealedSecrets
|
|
8
|
+
# interactively via `$EDITOR`, in the spirit of `knife vault create/edit`.
|
|
9
|
+
#
|
|
10
|
+
# == Layer map (one file per layer; each is independently testable/mockable)
|
|
11
|
+
#
|
|
12
|
+
# Foundation:
|
|
13
|
+
# RKSeal::Errors -- error hierarchy for fail-fast behavior
|
|
14
|
+
# (errors.rb)
|
|
15
|
+
#
|
|
16
|
+
# Domain:
|
|
17
|
+
# RKSeal::Secret -- build/parse the k8s Secret manifest, base64
|
|
18
|
+
# encode/decode, strip runtime metadata, convert
|
|
19
|
+
# between cluster JSON and the edit buffer
|
|
20
|
+
# (secret.rb)
|
|
21
|
+
# RKSeal::SealedSecret -- read a local SealedSecret's keys/scope/type and
|
|
22
|
+
# render the redacted `edit --local` buffer
|
|
23
|
+
# (sealed_secret.rb)
|
|
24
|
+
#
|
|
25
|
+
# External-binary adapters (shell out; stubbed in unit tests):
|
|
26
|
+
# RKSeal::Kubeseal -- adapter over `kubeseal` (seal/fetch_cert/
|
|
27
|
+
# merge_into/re_encrypt); owns scope/cert/
|
|
28
|
+
# controller flags (kubeseal.rb)
|
|
29
|
+
# RKSeal::Kubectl -- adapter over `kubectl` (get_secret/apply/
|
|
30
|
+
# current_context) (kubectl.rb)
|
|
31
|
+
# RKSeal::Editor -- launch `$EDITOR` on a buffer, return edited
|
|
32
|
+
# content (editor.rb)
|
|
33
|
+
#
|
|
34
|
+
# Environment / safety:
|
|
35
|
+
# RKSeal::SecureWorkspace -- per-OS RAM-backed scratch path with guaranteed
|
|
36
|
+
# teardown (secure_workspace.rb)
|
|
37
|
+
# RKSeal::ContextGuard -- enforce which kube context deploys are allowed
|
|
38
|
+
# against (context_guard.rb)
|
|
39
|
+
#
|
|
40
|
+
# Orchestration:
|
|
41
|
+
# RKSeal::Commands::Result -- shared command-outcome value object
|
|
42
|
+
# (commands/result.rb)
|
|
43
|
+
# RKSeal::Commands::Create -- the `create` flow (commands/create.rb)
|
|
44
|
+
# RKSeal::Commands::Edit -- the `edit` flow (commands/edit.rb)
|
|
45
|
+
# RKSeal::Commands::EditLocal -- the offline `edit --local` flow
|
|
46
|
+
# (commands/edit_local.rb)
|
|
47
|
+
# RKSeal::Commands::Reencrypt -- the `reencrypt` flow (commands/reencrypt.rb)
|
|
48
|
+
# RKSeal::Commands::Validate -- the `validate` flow (commands/validate.rb)
|
|
49
|
+
# RKSeal::Commands::View -- the `view` flow (commands/view.rb)
|
|
50
|
+
# RKSeal::Commands::List -- the `list` flow (commands/list.rb)
|
|
51
|
+
# RKSeal::CLI -- Thor command parsing & dispatch (cli.rb)
|
|
52
|
+
#
|
|
53
|
+
# == Require layout
|
|
54
|
+
#
|
|
55
|
+
# Requires are listed explicitly and ordered from leaves to roots so the
|
|
56
|
+
# dependency graph loads without surprises (errors and the domain model first,
|
|
57
|
+
# adapters and environment helpers next, orchestration last). Each layer lives
|
|
58
|
+
# in exactly one file, so the three implementation agents edit disjoint files
|
|
59
|
+
# and never need to co-edit this one. Adding a brand-new layer is the only
|
|
60
|
+
# reason to touch this file again.
|
|
61
|
+
module RKSeal
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Foundation.
|
|
65
|
+
require_relative "rkseal/errors"
|
|
66
|
+
|
|
67
|
+
# Domain models.
|
|
68
|
+
require_relative "rkseal/secret"
|
|
69
|
+
require_relative "rkseal/sealed_secret"
|
|
70
|
+
|
|
71
|
+
# External-binary adapters.
|
|
72
|
+
require_relative "rkseal/kubeseal"
|
|
73
|
+
require_relative "rkseal/kubectl"
|
|
74
|
+
require_relative "rkseal/editor"
|
|
75
|
+
|
|
76
|
+
# Environment / safety helpers.
|
|
77
|
+
require_relative "rkseal/secure_workspace"
|
|
78
|
+
require_relative "rkseal/context_guard"
|
|
79
|
+
|
|
80
|
+
# Orchestration (commands depend on everything above; CLI depends on commands).
|
|
81
|
+
require_relative "rkseal/commands/result"
|
|
82
|
+
require_relative "rkseal/commands/create"
|
|
83
|
+
require_relative "rkseal/commands/edit"
|
|
84
|
+
require_relative "rkseal/commands/edit_local"
|
|
85
|
+
require_relative "rkseal/commands/reencrypt"
|
|
86
|
+
require_relative "rkseal/commands/validate"
|
|
87
|
+
require_relative "rkseal/commands/view"
|
|
88
|
+
require_relative "rkseal/commands/list"
|
|
89
|
+
require_relative "rkseal/cli"
|
data/rkseal.gemspec
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/rkseal/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "rkseal"
|
|
7
|
+
spec.version = RKSeal::VERSION
|
|
8
|
+
spec.authors = ["Piotr Wojcieszonek"]
|
|
9
|
+
spec.email = ["piotr@wojcieszonek.pl"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Interactively create and edit Kubernetes SealedSecrets via $EDITOR."
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
rkseal wraps the kubeseal CLI to author and edit Kubernetes SealedSecrets.
|
|
14
|
+
The plaintext Secret manifest is edited in $EDITOR on a RAM-backed buffer
|
|
15
|
+
that never touches persistent disk, then sealed with the controller's public key.
|
|
16
|
+
Deploys to the cluster are explicit opt-in only and guarded by the active kube context.
|
|
17
|
+
DESC
|
|
18
|
+
spec.homepage = "https://github.com/pwojcieszonek/rkseal"
|
|
19
|
+
spec.license = "MIT"
|
|
20
|
+
spec.required_ruby_version = ">= 4.0.0"
|
|
21
|
+
|
|
22
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
23
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
24
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
25
|
+
|
|
26
|
+
# Files are listed explicitly to avoid depending on a git index (the gem may be
|
|
27
|
+
# built from a non-git export). Keep this in sync with the project layout.
|
|
28
|
+
spec.files = Dir[
|
|
29
|
+
"lib/**/*.rb",
|
|
30
|
+
"exe/*",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE.txt",
|
|
33
|
+
"rkseal.gemspec"
|
|
34
|
+
]
|
|
35
|
+
spec.bindir = "exe"
|
|
36
|
+
spec.executables = ["rkseal"]
|
|
37
|
+
spec.require_paths = ["lib"]
|
|
38
|
+
|
|
39
|
+
# Runtime dependency: the CLI framework.
|
|
40
|
+
spec.add_dependency "thor", "~> 1.3"
|
|
41
|
+
|
|
42
|
+
# base64 left the default gems in Ruby 3.4; on 4.0.2 it is no longer on the
|
|
43
|
+
# load path under `bundle exec` unless declared. RKSeal::Secret requires it
|
|
44
|
+
# for the base64 <-> plaintext data conversions, so it must be a runtime dep.
|
|
45
|
+
# (open3, also required by the adapters, is still a bundled default gem on
|
|
46
|
+
# 4.0.2 and loads cleanly under bundle exec, so it is intentionally not pinned.)
|
|
47
|
+
spec.add_dependency "base64", "~> 0.2"
|
|
48
|
+
|
|
49
|
+
# Development dependencies. Versions are intentionally left as compatible ranges
|
|
50
|
+
# rather than hard pins until the toolchain is proven on Ruby 4.0.2.
|
|
51
|
+
spec.add_development_dependency "rspec", "~> 3.13"
|
|
52
|
+
spec.add_development_dependency "rubocop", "~> 1.60"
|
|
53
|
+
end
|