rkseal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module RKSeal
7
+ module Commands
8
+ # Orchestrates the `rkseal list [namespace]` flow.
9
+ #
10
+ # Lists the SealedSecret CRD objects in the cluster (all namespaces, or a
11
+ # single one) as a kubectl-style table. Strictly read-only and metadata-only:
12
+ # it prints solely each object's namespace, name, derived scope, and age --
13
+ # NEVER any part of `spec.encryptedData` (not even its keys). No editor, no
14
+ # RAM workspace, no file is written.
15
+ #
16
+ # @example all namespaces
17
+ # puts RKSeal::Commands::List.new.call
18
+ # @example one namespace
19
+ # puts RKSeal::Commands::List.new(namespace: "app").call
20
+ class List
21
+ # @return [String, nil] the namespace filter, or nil for all namespaces.
22
+ attr_reader :namespace
23
+
24
+ # Column headers, in display order.
25
+ HEADERS = %w[NAMESPACE NAME SCOPE AGE].freeze
26
+
27
+ # Scope symbol -> the dashed display label shown in the SCOPE column.
28
+ SCOPE_LABELS = {
29
+ strict: "strict",
30
+ namespace_wide: "namespace-wide",
31
+ cluster_wide: "cluster-wide"
32
+ }.freeze
33
+
34
+ # @param namespace [String, nil] limit to this namespace; nil lists all.
35
+ # @param kubectl [RKSeal::Kubectl] cluster adapter (read only).
36
+ # @param now [Time] clock used to compute AGE (injectable for tests).
37
+ def initialize(namespace: nil, kubectl: Kubectl.new, now: Time.now)
38
+ @namespace = namespace
39
+ @kubectl = kubectl
40
+ @now = now
41
+ end
42
+
43
+ # Run the list flow: read the SealedSecrets and render the table.
44
+ #
45
+ # Side effects: a single read-only `kubectl get sealedsecret`. No editor,
46
+ # no workspace, no file write.
47
+ #
48
+ # @return [String] the table (or a friendly empty-list message) to print.
49
+ # @raise [RKSeal::CommandError] kubectl failed.
50
+ def call
51
+ @kubectl.ensure_available!
52
+ items = parse_items(@kubectl.list_sealedsecrets(namespace: @namespace))
53
+ return empty_message if items.empty?
54
+
55
+ render_table(items.map { |item| row_for(item) })
56
+ end
57
+
58
+ private
59
+
60
+ def parse_items(json)
61
+ doc = JSON.parse(json)
62
+ items = doc.is_a?(Hash) ? doc["items"] : nil
63
+ items.is_a?(Array) ? items : []
64
+ rescue JSON::ParserError => e
65
+ raise CommandError.new("kubectl did not return valid JSON: #{e.message}",
66
+ command: "kubectl get sealedsecret")
67
+ end
68
+
69
+ # Build a single table row from one SealedSecret. Reads ONLY metadata --
70
+ # `spec` is never touched, so no encrypted material can leak.
71
+ def row_for(item)
72
+ metadata = item["metadata"] || {}
73
+ [
74
+ metadata["namespace"].to_s,
75
+ metadata["name"].to_s,
76
+ SCOPE_LABELS.fetch(Secret.scope_from_sealed_json(item)),
77
+ age_for(metadata["creationTimestamp"])
78
+ ]
79
+ end
80
+
81
+ # kubectl-style compact age (e.g. "3d", "5h", "2m", "10s") from an RFC3339
82
+ # creationTimestamp. Unknown/unparseable -> "<unknown>".
83
+ def age_for(timestamp)
84
+ return "<unknown>" if timestamp.nil? || timestamp.to_s.empty?
85
+
86
+ seconds = (@now - Time.parse(timestamp.to_s)).to_i
87
+ humanize_age(seconds)
88
+ rescue ArgumentError
89
+ "<unknown>"
90
+ end
91
+
92
+ def humanize_age(seconds)
93
+ seconds = 0 if seconds.negative?
94
+ case seconds
95
+ when 0...60 then "#{seconds}s"
96
+ when 60...3600 then "#{seconds / 60}m"
97
+ when 3600...86_400 then "#{seconds / 3600}h"
98
+ else "#{seconds / 86_400}d"
99
+ end
100
+ end
101
+
102
+ # Render rows as a left-aligned, space-padded table with a header line,
103
+ # matching kubectl's `get` output style.
104
+ def render_table(rows)
105
+ widths = column_widths(rows)
106
+ [HEADERS, *rows].map { |row| format_row(row, widths) }.join("\n")
107
+ end
108
+
109
+ def column_widths(rows)
110
+ HEADERS.each_index.map do |col|
111
+ [HEADERS[col], *rows.map { |row| row[col] }].map(&:length).max
112
+ end
113
+ end
114
+
115
+ # Pad every cell but the last to its column width (trailing column is left
116
+ # un-padded so there is no trailing whitespace), joined by three spaces.
117
+ def format_row(row, widths)
118
+ row.each_with_index.map do |cell, col|
119
+ col == row.length - 1 ? cell : cell.ljust(widths[col])
120
+ end.join(" ").rstrip
121
+ end
122
+
123
+ def empty_message
124
+ return "No SealedSecrets found." if @namespace.nil?
125
+
126
+ "No SealedSecrets found in namespace #{@namespace.inspect}."
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module RKSeal
6
+ module Commands
7
+ # Orchestrates the `rkseal reencrypt <namespace> <secret-name>` flow.
8
+ #
9
+ # Re-encrypts an existing SealedSecret onto the controller's newest sealing
10
+ # key without ever exposing plaintext (`kubeseal --re-encrypt`). The input is
11
+ # the SealedSecret itself, not the unsealed Secret -- so unlike `edit`, this
12
+ # flow never touches `$EDITOR`, a RAM workspace, or cluster Secret values.
13
+ #
14
+ # Input resolution, in order:
15
+ # 1. the local `<name>.yaml` in the output directory (a previous run);
16
+ # 2. otherwise the live SealedSecret via {RKSeal::Kubectl#get_sealedsecret}.
17
+ # If neither exists, fail fast and point the user at `create`.
18
+ #
19
+ # Deploy is opt-in and identical to `edit`: {RKSeal::ContextGuard} surfaces
20
+ # the active context and confirms before `kubectl apply` (skipped with
21
+ # `assume_yes`).
22
+ #
23
+ # @example refresh to the newest key, write only
24
+ # RKSeal::Commands::Reencrypt.new(namespace: "app", name: "db").call
25
+ class Reencrypt
26
+ # @return [String]
27
+ attr_reader :namespace
28
+ # @return [String]
29
+ attr_reader :name
30
+ # @return [Boolean]
31
+ attr_reader :deploy
32
+
33
+ # @param namespace [String] target namespace (positional CLI arg).
34
+ # @param name [String] Secret name (positional CLI arg).
35
+ # @param deploy [Boolean] opt-in deploy after writing; defaults to false.
36
+ # @param assume_yes [Boolean] skip the deploy confirmation (with deploy:).
37
+ # @param kubectl [RKSeal::Kubectl] cluster adapter (read + apply).
38
+ # @param kubeseal [RKSeal::Kubeseal] sealing adapter (re-encrypt).
39
+ # @param context_guard [RKSeal::ContextGuard, nil] deploy gatekeeper; built
40
+ # from kubectl + prompt when nil and a deploy is requested.
41
+ # @param prompt [Thor::Shell::Basic] shell for the deploy confirmation.
42
+ # @param output_dir [String] directory the manifest is read from / written
43
+ # to (CWD).
44
+ def initialize(namespace:, name:, deploy: false, assume_yes: false,
45
+ kubectl: Kubectl.new, kubeseal: Kubeseal.new,
46
+ context_guard: nil, prompt: Thor::Shell::Basic.new,
47
+ output_dir: Dir.pwd)
48
+ @namespace = namespace
49
+ @name = name
50
+ @deploy = deploy
51
+ @assume_yes = assume_yes
52
+ @kubectl = kubectl
53
+ @kubeseal = kubeseal
54
+ @context_guard = context_guard
55
+ @prompt = prompt
56
+ @output_dir = output_dir
57
+ end
58
+
59
+ # Run the re-encrypt flow end to end.
60
+ #
61
+ # Side effects: reads the local `<name>.yaml` or the cluster SealedSecret;
62
+ # shells out to `kubeseal --re-encrypt`; writes `<name>.yaml`; and, only
63
+ # when {#deploy} is true and the operator confirms, runs `kubectl apply`.
64
+ #
65
+ # @return [RKSeal::Commands::Result] outcome (written path, deployed?).
66
+ # @raise [RKSeal::NotFoundError] no local file and the SealedSecret is
67
+ # absent from the cluster (message points at `create`).
68
+ # @raise [RKSeal::CommandError] kubectl/kubeseal failed.
69
+ def call
70
+ @kubectl.ensure_available!
71
+ @kubeseal.ensure_available!
72
+
73
+ reencrypted = @kubeseal.re_encrypt(source_sealed_yaml)
74
+ path = write_manifest(reencrypted)
75
+ deployed = @deploy && deploy_confirmed?
76
+ @kubectl.apply(file: path) if deployed
77
+ Result.new(secret_name: @name, namespace: @namespace, output_path: path, deployed: deployed)
78
+ end
79
+
80
+ private
81
+
82
+ # The SealedSecret to re-encrypt: prefer the local file, fall back to the
83
+ # cluster. A missing cluster SealedSecret surfaces as NotFoundError from the
84
+ # adapter, which we re-message to point at `create`.
85
+ def source_sealed_yaml
86
+ local = manifest_path
87
+ return File.read(local) if File.file?(local)
88
+
89
+ @kubectl.get_sealedsecret(name: @name, namespace: @namespace)
90
+ rescue NotFoundError
91
+ raise NotFoundError,
92
+ "No local #{@name}.yaml and no SealedSecret #{@name.inspect} in " \
93
+ "namespace #{@namespace.inspect}. Run `rkseal create` first."
94
+ end
95
+
96
+ def write_manifest(sealed_yaml)
97
+ File.write(manifest_path, sealed_yaml)
98
+ File.expand_path(manifest_path)
99
+ end
100
+
101
+ def manifest_path
102
+ File.join(@output_dir, "#{@name}.yaml")
103
+ end
104
+
105
+ # Whether to proceed with the deploy: --yes skips the prompt, otherwise the
106
+ # ContextGuard surfaces the active context and confirms.
107
+ def deploy_confirmed?
108
+ return true if @assume_yes
109
+
110
+ context_guard.confirm_deploy(secret_name: @name, namespace: @namespace)
111
+ end
112
+
113
+ def context_guard
114
+ @context_guard ||= ContextGuard.new(kubectl: @kubectl, prompt: @prompt)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RKSeal
4
+ module Commands
5
+ # Immutable value object describing the outcome of a command flow, returned
6
+ # to the CLI so it can print a result without reaching into flow internals.
7
+ #
8
+ # Shared by {RKSeal::Commands::Create} and {RKSeal::Commands::Edit}; it lives
9
+ # in its own file so neither command file owns the other's return type.
10
+ #
11
+ # @!attribute [r] secret_name
12
+ # @return [String] the Secret name that was sealed.
13
+ # @!attribute [r] namespace
14
+ # @return [String] the namespace it was sealed for.
15
+ # @!attribute [r] output_path
16
+ # @return [String] absolute path of the written `<name>.yaml`.
17
+ # @!attribute [r] deployed
18
+ # @return [Boolean] whether the manifest was applied to the cluster
19
+ # (always false for `create`).
20
+ Result = Data.define(:secret_name, :namespace, :output_path, :deployed)
21
+ end
22
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RKSeal
4
+ module Commands
5
+ # Orchestrates the `rkseal validate <namespace> <secret-name>` flow (and its
6
+ # `--file <path>` variant).
7
+ #
8
+ # Asks the controller whether a SealedSecret is well-formed and decryptable
9
+ # for its target, via `kubeseal --validate`. It does not decrypt or expose
10
+ # anything; it is a pre-flight check you can run before committing or
11
+ # applying. No editor, no workspace, no cluster Secret read, no file write.
12
+ #
13
+ # Input is either the local `<name>.yaml` in the output directory, or an
14
+ # explicit file path (`file:`), which takes precedence and lets you validate
15
+ # any SealedSecret manifest regardless of name.
16
+ #
17
+ # @example validate the local <name>.yaml
18
+ # RKSeal::Commands::Validate.new(namespace: "app", name: "db").call
19
+ # @example validate an arbitrary file
20
+ # RKSeal::Commands::Validate.new(file: "out/db.yaml").call
21
+ class Validate
22
+ # @return [String, nil]
23
+ attr_reader :namespace
24
+ # @return [String, nil]
25
+ attr_reader :name
26
+ # @return [String, nil] explicit file path to validate, if given.
27
+ attr_reader :file
28
+
29
+ # @param namespace [String, nil] target namespace (positional CLI arg);
30
+ # may be nil when `file:` is used.
31
+ # @param name [String, nil] Secret name (positional CLI arg); the
32
+ # `<name>.yaml` stem. May be nil when `file:` is used.
33
+ # @param file [String, nil] explicit SealedSecret file path; overrides the
34
+ # `<name>.yaml` lookup when present.
35
+ # @param kubeseal [RKSeal::Kubeseal] sealing adapter (validate).
36
+ # @param output_dir [String] directory the `<name>.yaml` is read from (CWD).
37
+ def initialize(namespace: nil, name: nil, file: nil,
38
+ kubeseal: Kubeseal.new, output_dir: Dir.pwd)
39
+ @namespace = namespace
40
+ @name = name
41
+ @file = file
42
+ @kubeseal = kubeseal
43
+ @output_dir = output_dir
44
+ end
45
+
46
+ # Run the validation.
47
+ #
48
+ # @return [String] the validated path (so the CLI can name it in the
49
+ # "valid" message).
50
+ # @raise [RKSeal::InvalidInputError] the target file does not exist.
51
+ # @raise [RKSeal::ValidationError] the controller rejected the SealedSecret
52
+ # (the message carries the reason; the CLI prints it and exits non-zero).
53
+ # @raise [RKSeal::CommandError] the validate operation itself failed.
54
+ def call
55
+ @kubeseal.ensure_available!
56
+ path = target_path
57
+ @kubeseal.validate(read_sealed(path))
58
+ path
59
+ end
60
+
61
+ private
62
+
63
+ # The file to validate: an explicit --file wins; otherwise <name>.yaml in
64
+ # the output directory.
65
+ def target_path
66
+ return File.expand_path(@file) if @file
67
+
68
+ File.expand_path(File.join(@output_dir, "#{@name}.yaml"))
69
+ end
70
+
71
+ def read_sealed(path)
72
+ File.read(path)
73
+ rescue SystemCallError => e
74
+ raise InvalidInputError, "cannot read SealedSecret file #{path.inspect}: #{e.message}"
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RKSeal
4
+ module Commands
5
+ # Orchestrates the `rkseal view <namespace> <secret-name>` flow.
6
+ #
7
+ # A strictly read-only inspector: it reads the live unsealed Secret from the
8
+ # cluster (the only source of current values) and renders the full Secret
9
+ # manifest as a string for the CLI to print. It NEVER opens `$EDITOR`, never
10
+ # provisions a {RKSeal::SecureWorkspace}, and never writes a file.
11
+ #
12
+ # By default `data` is shown as raw base64 (verbatim, consistent with `edit`).
13
+ # With `reveal: true` the values are decoded and presented as plaintext
14
+ # `stringData` -- an explicit opt-in for the operator who wants to read the
15
+ # cleartext.
16
+ #
17
+ # If the Secret is absent from the cluster, the flow fails fast and points the
18
+ # user at `create`.
19
+ #
20
+ # @example show base64 (default)
21
+ # puts RKSeal::Commands::View.new(namespace: "app", name: "db").call
22
+ # @example reveal plaintext
23
+ # puts RKSeal::Commands::View.new(namespace: "app", name: "db", reveal: true).call
24
+ class View
25
+ # @return [String]
26
+ attr_reader :namespace
27
+ # @return [String]
28
+ attr_reader :name
29
+ # @return [Boolean] whether to decode data to plaintext stringData.
30
+ attr_reader :reveal
31
+
32
+ # @param namespace [String] target namespace (positional CLI arg).
33
+ # @param name [String] Secret name (positional CLI arg).
34
+ # @param reveal [Boolean] decode data to plaintext; defaults to false.
35
+ # @param kubectl [RKSeal::Kubectl] cluster adapter (read only).
36
+ def initialize(namespace:, name:, reveal: false, kubectl: Kubectl.new)
37
+ @namespace = namespace
38
+ @name = name
39
+ @reveal = reveal
40
+ @kubectl = kubectl
41
+ end
42
+
43
+ # Run the view flow: read the cluster Secret and render it.
44
+ #
45
+ # Side effects: a single read-only `kubectl get secret`. No editor, no
46
+ # workspace, no file write.
47
+ #
48
+ # @return [String] the full Secret manifest YAML to print.
49
+ # @raise [RKSeal::NotFoundError] the Secret is absent (points at `create`).
50
+ # @raise [RKSeal::CommandError] kubectl failed.
51
+ def call
52
+ @kubectl.ensure_available!
53
+ secret = Secret.from_kubectl_json(@kubectl.get_secret(name: @name, namespace: @namespace))
54
+ secret.to_buffer(reveal: @reveal)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module RKSeal
6
+ # Gatekeeper for the one genuinely dangerous operation: deploying to a
7
+ # cluster. Applying a SealedSecret to the wrong context can clobber another
8
+ # environment, so a deploy must be explicitly confirmed by the operator.
9
+ #
10
+ # rkseal always operates on the *current* kube context -- there is no
11
+ # allow-list. The guard's job is narrow: surface the active context and ask
12
+ # the operator to confirm before {RKSeal::Kubectl#apply} runs. Deploy is never
13
+ # the default for `edit`; this class enforces the "explicit + confirmed"
14
+ # requirement via an interactive yes/no prompt that defaults to No.
15
+ #
16
+ # This class does NOT shell out itself -- it delegates to the injected
17
+ # {RKSeal::Kubectl} for the context name and to a Thor shell for the prompt.
18
+ class ContextGuard
19
+ # @param kubectl [RKSeal::Kubectl] adapter used to read the active context.
20
+ # @param prompt [Thor::Shell::Basic] shell used for the interactive
21
+ # confirmation; injected so specs can drive #yes? without real stdin.
22
+ def initialize(kubectl:, prompt: Thor::Shell::Basic.new)
23
+ @kubectl = kubectl
24
+ @prompt = prompt
25
+ end
26
+
27
+ # The current kube context, as reported by kubectl.
28
+ #
29
+ # @return [String]
30
+ # @raise [RKSeal::CommandError] if kubectl cannot report a context.
31
+ def current_context
32
+ @kubectl.current_context
33
+ end
34
+
35
+ # Surface the active context and ask the operator to confirm the deploy.
36
+ # Called immediately before {RKSeal::Kubectl#apply}; the apply happens only
37
+ # when this returns true. The prompt defaults to No, so an empty answer (or a
38
+ # non-interactive run) declines.
39
+ #
40
+ # @param secret_name [String] the SealedSecret's name, for the prompt.
41
+ # @param namespace [String] the target namespace, for the prompt.
42
+ # @return [Boolean] whether the operator approved the deploy.
43
+ # @raise [RKSeal::CommandError] if kubectl cannot report a context.
44
+ #
45
+ # rubocop:disable Naming/PredicateMethod -- this is an action ("ask and
46
+ # apply-or-not"), not a query; its name is a frozen part of the public API
47
+ # that the command layer codes against, so it cannot take a `?` suffix.
48
+ def confirm_deploy(secret_name:, namespace:)
49
+ context = current_context
50
+ @prompt.yes?(
51
+ "Deploy #{secret_name.inspect} (namespace #{namespace.inspect}) " \
52
+ "to context #{context.inspect}? [y/N]"
53
+ )
54
+ end
55
+ # rubocop:enable Naming/PredicateMethod
56
+ end
57
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module RKSeal
6
+ # Launches the user's `$EDITOR` on a buffer and returns the edited content.
7
+ #
8
+ # The editor never sees a persistent path: the caller supplies a RAM-backed
9
+ # path from {RKSeal::SecureWorkspace}, this class seeds it with the initial
10
+ # content, spawns `$EDITOR <path>`, blocks until the editor exits, then reads
11
+ # the result back. It does not create, choose, or destroy the path -- that is
12
+ # the workspace's job -- which keeps the "never on disk" guarantee in one
13
+ # place.
14
+ class Editor
15
+ # Environment variables consulted, in priority order, to find the editor
16
+ # command when none is injected explicitly.
17
+ ENV_KEYS = %w[VISUAL EDITOR].freeze
18
+
19
+ # vim-family editors persist buffer contents to files OUTSIDE the RAM-backed
20
+ # path -- a swap file and the viminfo register/mark history -- which would
21
+ # leak plaintext to persistent disk despite the workspace guarantee. Each
22
+ # flag below suppresses one such sink (`-n` disables the swap file; `-i NONE`
23
+ # disables viminfo). Keyed by the flag we test for, so an operator who has
24
+ # already set it keeps their choice (we never duplicate it).
25
+ VIM_HARDENING = { "-n" => ["-n"], "-i" => ["-i", "NONE"] }.freeze
26
+
27
+ # @param command [String, nil] explicit editor command; when nil it is
28
+ # resolved from {ENV_KEYS} at edit time.
29
+ def initialize(command: nil)
30
+ @command = command
31
+ end
32
+
33
+ # Seed `path` with `content`, open it in `$EDITOR`, wait for the editor to
34
+ # exit, and return the (possibly modified) file contents.
35
+ #
36
+ # Side effects: writes `content` to `path` and re-reads it; spawns and waits
37
+ # on the editor process. Does not delete `path`.
38
+ #
39
+ # @param content [String] initial buffer contents (e.g. a seed manifest).
40
+ # @param path [String] RAM-backed file path to edit on
41
+ # (from {RKSeal::SecureWorkspace}).
42
+ # @return [String] the buffer contents after the editor exits.
43
+ # @raise [RKSeal::EditorError] if no editor is configured, the editor cannot
44
+ # be launched, or it exits signaling the user aborted.
45
+ def edit(content:, path:)
46
+ # Resolve BEFORE writing any secret: if no editor is available we must
47
+ # fail fast, before the plaintext ever lands in the buffer.
48
+ argv = editor_argv
49
+
50
+ File.write(path, content)
51
+ launch(argv, path)
52
+ File.read(path)
53
+ end
54
+
55
+ # Resolve the editor command that would be used (injected value or the first
56
+ # set variable among {ENV_KEYS}). Exposed so a flow can fail fast *before*
57
+ # provisioning a workspace if no editor is available.
58
+ #
59
+ # @return [String] the resolved editor command.
60
+ # @raise [RKSeal::EditorError] if none is set.
61
+ def resolve_command
62
+ candidate = @command || env_command
63
+ if candidate.nil? || candidate.strip.empty?
64
+ raise EditorError,
65
+ "no editor configured: set $VISUAL or $EDITOR (e.g. `export EDITOR=vim`)"
66
+ end
67
+
68
+ candidate
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :command
74
+
75
+ # First non-empty value among {ENV_KEYS}, honouring their priority order.
76
+ def env_command
77
+ ENV_KEYS.filter_map { |key| ENV.fetch(key, nil) }.find { |value| !value.strip.empty? }
78
+ end
79
+
80
+ # Split the resolved command into an argv array so editors carrying flags
81
+ # (`code --wait`, `subl -w`, `emacsclient -nw`) launch correctly without a
82
+ # shell. The file path is appended as a separate, un-split element so a path
83
+ # is never re-parsed for metacharacters.
84
+ #
85
+ # @return [Array<String>] the editor command split into argv tokens.
86
+ # @raise [RKSeal::EditorError] if the command does not resolve to any token.
87
+ def editor_argv
88
+ tokens = Shellwords.split(resolve_command)
89
+ raise EditorError, "editor command resolved to nothing" if tokens.empty?
90
+
91
+ harden_side_files(tokens)
92
+ end
93
+
94
+ # For the vim family, inject the flags from {VIM_HARDENING} the operator has
95
+ # not already set, so swap/viminfo never write the plaintext to disk. The
96
+ # flags go right after the command and before any user arguments (and the
97
+ # path, appended in {#launch}), which is where vim expects its options.
98
+ # Other editors pass through untouched.
99
+ def harden_side_files(tokens)
100
+ command, *rest = tokens
101
+ return tokens unless vim_family?(command)
102
+
103
+ flags = VIM_HARDENING.except(*rest).values.flatten
104
+ [command, *flags, *rest]
105
+ end
106
+
107
+ # Whether the editor binary is a vim variant (vim, vi, nvim, gvim, mvim, and
108
+ # suffixed builds like vim.basic/vimx). Matched on the basename so a full
109
+ # path still resolves.
110
+ def vim_family?(command)
111
+ name = File.basename(command)
112
+ name.start_with?("vim", "nvim") || %w[vi gvim mvim].include?(name)
113
+ end
114
+
115
+ # Spawn the editor (no shell), wait for it, and translate any non-clean exit
116
+ # into an {EditorError}. A non-zero status or a termination by signal both
117
+ # mean "the user did not save a usable result" -- we treat them as aborts.
118
+ def launch(argv, path)
119
+ pid = Process.spawn(*argv, path)
120
+ _, status = Process.wait2(pid)
121
+ raise_on_failure(status, argv.first)
122
+ rescue Errno::ENOENT
123
+ raise EditorError, "could not launch editor: command not found (#{argv.first.inspect})"
124
+ rescue SystemCallError => e
125
+ raise EditorError, "could not launch editor #{argv.first.inspect}: #{e.message}"
126
+ end
127
+
128
+ def raise_on_failure(status, command_name)
129
+ return if status.success?
130
+
131
+ if status.signaled?
132
+ raise EditorError,
133
+ "editor #{command_name.inspect} was killed by signal #{status.termsig}; aborting"
134
+ end
135
+
136
+ raise EditorError,
137
+ "editor #{command_name.inspect} exited with status #{status.exitstatus}; " \
138
+ "treating as aborted edit"
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RKSeal
4
+ # Error hierarchy for rkseal's fail-fast behavior.
5
+ #
6
+ # Every error rkseal raises on purpose descends from {RKSeal::Error}, so the
7
+ # CLI entry point can rescue that one base class, print a single clean line,
8
+ # and exit non-zero -- without swallowing genuinely unexpected exceptions
9
+ # (those bubble up with a backtrace, as they should).
10
+ #
11
+ # Guidelines for raisers:
12
+ # - Pick the most specific subclass that fits.
13
+ # - Put a human-actionable message in the exception (what went wrong + what to
14
+ # do about it). Never put secret *values* in a message.
15
+ # - Do not rescue-and-wrap unless you are adding context; prefer to let a
16
+ # specific error propagate.
17
+
18
+ # Base class for all errors deliberately raised by rkseal.
19
+ class Error < StandardError; end
20
+
21
+ # A required external binary (`kubeseal` or `kubectl`) was not found on PATH,
22
+ # or is not executable. Message should name the missing tool.
23
+ class DependencyMissingError < Error; end
24
+
25
+ # An external command ran but exited non-zero. Carries the command label,
26
+ # exit status, and captured stderr so callers can surface a useful message
27
+ # without re-deriving them.
28
+ class CommandError < Error
29
+ # @return [String] human label for the command (e.g. "kubeseal seal").
30
+ attr_reader :command
31
+ # @return [Integer, nil] process exit status, if one was produced.
32
+ attr_reader :status
33
+ # @return [String, nil] captured stderr (already scrubbed of secrets).
34
+ attr_reader :stderr
35
+
36
+ # @param message [String] human-readable summary.
37
+ # @param command [String, nil] command label.
38
+ # @param status [Integer, nil] exit status.
39
+ # @param stderr [String, nil] captured stderr.
40
+ def initialize(message = nil, command: nil, status: nil, stderr: nil)
41
+ @command = command
42
+ @status = status
43
+ @stderr = stderr
44
+ super(message)
45
+ end
46
+ end
47
+
48
+ # The thing the user asked to operate on does not exist where it must.
49
+ # Notably: `edit` was asked for a Secret that is absent from the cluster
50
+ # (the message must point the user at `rkseal create`).
51
+ class NotFoundError < Error; end
52
+
53
+ # The user's input is unusable: an empty edit buffer, malformed YAML from the
54
+ # editor, a manifest that is not a valid Kubernetes Secret, an unknown scope,
55
+ # a `--from-file` path that does not exist, etc.
56
+ class InvalidInputError < Error; end
57
+
58
+ # A SealedSecret was checked against the controller and cannot be decrypted
59
+ # (`kubeseal --validate` rejected it -- e.g. wrong scope, tampered ciphertext,
60
+ # or sealed for a different name/namespace). Distinct from {CommandError},
61
+ # which covers operational failures (missing binary, unreachable cluster) that
62
+ # say nothing about the SealedSecret's validity. The message carries the
63
+ # controller's stated reason.
64
+ class ValidationError < Error; end
65
+
66
+ # Something went wrong provisioning, mounting, or tearing down the RAM-backed
67
+ # workspace (see {RKSeal::SecureWorkspace}). Treated as fatal: rkseal must not
68
+ # silently fall back to on-disk scratch space.
69
+ class WorkspaceError < Error; end
70
+
71
+ # `$EDITOR` is unset/blank, could not be launched, or exited in a way that
72
+ # signals the user aborted the edit.
73
+ class EditorError < Error; end
74
+ end