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,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module RKSeal
|
|
6
|
+
# Domain model for the *SealedSecret* resource -- the encrypted, on-disk
|
|
7
|
+
# counterpart of {RKSeal::Secret}.
|
|
8
|
+
#
|
|
9
|
+
# Unlike a Secret, a SealedSecret cannot be decrypted client-side: its
|
|
10
|
+
# `spec.encryptedData` values are opaque ciphertext. What *is* readable is the
|
|
11
|
+
# set of data **keys** (the map keys are plaintext), the sealing **scope**
|
|
12
|
+
# (a metadata annotation), and the **template** `type`. That readable surface
|
|
13
|
+
# is exactly what powers the offline `edit --local` flow: rkseal can show the
|
|
14
|
+
# user which keys exist and let them keep / replace / add / remove keys
|
|
15
|
+
# without ever seeing the current values.
|
|
16
|
+
#
|
|
17
|
+
# This model is the single place that knows how to:
|
|
18
|
+
#
|
|
19
|
+
# - parse a local `<name>.yaml` SealedSecret into that readable surface;
|
|
20
|
+
# - render a *redacted* editor buffer -- a Secret manifest in which every
|
|
21
|
+
# existing key is shown under `stringData` as {REDACTED_PLACEHOLDER}, so a
|
|
22
|
+
# value left untouched means "keep the current ciphertext".
|
|
23
|
+
#
|
|
24
|
+
# No method here shells out, touches the cluster, or decrypts anything; it is
|
|
25
|
+
# pure data transformation and trivially unit-testable.
|
|
26
|
+
class SealedSecret
|
|
27
|
+
# apiVersion/kind this model represents.
|
|
28
|
+
API_VERSION = "bitnami.com/v1alpha1"
|
|
29
|
+
KIND = "SealedSecret"
|
|
30
|
+
|
|
31
|
+
# Placeholder shown for every existing key in the `edit --local` buffer.
|
|
32
|
+
# Because the ciphertext cannot be decrypted, the current value is never
|
|
33
|
+
# revealed; leaving this token in place means "keep the sealed value as-is".
|
|
34
|
+
REDACTED_PLACEHOLDER = "<redacted>"
|
|
35
|
+
|
|
36
|
+
# @return [String] the SealedSecret name.
|
|
37
|
+
attr_reader :name
|
|
38
|
+
# @return [String, nil] the namespace.
|
|
39
|
+
attr_reader :namespace
|
|
40
|
+
# @return [Symbol] sealing scope (:strict, :namespace_wide, :cluster_wide),
|
|
41
|
+
# derived from the metadata annotation (see {RKSeal::Secret.scope_from_sealed_json}).
|
|
42
|
+
attr_reader :scope
|
|
43
|
+
# @return [String] the template Secret `type` (e.g. "Opaque").
|
|
44
|
+
attr_reader :type
|
|
45
|
+
# @return [Array<String>] the data keys present in `spec.encryptedData`
|
|
46
|
+
# (plaintext keys; the values are ciphertext and are not held here).
|
|
47
|
+
attr_reader :encrypted_keys
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Parse a SealedSecret manifest (the local `<name>.yaml`) into the model.
|
|
51
|
+
#
|
|
52
|
+
# @param yaml [String, Hash] raw YAML/JSON text or a pre-parsed Hash.
|
|
53
|
+
# @return [RKSeal::SealedSecret]
|
|
54
|
+
# @raise [RKSeal::InvalidInputError] if the document is empty, not valid
|
|
55
|
+
# YAML, not a SealedSecret, or carries a non-mapping `encryptedData`.
|
|
56
|
+
def parse(yaml)
|
|
57
|
+
doc = yaml.is_a?(Hash) ? yaml : load_yaml(yaml)
|
|
58
|
+
unless doc.is_a?(Hash)
|
|
59
|
+
raise InvalidInputError, "not a SealedSecret manifest (expected a YAML mapping)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
validate_kind!(doc)
|
|
63
|
+
new(
|
|
64
|
+
name: fetch_name(doc),
|
|
65
|
+
namespace: doc.dig("metadata", "namespace"),
|
|
66
|
+
scope: Secret.scope_from_sealed_json(doc),
|
|
67
|
+
type: doc.dig("spec", "template", "type") || Secret::DEFAULT_TYPE,
|
|
68
|
+
encrypted_keys: encrypted_keys(doc)
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether the sealed payload (`spec.encryptedData` + `spec.template`) of two
|
|
73
|
+
# SealedSecret documents differs. `kubectl apply` stores the manifest
|
|
74
|
+
# verbatim, so right after a deploy the local `<name>.yaml` and the cluster
|
|
75
|
+
# object share an identical payload; an unequal payload therefore means the
|
|
76
|
+
# local file is *ahead of* (or absent from) the cluster -- i.e. it carries
|
|
77
|
+
# un-deployed changes. Re-sealing is non-deterministic, so equal payload is
|
|
78
|
+
# only ever produced by the exact same applied file -- there are no false
|
|
79
|
+
# "equal" verdicts that could mask drift. Tolerant of JSON (kubectl) and
|
|
80
|
+
# YAML (the local file) alike, and of malformed input (treated as drift, so
|
|
81
|
+
# the user's local file is never silently overwritten).
|
|
82
|
+
#
|
|
83
|
+
# @param local [String, Hash] the local SealedSecret (YAML text or Hash).
|
|
84
|
+
# @param cluster [String, Hash] the cluster SealedSecret (JSON/YAML or Hash).
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def diverged?(local, cluster)
|
|
87
|
+
sealed_payload(local) != sealed_payload(cluster)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# The comparable sealed payload of a SealedSecret document: its
|
|
93
|
+
# `encryptedData` and `template`. Anything unparseable collapses to a
|
|
94
|
+
# sentinel that compares unequal to a real payload (so drift wins).
|
|
95
|
+
def sealed_payload(document)
|
|
96
|
+
doc = document.is_a?(Hash) ? document : safe_parse(document)
|
|
97
|
+
spec = doc.is_a?(Hash) ? doc["spec"] : nil
|
|
98
|
+
spec.is_a?(Hash) ? [spec["encryptedData"], spec["template"]] : [:unparseable]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Best-effort parse for the divergence check: never raises, returns nil on
|
|
102
|
+
# empty/invalid input (YAML.safe_load parses JSON too).
|
|
103
|
+
def safe_parse(text)
|
|
104
|
+
return nil if text.nil? || text.strip.empty?
|
|
105
|
+
|
|
106
|
+
YAML.safe_load(text, permitted_classes: [], aliases: false)
|
|
107
|
+
rescue Psych::SyntaxError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def load_yaml(text)
|
|
112
|
+
raise InvalidInputError, "the SealedSecret file is empty" if text.nil? || text.strip.empty?
|
|
113
|
+
|
|
114
|
+
YAML.safe_load(text, permitted_classes: [], aliases: false)
|
|
115
|
+
rescue Psych::SyntaxError => e
|
|
116
|
+
raise InvalidInputError, "the SealedSecret file is not valid YAML: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_kind!(doc)
|
|
120
|
+
kind = doc["kind"]
|
|
121
|
+
return if kind == KIND
|
|
122
|
+
|
|
123
|
+
raise InvalidInputError, "not a SealedSecret (kind: #{kind.inspect})"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def fetch_name(doc)
|
|
127
|
+
name = doc.dig("metadata", "name")
|
|
128
|
+
return name unless name.nil? || (name.respond_to?(:strip) && name.strip.empty?)
|
|
129
|
+
|
|
130
|
+
raise InvalidInputError, "the SealedSecret is missing metadata.name"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def encrypted_keys(doc)
|
|
134
|
+
encrypted = doc.dig("spec", "encryptedData")
|
|
135
|
+
return [] if encrypted.nil?
|
|
136
|
+
unless encrypted.is_a?(Hash)
|
|
137
|
+
raise InvalidInputError, "spec.encryptedData must be a mapping of key to ciphertext"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
encrypted.keys.map(&:to_s)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @param name [String]
|
|
145
|
+
# @param namespace [String, nil]
|
|
146
|
+
# @param scope [Symbol]
|
|
147
|
+
# @param type [String]
|
|
148
|
+
# @param encrypted_keys [Array<String>]
|
|
149
|
+
def initialize(name:, namespace:, scope:, type:, encrypted_keys:)
|
|
150
|
+
@name = name
|
|
151
|
+
@namespace = namespace
|
|
152
|
+
@scope = scope
|
|
153
|
+
@type = type
|
|
154
|
+
@encrypted_keys = encrypted_keys.freeze
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Render the redacted editor buffer for the offline local edit: a Kubernetes
|
|
158
|
+
# Secret manifest in which every existing key is shown as
|
|
159
|
+
# {REDACTED_PLACEHOLDER}. The operator keeps a value by leaving the
|
|
160
|
+
# placeholder, replaces it by typing a new value, adds keys by adding lines,
|
|
161
|
+
# and removes keys by deleting lines.
|
|
162
|
+
#
|
|
163
|
+
# By default the keys sit under `data` (so replacements are base64, matching
|
|
164
|
+
# the rest of the tool). With `string_data: true` they sit under
|
|
165
|
+
# `stringData`, so replacements/new keys are entered as plaintext.
|
|
166
|
+
#
|
|
167
|
+
# @param commented [Boolean] include the explanatory header comment.
|
|
168
|
+
# @param string_data [Boolean] place the keys under `stringData` (plaintext)
|
|
169
|
+
# instead of `data` (base64).
|
|
170
|
+
# @return [String] YAML suitable to hand to {RKSeal::Editor}.
|
|
171
|
+
def to_buffer(commented: true, string_data: false)
|
|
172
|
+
body = {
|
|
173
|
+
"apiVersion" => Secret::API_VERSION,
|
|
174
|
+
"kind" => Secret::KIND,
|
|
175
|
+
"metadata" => { "name" => name, "namespace" => namespace },
|
|
176
|
+
"type" => type,
|
|
177
|
+
(string_data ? "stringData" : "data") =>
|
|
178
|
+
encrypted_keys.to_h { |key| [key, REDACTED_PLACEHOLDER] }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
yaml = YAML.dump(body).delete_prefix("---\n")
|
|
182
|
+
commented ? "#{buffer_header(string_data)}#{yaml}" : yaml
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def buffer_header(string_data)
|
|
188
|
+
entry = string_data ? "plaintext" : "base64"
|
|
189
|
+
<<~HEADER
|
|
190
|
+
# rkseal: LOCAL edit of a SealedSecret (offline -- cluster state is NOT read).
|
|
191
|
+
#
|
|
192
|
+
# Existing values cannot be decrypted, so each key shows #{REDACTED_PLACEHOLDER}.
|
|
193
|
+
# On save:
|
|
194
|
+
# - leave a value as #{REDACTED_PLACEHOLDER} to KEEP its current sealed value untouched;
|
|
195
|
+
# - replace #{REDACTED_PLACEHOLDER} with a new #{entry} value to RE-SEAL that key (rehash);
|
|
196
|
+
# - add a new `key: value` line to seal a NEW key (#{entry} value);
|
|
197
|
+
# - delete a key's line to REMOVE it from the SealedSecret.
|
|
198
|
+
#
|
|
199
|
+
# Scope (#{scope}) is preserved and cannot be changed here: kept values
|
|
200
|
+
# cannot be re-sealed under a new scope. name/namespace are fixed too.
|
|
201
|
+
HEADER
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|