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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RKSeal
|
|
6
|
+
# Thin adapter over the `kubectl` binary.
|
|
7
|
+
#
|
|
8
|
+
# Each public method maps to one kubectl invocation. Methods return raw
|
|
9
|
+
# strings (JSON / context name) or nothing; this adapter does not parse the
|
|
10
|
+
# Secret into the domain model -- {RKSeal::Secret.from_kubectl_json} does that.
|
|
11
|
+
#
|
|
12
|
+
# As with {RKSeal::Kubeseal}, all process execution funnels through one
|
|
13
|
+
# private runner that is the single stub seam for unit tests. The runner must
|
|
14
|
+
# never log Secret contents.
|
|
15
|
+
class Kubectl
|
|
16
|
+
BINARY = "kubectl"
|
|
17
|
+
|
|
18
|
+
# kubectl prints this token to stderr when a resource is absent. Matched
|
|
19
|
+
# case-insensitively to map the failure onto {NotFoundError}.
|
|
20
|
+
NOT_FOUND_MARKER = "notfound"
|
|
21
|
+
|
|
22
|
+
# @param binary [String] override the executable name/path (testing/env).
|
|
23
|
+
def initialize(binary: BINARY)
|
|
24
|
+
@binary = binary
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Verify the kubectl binary is present and executable; raise otherwise.
|
|
28
|
+
#
|
|
29
|
+
# @return [void]
|
|
30
|
+
# @raise [RKSeal::DependencyMissingError] if `kubectl` is not on PATH.
|
|
31
|
+
def ensure_available!
|
|
32
|
+
return if executable_on_path?(@binary)
|
|
33
|
+
|
|
34
|
+
raise DependencyMissingError,
|
|
35
|
+
"kubectl not found on PATH (looked for #{@binary.inspect}). " \
|
|
36
|
+
"Install it from https://kubernetes.io/docs/tasks/tools/."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read a Secret from the cluster as JSON
|
|
40
|
+
# (`kubectl get secret <name> -n <namespace> -o json`). This is the only way
|
|
41
|
+
# to recover the *current* plaintext of an existing SealedSecret, so it
|
|
42
|
+
# drives the `edit` flow.
|
|
43
|
+
#
|
|
44
|
+
# @param name [String]
|
|
45
|
+
# @param namespace [String]
|
|
46
|
+
# @return [String] the JSON document kubectl prints on stdout.
|
|
47
|
+
# @raise [RKSeal::NotFoundError] if the Secret does not exist (kubectl
|
|
48
|
+
# "NotFound"); the message must point the user at `rkseal create`.
|
|
49
|
+
# @raise [RKSeal::CommandError] on any other kubectl failure (e.g. cluster
|
|
50
|
+
# unreachable, unknown namespace).
|
|
51
|
+
def get_secret(name:, namespace:)
|
|
52
|
+
run("get", "secret", name, "-n", namespace, "-o", "json")
|
|
53
|
+
rescue CommandError => e
|
|
54
|
+
raise unless not_found?(e.stderr)
|
|
55
|
+
|
|
56
|
+
raise NotFoundError,
|
|
57
|
+
"Secret #{name.inspect} not found in namespace #{namespace.inspect}. " \
|
|
58
|
+
"Use `rkseal create #{namespace} #{name}` to author it first."
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Read a SealedSecret from the cluster as JSON
|
|
62
|
+
# (`kubectl get sealedsecret <name> -n <namespace> -o json`). The `edit` flow
|
|
63
|
+
# consults this to recover the existing seal's scope when it is not otherwise
|
|
64
|
+
# known; callers rescue {NotFoundError} to fall back to the local file.
|
|
65
|
+
#
|
|
66
|
+
# @param name [String]
|
|
67
|
+
# @param namespace [String]
|
|
68
|
+
# @return [String] the JSON document kubectl prints on stdout.
|
|
69
|
+
# @raise [RKSeal::NotFoundError] if the SealedSecret does not exist.
|
|
70
|
+
# @raise [RKSeal::CommandError] on any other kubectl failure (e.g. cluster
|
|
71
|
+
# unreachable, unknown namespace, CRD not installed).
|
|
72
|
+
def get_sealedsecret(name:, namespace:)
|
|
73
|
+
run("get", "sealedsecret", name, "-n", namespace, "-o", "json")
|
|
74
|
+
rescue CommandError => e
|
|
75
|
+
raise unless not_found?(e.stderr)
|
|
76
|
+
|
|
77
|
+
raise NotFoundError,
|
|
78
|
+
"SealedSecret #{name.inspect} not found in namespace #{namespace.inspect}."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# List SealedSecrets as JSON (`kubectl get sealedsecret -o json`), scoped to
|
|
82
|
+
# one namespace (`-n <namespace>`) or across all namespaces (`-A`) when none
|
|
83
|
+
# is given. Drives the `list` flow.
|
|
84
|
+
#
|
|
85
|
+
# An empty namespace is not an error: kubectl returns a List object with an
|
|
86
|
+
# empty `items: []`, so this does NOT map NotFound -- only operational
|
|
87
|
+
# failures (cluster unreachable, CRD absent) surface, as {CommandError}.
|
|
88
|
+
#
|
|
89
|
+
# @param namespace [String, nil] a single namespace, or nil for all.
|
|
90
|
+
# @return [String] the JSON List document kubectl prints on stdout.
|
|
91
|
+
# @raise [RKSeal::CommandError] on any kubectl failure.
|
|
92
|
+
def list_sealedsecrets(namespace: nil)
|
|
93
|
+
scope = namespace ? ["-n", namespace] : ["-A"]
|
|
94
|
+
run("get", "sealedsecret", *scope, "-o", "json")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Apply a manifest file to the cluster (`kubectl apply -f <file>`). Only the
|
|
98
|
+
# `edit` deploy step calls this, and only after {RKSeal::ContextGuard} has
|
|
99
|
+
# approved the active context.
|
|
100
|
+
#
|
|
101
|
+
# @param file [String] path to the SealedSecret manifest to apply.
|
|
102
|
+
# @return [String] kubectl's stdout (the apply result line).
|
|
103
|
+
# @raise [RKSeal::CommandError] if apply fails.
|
|
104
|
+
def apply(file:)
|
|
105
|
+
run("apply", "-f", file)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Return the active kube context (`kubectl config current-context`). Used by
|
|
109
|
+
# {RKSeal::ContextGuard} to gate deploys.
|
|
110
|
+
#
|
|
111
|
+
# @return [String] the current context name (whitespace stripped).
|
|
112
|
+
# @raise [RKSeal::CommandError] if kubectl cannot report a context.
|
|
113
|
+
def current_context
|
|
114
|
+
run("config", "current-context").strip
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
# Whether kubectl's stderr indicates the resource was absent ("NotFound").
|
|
120
|
+
def not_found?(stderr)
|
|
121
|
+
return false unless stderr
|
|
122
|
+
|
|
123
|
+
stderr.downcase.include?(NOT_FOUND_MARKER)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The single shell-out seam for this adapter. Uses Open3.capture3 with an
|
|
127
|
+
# argv array (never a shell string) so user-supplied names/namespaces can
|
|
128
|
+
# never be interpreted by a shell -- no injection surface. On a non-zero
|
|
129
|
+
# exit it raises CommandError carrying the scrubbed command label, status,
|
|
130
|
+
# and stderr.
|
|
131
|
+
#
|
|
132
|
+
# SECURITY: any `stdin` is piped to the child but is NEVER echoed into the
|
|
133
|
+
# command label or error message; only argv (subcommands, names, paths) is
|
|
134
|
+
# surfaced.
|
|
135
|
+
#
|
|
136
|
+
# @param argv [Array<String>] arguments passed after the binary name.
|
|
137
|
+
# @param stdin [String, nil] data piped to the child process's stdin.
|
|
138
|
+
# @return [String] captured stdout.
|
|
139
|
+
# @raise [RKSeal::CommandError] on a non-zero exit.
|
|
140
|
+
def run(*argv, stdin: nil)
|
|
141
|
+
stdout, stderr, status = Open3.capture3(@binary, *argv, stdin_data: stdin || "")
|
|
142
|
+
return stdout if status.success?
|
|
143
|
+
|
|
144
|
+
raise CommandError.new(
|
|
145
|
+
"kubectl failed (exit #{status.exitstatus}): #{stderr.strip}",
|
|
146
|
+
command: command_label(argv),
|
|
147
|
+
status: status.exitstatus,
|
|
148
|
+
stderr: stderr
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Whether `name` resolves to an executable file on PATH (or is itself an
|
|
153
|
+
# executable path).
|
|
154
|
+
def executable_on_path?(name)
|
|
155
|
+
return File.executable?(name) if name.include?(File::SEPARATOR)
|
|
156
|
+
|
|
157
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
158
|
+
File.executable?(File.join(dir, name))
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# A safe, human-readable label for error messages: the binary plus its argv.
|
|
163
|
+
# Contains only subcommands/paths -- never stdin -- so it cannot leak data.
|
|
164
|
+
def command_label(argv)
|
|
165
|
+
[@binary, *argv].join(" ")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module RKSeal
|
|
8
|
+
# Thin adapter over the `kubeseal` binary.
|
|
9
|
+
#
|
|
10
|
+
# Owns everything kubeseal-flag-shaped: scope, certificate source, and the
|
|
11
|
+
# controller's name/namespace. Each public method maps to one kubeseal
|
|
12
|
+
# invocation and returns its stdout (the produced SealedSecret YAML or a PEM
|
|
13
|
+
# certificate). Nothing here parses YAML or knows about the domain model --
|
|
14
|
+
# callers pass in manifest text and get sealed text back.
|
|
15
|
+
#
|
|
16
|
+
# Developed against kubeseal **v0.36.6**; flag names below assume that CLI.
|
|
17
|
+
#
|
|
18
|
+
# All process execution funnels through one private runner so that unit tests
|
|
19
|
+
# stub a single seam (or stub the public methods directly). The runner must
|
|
20
|
+
# never echo stdin (the plaintext Secret) into logs or error messages.
|
|
21
|
+
#
|
|
22
|
+
# rubocop:disable Metrics/ClassLength -- the inline {CertCache} is co-located
|
|
23
|
+
# here by design (the cert cache is intrinsic to this adapter and must not add
|
|
24
|
+
# a new top-level require); that nested class accounts for the extra lines.
|
|
25
|
+
class Kubeseal
|
|
26
|
+
BINARY = "kubeseal"
|
|
27
|
+
|
|
28
|
+
# Allowed sealing scopes, mapped to their kubeseal `--scope` argument.
|
|
29
|
+
SCOPES = {
|
|
30
|
+
strict: "strict",
|
|
31
|
+
namespace_wide: "namespace-wide",
|
|
32
|
+
cluster_wide: "cluster-wide"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# kubeseal's own defaults for the controller's identity. Used to name the
|
|
36
|
+
# cache entry consistently when the caller does not override them, so a run
|
|
37
|
+
# with implicit defaults and a run with explicit-but-identical flags share
|
|
38
|
+
# one cached cert.
|
|
39
|
+
DEFAULT_CONTROLLER_NAME = "sealed-secrets-controller"
|
|
40
|
+
DEFAULT_CONTROLLER_NAMESPACE = "kube-system"
|
|
41
|
+
|
|
42
|
+
# Substring kubeseal prints to stderr when the controller could decrypt-test
|
|
43
|
+
# the SealedSecret but it is NOT valid. Anything else on a non-zero
|
|
44
|
+
# `--validate` exit is treated as operational (CommandError), not a verdict.
|
|
45
|
+
VALIDATION_FAILURE_MARKER = "unable to decrypt"
|
|
46
|
+
|
|
47
|
+
# @param binary [String] override the executable name/path (testing/env).
|
|
48
|
+
# @param controller_name [String, nil] `--controller-name` value.
|
|
49
|
+
# @param controller_namespace [String, nil] `--controller-namespace` value.
|
|
50
|
+
# @param cert [String, nil] `--cert <file|URL>` source; when nil and no env
|
|
51
|
+
# cert is present, the cert is fetched from the controller and cached.
|
|
52
|
+
# @param refresh_cert [Boolean] when true, ignore any cached cert and
|
|
53
|
+
# overwrite it with a freshly fetched one (wired to `--refresh-cert`).
|
|
54
|
+
def initialize(binary: BINARY, controller_name: nil, controller_namespace: nil,
|
|
55
|
+
cert: nil, refresh_cert: false)
|
|
56
|
+
@binary = binary
|
|
57
|
+
@controller_name = controller_name
|
|
58
|
+
@controller_namespace = controller_namespace
|
|
59
|
+
@cert = cert
|
|
60
|
+
@refresh_cert = refresh_cert
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Verify the kubeseal binary is present and executable; raise otherwise.
|
|
64
|
+
# Called early so the flow fails fast on a missing dependency.
|
|
65
|
+
#
|
|
66
|
+
# @return [void]
|
|
67
|
+
# @raise [RKSeal::DependencyMissingError] if `kubeseal` is not on PATH.
|
|
68
|
+
def ensure_available!
|
|
69
|
+
return if executable_on_path?(@binary)
|
|
70
|
+
|
|
71
|
+
raise DependencyMissingError,
|
|
72
|
+
"kubeseal not found on PATH (looked for #{@binary.inspect}). " \
|
|
73
|
+
"Install it from https://github.com/bitnami-labs/sealed-secrets/releases."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Resolve the encryption certificate up front so a flow fails fast before any
|
|
77
|
+
# editor opens. When an offline cert is configured (`--cert` or the
|
|
78
|
+
# `SEALED_SECRETS_CERT` env var) nothing is contacted. Otherwise the cert is
|
|
79
|
+
# resolved through the on-disk cache: a cached PEM is reused as-is, otherwise
|
|
80
|
+
# it is fetched from the live controller and written to the cache (which is
|
|
81
|
+
# what makes a subsequent {#seal} offline). `refresh_cert: true` skips the
|
|
82
|
+
# cached copy and refetches.
|
|
83
|
+
#
|
|
84
|
+
# @return [void]
|
|
85
|
+
# @raise [RKSeal::CommandError] if no offline cert is configured and the
|
|
86
|
+
# controller is unreachable (the underlying `--fetch-cert` exits non-zero).
|
|
87
|
+
def ensure_cert!
|
|
88
|
+
return if offline_cert?
|
|
89
|
+
|
|
90
|
+
resolve_cached_cert_path
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Seal a Secret manifest into a SealedSecret.
|
|
95
|
+
#
|
|
96
|
+
# Pipes `manifest_yaml` to kubeseal on stdin with `-o yaml` and the resolved
|
|
97
|
+
# `--scope`. The certificate is resolved offline-first: an explicit `--cert`
|
|
98
|
+
# or the cached controller PEM is passed via `--cert` (no API round-trip);
|
|
99
|
+
# only when neither is available does kubeseal fall back to the env var or
|
|
100
|
+
# the controller itself. Returns the SealedSecret YAML on stdout.
|
|
101
|
+
#
|
|
102
|
+
# @param manifest_yaml [String] a full Secret manifest (from
|
|
103
|
+
# {RKSeal::Secret#to_manifest}).
|
|
104
|
+
# @param scope [Symbol] one of {SCOPES} keys; defaults to :strict.
|
|
105
|
+
# @return [String] SealedSecret YAML.
|
|
106
|
+
# @raise [RKSeal::InvalidInputError] if scope is unknown.
|
|
107
|
+
# @raise [RKSeal::CommandError] if kubeseal exits non-zero (e.g. controller
|
|
108
|
+
# unreachable, bad cert).
|
|
109
|
+
def seal(manifest_yaml, scope: :strict)
|
|
110
|
+
# `-o yaml` is mandatory: kubeseal defaults to JSON, so without it the
|
|
111
|
+
# output written to `<name>.yaml` would actually contain JSON.
|
|
112
|
+
argv = ["--scope", scope_flag(scope), "-o", "yaml"]
|
|
113
|
+
cert_path = resolved_cert_path
|
|
114
|
+
argv += ["--cert", cert_path] if cert_path
|
|
115
|
+
argv += controller_flags
|
|
116
|
+
|
|
117
|
+
run(*argv, stdin: manifest_yaml)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Validate that a SealedSecret can be decrypted by the controller
|
|
121
|
+
# (`kubeseal --validate`, SealedSecret piped on stdin). Contacts the cluster:
|
|
122
|
+
# the controller performs the decrypt-test.
|
|
123
|
+
#
|
|
124
|
+
# kubeseal v0.36.6 exits 0 when valid and non-zero otherwise, printing the
|
|
125
|
+
# reason to stderr. A non-zero exit whose stderr names a decrypt failure is a
|
|
126
|
+
# validity verdict ({ValidationError}); any other non-zero exit (missing
|
|
127
|
+
# binary, unreachable cluster, controller service not found) is operational
|
|
128
|
+
# ({CommandError}) and says nothing about the SealedSecret itself.
|
|
129
|
+
#
|
|
130
|
+
# @param sealed_secret_yaml [String] a SealedSecret manifest.
|
|
131
|
+
# @return [true] when the controller can decrypt it.
|
|
132
|
+
# @raise [RKSeal::ValidationError] when the controller rejects it as invalid.
|
|
133
|
+
# @raise [RKSeal::CommandError] on operational failures.
|
|
134
|
+
def validate(sealed_secret_yaml)
|
|
135
|
+
run("--validate", *controller_flags, stdin: sealed_secret_yaml)
|
|
136
|
+
true
|
|
137
|
+
rescue CommandError => e
|
|
138
|
+
raise unless validation_failure?(e.stderr)
|
|
139
|
+
|
|
140
|
+
raise ValidationError,
|
|
141
|
+
"SealedSecret failed validation: #{e.stderr.strip}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Fetch the controller's public certificate (`kubeseal --fetch-cert`) so it
|
|
145
|
+
# can be cached and reused, avoiding an API round-trip per seal.
|
|
146
|
+
#
|
|
147
|
+
# NOTE: unlike {#seal}, this method contacts the cluster API by design.
|
|
148
|
+
#
|
|
149
|
+
# @return [String] the certificate in PEM format.
|
|
150
|
+
# @raise [RKSeal::CommandError] if the controller is unreachable.
|
|
151
|
+
def fetch_cert
|
|
152
|
+
run("--fetch-cert", *controller_flags)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Blind-append freshly-encrypted items to an existing SealedSecret file
|
|
156
|
+
# (`kubeseal --merge-into <file>`). Does NOT decrypt anything: it appends or
|
|
157
|
+
# overwrites the items in the input Secret while leaving every other sealed
|
|
158
|
+
# entry untouched. This is what powers the offline `edit --local` flow,
|
|
159
|
+
# where kept keys must stay byte-for-byte unchanged.
|
|
160
|
+
#
|
|
161
|
+
# The certificate is resolved offline-first, exactly like {#seal}: an
|
|
162
|
+
# explicit `--cert` or the cached controller PEM is passed via `--cert` (no
|
|
163
|
+
# API round-trip); only when neither is available does kubeseal fall back to
|
|
164
|
+
# the env var or the controller. The output format is inherited from the
|
|
165
|
+
# existing file, so `-o` is NOT forced here.
|
|
166
|
+
#
|
|
167
|
+
# @param manifest_yaml [String] Secret manifest with the items to add.
|
|
168
|
+
# @param file [String] path to the existing SealedSecret to merge into.
|
|
169
|
+
# @param scope [Symbol] sealing scope for the new items.
|
|
170
|
+
# @return [void] mutates `file` in place.
|
|
171
|
+
# @raise [RKSeal::CommandError] on kubeseal failure.
|
|
172
|
+
def merge_into(manifest_yaml, file:, scope: :strict)
|
|
173
|
+
argv = ["--merge-into", file, "--scope", scope_flag(scope)]
|
|
174
|
+
cert_path = resolved_cert_path
|
|
175
|
+
argv += ["--cert", cert_path] if cert_path
|
|
176
|
+
argv += controller_flags
|
|
177
|
+
|
|
178
|
+
run(*argv, stdin: manifest_yaml)
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Upgrade an existing SealedSecret to the controller's newest key without
|
|
183
|
+
# exposing plaintext (`kubeseal --re-encrypt`). Out of scope for the initial
|
|
184
|
+
# create/edit flows but part of the adapter surface.
|
|
185
|
+
#
|
|
186
|
+
# NOTE: contacts the cluster API by design.
|
|
187
|
+
#
|
|
188
|
+
# @param sealed_yaml [String] an existing SealedSecret manifest.
|
|
189
|
+
# @return [String] the re-encrypted SealedSecret YAML.
|
|
190
|
+
# @raise [RKSeal::CommandError] on kubeseal failure.
|
|
191
|
+
def re_encrypt(sealed_yaml)
|
|
192
|
+
run("--re-encrypt", "-o", "yaml", *controller_flags, stdin: sealed_yaml)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
# Translate a scope symbol into its kubeseal `--scope` argument.
|
|
198
|
+
#
|
|
199
|
+
# @raise [RKSeal::InvalidInputError] for an unknown scope.
|
|
200
|
+
def scope_flag(scope)
|
|
201
|
+
SCOPES.fetch(scope) do
|
|
202
|
+
raise InvalidInputError,
|
|
203
|
+
"Unknown scope #{scope.inspect}; expected one of #{SCOPES.keys.inspect}."
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Whether an offline certificate source is configured, meaning {#ensure_cert!}
|
|
208
|
+
# need not contact the controller. Either the injected `--cert` value or a
|
|
209
|
+
# non-blank `SEALED_SECRETS_CERT` env var counts.
|
|
210
|
+
def offline_cert?
|
|
211
|
+
return true if @cert
|
|
212
|
+
|
|
213
|
+
!env_cert.strip.empty?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The `SEALED_SECRETS_CERT` env var, or an empty string.
|
|
217
|
+
def env_cert
|
|
218
|
+
ENV.fetch("SEALED_SECRETS_CERT", "")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Resolve a cert file/URL to pass to `--cert`, or nil to let kubeseal fall
|
|
222
|
+
# back to its own sources. Precedence: explicit `--cert` > env var (kubeseal
|
|
223
|
+
# reads SEALED_SECRETS_CERT itself, so we pass nil) > cached/fetched PEM.
|
|
224
|
+
def resolved_cert_path
|
|
225
|
+
return @cert if @cert
|
|
226
|
+
return nil unless env_cert.strip.empty?
|
|
227
|
+
|
|
228
|
+
resolve_cached_cert_path
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Return the path to a usable cached controller PEM, fetching and writing it
|
|
232
|
+
# first if absent (or if a refresh was requested). The cert is public, so the
|
|
233
|
+
# cache file is world-readable.
|
|
234
|
+
def cert_cache
|
|
235
|
+
@cert_cache ||= CertCache.new(
|
|
236
|
+
controller_namespace: @controller_namespace || DEFAULT_CONTROLLER_NAMESPACE,
|
|
237
|
+
controller_name: @controller_name || DEFAULT_CONTROLLER_NAME
|
|
238
|
+
)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def resolve_cached_cert_path
|
|
242
|
+
return cert_cache.path if !@refresh_cert && cert_cache.exist?
|
|
243
|
+
|
|
244
|
+
cert_cache.write(fetch_cert)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Whether kubeseal's stderr from a failed `--validate` is a validity verdict
|
|
248
|
+
# (the controller could not decrypt) rather than an operational failure.
|
|
249
|
+
def validation_failure?(stderr)
|
|
250
|
+
return false unless stderr
|
|
251
|
+
|
|
252
|
+
stderr.downcase.include?(VALIDATION_FAILURE_MARKER)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# `--controller-name` / `--controller-namespace` flags when configured.
|
|
256
|
+
def controller_flags
|
|
257
|
+
flags = []
|
|
258
|
+
flags += ["--controller-name", @controller_name] if @controller_name
|
|
259
|
+
flags += ["--controller-namespace", @controller_namespace] if @controller_namespace
|
|
260
|
+
flags
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# The single shell-out seam for this adapter. Uses Open3.capture3 with an
|
|
264
|
+
# argv array (never a shell string) so user-supplied values can never be
|
|
265
|
+
# interpreted by a shell -- no injection surface. On a non-zero exit it
|
|
266
|
+
# raises CommandError carrying the scrubbed command label, status, and
|
|
267
|
+
# stderr.
|
|
268
|
+
#
|
|
269
|
+
# SECURITY: `stdin` (the plaintext Secret manifest) is piped to the child
|
|
270
|
+
# process but is NEVER included in the command label or any error message.
|
|
271
|
+
# Only argv -- which holds flags and file paths, not secret values -- is
|
|
272
|
+
# surfaced.
|
|
273
|
+
#
|
|
274
|
+
# @param argv [Array<String>] arguments passed after the binary name.
|
|
275
|
+
# @param stdin [String, nil] data piped to the child process's stdin.
|
|
276
|
+
# @return [String] captured stdout.
|
|
277
|
+
# @raise [RKSeal::CommandError] on a non-zero exit.
|
|
278
|
+
def run(*argv, stdin: nil)
|
|
279
|
+
stdout, stderr, status = Open3.capture3(@binary, *argv, stdin_data: stdin || "")
|
|
280
|
+
return stdout if status.success?
|
|
281
|
+
|
|
282
|
+
raise CommandError.new(
|
|
283
|
+
"kubeseal failed (exit #{status.exitstatus}): #{stderr.strip}",
|
|
284
|
+
command: command_label(argv),
|
|
285
|
+
status: status.exitstatus,
|
|
286
|
+
stderr: stderr
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Whether `name` resolves to an executable file on PATH (or is itself an
|
|
291
|
+
# executable path).
|
|
292
|
+
def executable_on_path?(name)
|
|
293
|
+
return File.executable?(name) if name.include?(File::SEPARATOR)
|
|
294
|
+
|
|
295
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
296
|
+
File.executable?(File.join(dir, name))
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# A safe, human-readable label for error messages: the binary plus its argv.
|
|
301
|
+
# Contains only flags/paths -- never stdin -- so it cannot leak secrets.
|
|
302
|
+
def command_label(argv)
|
|
303
|
+
[@binary, *argv].join(" ")
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# On-disk cache for the controller's PUBLIC certificate, so repeated seals do
|
|
307
|
+
# not each hit the cluster for `--fetch-cert`. The cert is public, hence the
|
|
308
|
+
# world-readable 0644 perms. One entry per controller identity, under the XDG
|
|
309
|
+
# cache dir:
|
|
310
|
+
# ${XDG_CACHE_HOME:-$HOME/.cache}/rkseal/<namespace>/<name>.pem
|
|
311
|
+
#
|
|
312
|
+
# The namespace is a path segment rather than a `<namespace>-<name>` prefix
|
|
313
|
+
# so two distinct identities can never collide on one file (e.g. `a-b`/`c`
|
|
314
|
+
# vs `a`/`b-c`); a DNS-1123 name contains no `/`, so the layout is
|
|
315
|
+
# unambiguous. Writes go through a temp file + atomic rename so a concurrent
|
|
316
|
+
# seal never reads a half-written cert.
|
|
317
|
+
#
|
|
318
|
+
# Defined inline (not a separate file) so this adapter stays self-contained
|
|
319
|
+
# and adds no new top-level require.
|
|
320
|
+
class CertCache
|
|
321
|
+
DIR_PERMS = 0o755
|
|
322
|
+
FILE_PERMS = 0o644
|
|
323
|
+
|
|
324
|
+
# @param controller_namespace [String]
|
|
325
|
+
# @param controller_name [String]
|
|
326
|
+
def initialize(controller_namespace:, controller_name:)
|
|
327
|
+
@controller_namespace = controller_namespace
|
|
328
|
+
@controller_name = controller_name
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# @return [String] absolute path to this controller's cached PEM.
|
|
332
|
+
def path
|
|
333
|
+
File.join(cache_dir, @controller_namespace, "#{@controller_name}.pem")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# @return [Boolean] whether a cached PEM already exists.
|
|
337
|
+
def exist?
|
|
338
|
+
File.exist?(path)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# @return [String] the cached PEM contents.
|
|
342
|
+
def read
|
|
343
|
+
File.read(path)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Persist a freshly fetched PEM (overwriting any existing entry) and return
|
|
347
|
+
# its path so the caller can hand it to `--cert`.
|
|
348
|
+
#
|
|
349
|
+
# @param pem [String] certificate contents.
|
|
350
|
+
# @return [String] the cache path that now holds the PEM.
|
|
351
|
+
def write(pem)
|
|
352
|
+
FileUtils.mkdir_p(File.dirname(path), mode: DIR_PERMS)
|
|
353
|
+
write_atomically(pem)
|
|
354
|
+
path
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private
|
|
358
|
+
|
|
359
|
+
# Write the PEM to a uniquely-named temp file in the same directory, then
|
|
360
|
+
# rename it over the target. rename(2) is atomic within one filesystem, so
|
|
361
|
+
# a concurrent seal sees either the old cert or the new one -- never a
|
|
362
|
+
# half-written file. The temp file carries the final 0644 perms and is
|
|
363
|
+
# removed if the rename never happens (e.g. an error mid-write).
|
|
364
|
+
def write_atomically(pem)
|
|
365
|
+
tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{SecureRandom.hex(8)}.tmp")
|
|
366
|
+
File.write(tmp, pem)
|
|
367
|
+
File.chmod(FILE_PERMS, tmp)
|
|
368
|
+
File.rename(tmp, path)
|
|
369
|
+
ensure
|
|
370
|
+
File.unlink(tmp) if tmp && File.exist?(tmp)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# ${XDG_CACHE_HOME:-$HOME/.cache}/rkseal
|
|
374
|
+
def cache_dir
|
|
375
|
+
base = ENV.fetch("XDG_CACHE_HOME", nil)
|
|
376
|
+
base = File.join(Dir.home, ".cache") if base.nil? || base.strip.empty?
|
|
377
|
+
File.join(base, "rkseal")
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
# rubocop:enable Metrics/ClassLength
|
|
382
|
+
end
|