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