underpost 3.2.80 → 3.3.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.
- package/.github/workflows/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +291 -1
- package/CLI-HELP.md +174 -23
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +19 -17
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/playwright/deployment.yaml +1 -1
- package/manifests/mongodb/kustomization.yaml +4 -1
- package/manifests/mongodb/statefulset.yaml +4 -0
- package/manifests/mongodb/storage-class.yaml +9 -2
- package/package.json +19 -19
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +12 -4
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +4 -3
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +786 -96
- package/src/cli/db.js +11 -4
- package/src/cli/deploy.js +1698 -177
- package/src/cli/docker-compose.js +19 -178
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +15 -7
- package/src/cli/index.js +245 -44
- package/src/cli/ipfs.js +82 -11
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +2 -2
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +12 -10
- package/src/cli/run.js +2195 -427
- package/src/cli/secrets.js +969 -0
- package/src/cli/ssh.js +206 -105
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +102 -13
- package/src/client-builder/ssr.js +27 -73
- package/src/db/mongo/MongoBootstrap.js +295 -54
- package/src/db/mongo/MongooseDB.js +51 -32
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +1216 -168
- package/src/server/cri.js +70 -0
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +1083 -0
- package/src/server/underpost-ingress.js +380 -0
- package/test/cluster-instances.test.js +435 -0
- package/test/deploy-node-placement.test.js +45 -0
- package/test/instance-traffic-plan.test.js +710 -0
- package/test/selinux.test.js +71 -0
- package/test/sops-secret-store.test.js +612 -0
- package/test/underpost-gateway.test.js +510 -0
- package/test/underpost-ingress.test.js +305 -0
- package/test/wireguard-edge.test.js +1177 -0
package/src/cli/secrets.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { shellExec } from '../server/process.js';
|
|
8
8
|
import fs from 'fs-extra';
|
|
9
|
+
import os from 'os';
|
|
9
10
|
import dotenv from 'dotenv';
|
|
10
11
|
import Underpost from '../index.js';
|
|
11
12
|
import { loadConf } from '../server/conf.js';
|
|
@@ -13,6 +14,66 @@ import { loggerFactory } from '../server/logger.js';
|
|
|
13
14
|
|
|
14
15
|
const logger = loggerFactory(import.meta);
|
|
15
16
|
|
|
17
|
+
// Git-tracked store of SOPS/Age encrypted Kubernetes Secret manifests, laid out as
|
|
18
|
+
// `<SOPS_SECRETS_DIR>/<namespace>/<name>.enc.yaml`. Lives inside the per-deploy private
|
|
19
|
+
// repo so encrypted manifests version alongside the conf they belong to. Only the
|
|
20
|
+
// ciphertext is committed; the Age private key never enters this tree.
|
|
21
|
+
const SOPS_SECRETS_DIR = './engine-private/secrets';
|
|
22
|
+
const SOPS_MANIFEST_EXT = '.enc.yaml';
|
|
23
|
+
// `creation_rules[].path_regex` is matched against the manifest path **relative to the directory
|
|
24
|
+
// holding .sops.yaml**, not the repo root. Since .sops.yaml lives at the store root, an
|
|
25
|
+
// `engine-private/secrets/` prefix here can never match — sops sees `<namespace>/<name>.enc.yaml`
|
|
26
|
+
// and reports "no matching creation rules found".
|
|
27
|
+
const SOPS_MANIFEST_PATH_REGEX = `.*${SOPS_MANIFEST_EXT.replace(/\./g, '\\.')}$`;
|
|
28
|
+
// Purged manifests are moved here rather than deleted, so an emergency purge stays
|
|
29
|
+
// reversible. Dot-prefixed so it is never mistaken for a namespace directory.
|
|
30
|
+
const SOPS_ARCHIVE_DIR = `${SOPS_SECRETS_DIR}/.archive`;
|
|
31
|
+
// Encrypting only the value tree under `data`/`stringData` keeps apiVersion/kind/metadata
|
|
32
|
+
// readable, so kubectl, kustomize, and code review still work on the encrypted file.
|
|
33
|
+
const SOPS_ENCRYPTED_REGEX = '^(data|stringData)$';
|
|
34
|
+
// Pinned SOPS/Age release binaries. Neither has an RHEL package, so both are installed
|
|
35
|
+
// from upstream static builds. Single source of truth for host provisioning: both
|
|
36
|
+
// `underpost secret --install-tools` and `underpost cluster --init-host` resolve here.
|
|
37
|
+
const SOPS_VERSION = 'v3.10.2';
|
|
38
|
+
const AGE_VERSION = 'v1.2.1';
|
|
39
|
+
// Origin seed paths: the plaintext credential files a secret is seeded from before SOPS/Age
|
|
40
|
+
// onboarding, and the path cluster init falls back to when no encrypted manifest exists.
|
|
41
|
+
// Keyed by Secret name, then by the Secret data key each file supplies — stated explicitly
|
|
42
|
+
// rather than derived from the filename, because `mongodb-keyfile` supplies a key of that same
|
|
43
|
+
// full name while `postgresql-password` supplies `password`.
|
|
44
|
+
// Mirrors the seed-fallback branches in UnderpostCluster.API.init() and
|
|
45
|
+
// MongoBootstrap.ensureMongoSecrets(); kept here so a purge can report whether the cluster
|
|
46
|
+
// still has a working seed path for that secret.
|
|
47
|
+
// Every Secret whose creation goes through `applyIfPresent` — i.e. the encrypted store is
|
|
48
|
+
// consulted first and the origin seed path is the fallback. Single source of truth for coverage
|
|
49
|
+
// reporting; keep in step with the call sites in UnderpostCluster.API.init(),
|
|
50
|
+
// MongoBootstrap.ensureMongoSecrets(), and UnderpostIPFS.applySecrets().
|
|
51
|
+
const MANAGED_SECRETS = [
|
|
52
|
+
'postgres-secret',
|
|
53
|
+
'mariadb-secret',
|
|
54
|
+
'mysql-secret',
|
|
55
|
+
'mongodb-secret',
|
|
56
|
+
'mongodb-keyfile',
|
|
57
|
+
'ipfs-cluster-secret',
|
|
58
|
+
];
|
|
59
|
+
const ORIGIN_SEED_SOURCES = {
|
|
60
|
+
'mariadb-secret': {
|
|
61
|
+
username: './engine-private/mariadb-username',
|
|
62
|
+
password: './engine-private/mariadb-password',
|
|
63
|
+
},
|
|
64
|
+
'mysql-secret': {
|
|
65
|
+
username: './engine-private/mysql-username',
|
|
66
|
+
password: './engine-private/mysql-password',
|
|
67
|
+
},
|
|
68
|
+
'postgres-secret': { password: './engine-private/postgresql-password' },
|
|
69
|
+
'mongodb-secret': {
|
|
70
|
+
username: './engine-private/mongodb-username',
|
|
71
|
+
password: './engine-private/mongodb-password',
|
|
72
|
+
},
|
|
73
|
+
// Shared replica-set auth keyfile, mounted as a volume rather than injected as env.
|
|
74
|
+
'mongodb-keyfile': { 'mongodb-keyfile': './engine-private/mongodb-keyfile' },
|
|
75
|
+
};
|
|
76
|
+
|
|
16
77
|
// Shell/runtime-critical and Kubernetes-injected env keys that must never be persisted as
|
|
17
78
|
// application secrets nor injected into a pod via `envFrom`. An injected PATH (or HOME, etc.)
|
|
18
79
|
// overrides the container image's own and breaks coreutils/sudo resolution inside the pod
|
|
@@ -108,6 +169,914 @@ class UnderpostSecret {
|
|
|
108
169
|
},
|
|
109
170
|
},
|
|
110
171
|
|
|
172
|
+
/**
|
|
173
|
+
* @method sops
|
|
174
|
+
* @description Git-native encrypted secret management backed by Mozilla SOPS and Age, for
|
|
175
|
+
* fully self-hosted clusters with no cloud KMS or external secret store. Encrypted manifests
|
|
176
|
+
* live in `engine-private/secrets/<namespace>/<name>.enc.yaml` and are safe to commit; the Age
|
|
177
|
+
* private key stays at `~/.config/sops/age/keys.txt` (or `$SOPS_AGE_KEY_FILE`) and is never
|
|
178
|
+
* committed, rendered into a manifest, or shipped into a container. Decryption is always
|
|
179
|
+
* streamed straight into `kubectl apply -f -`, so plaintext never reaches persistent storage.
|
|
180
|
+
* @memberof UnderpostSecret
|
|
181
|
+
*/
|
|
182
|
+
sops: {
|
|
183
|
+
/**
|
|
184
|
+
* @method keyFileCandidates
|
|
185
|
+
* @description Ordered paths the Age private key is looked for, matching what sops itself
|
|
186
|
+
* resolves. Surfaced separately so a "key not found" error can name every location tried —
|
|
187
|
+
* the identity-context trap is a key generated as an unprivileged user but read back under
|
|
188
|
+
* `sudo`, where `os.homedir()` points at root's home instead.
|
|
189
|
+
* @returns {Array<string>} Candidate paths, highest precedence first.
|
|
190
|
+
* @memberof UnderpostSecret
|
|
191
|
+
*/
|
|
192
|
+
keyFileCandidates() {
|
|
193
|
+
const candidates = [];
|
|
194
|
+
if (process.env.SOPS_AGE_KEY_FILE) candidates.push(process.env.SOPS_AGE_KEY_FILE);
|
|
195
|
+
if (process.env.XDG_CONFIG_HOME) candidates.push(`${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`);
|
|
196
|
+
candidates.push(`${os.homedir()}/.config/sops/age/keys.txt`);
|
|
197
|
+
// Under `sudo`, the invoking user's key is the one the operator actually generated.
|
|
198
|
+
// Offered as a diagnostic hint only — never resolved implicitly, since silently reading a
|
|
199
|
+
// different user's private key would make the effective identity non-obvious.
|
|
200
|
+
if (process.env.SUDO_USER) candidates.push(`/home/${process.env.SUDO_USER}/.config/sops/age/keys.txt`);
|
|
201
|
+
return [...new Set(candidates)];
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @method keyFile
|
|
206
|
+
* @description Resolves the Age private key path. Honors `SOPS_AGE_KEY_FILE` then
|
|
207
|
+
* `XDG_CONFIG_HOME`, matching sops' own resolution so key location has a single source of
|
|
208
|
+
* truth. Deliberately resolves a *path*, never key material — `SOPS_AGE_KEY` would expose the
|
|
209
|
+
* private key in `/proc/<pid>/environ` and in any process listing.
|
|
210
|
+
* @returns {string} Path to the Age private key file (may not exist yet).
|
|
211
|
+
* @memberof UnderpostSecret
|
|
212
|
+
*/
|
|
213
|
+
keyFile() {
|
|
214
|
+
if (process.env.SOPS_AGE_KEY_FILE) return process.env.SOPS_AGE_KEY_FILE;
|
|
215
|
+
if (process.env.XDG_CONFIG_HOME) return `${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`;
|
|
216
|
+
return `${os.homedir()}/.config/sops/age/keys.txt`;
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @method assertKeyFile
|
|
221
|
+
* @description Resolves the Age private key and refuses to proceed unless it exists and is
|
|
222
|
+
* unreadable by group/other. A key at mode 0644 is a disclosed key, so this fails closed
|
|
223
|
+
* rather than warning. When the resolved path is missing it names every candidate checked,
|
|
224
|
+
* including the invoking user's home under `sudo`, so the identity mismatch is diagnosable
|
|
225
|
+
* instead of presenting as a decrypt failure.
|
|
226
|
+
* @returns {string} Verified key file path.
|
|
227
|
+
* @memberof UnderpostSecret
|
|
228
|
+
*/
|
|
229
|
+
assertKeyFile() {
|
|
230
|
+
const keyFile = Underpost.secret.sops.keyFile();
|
|
231
|
+
if (!fs.existsSync(keyFile)) {
|
|
232
|
+
const alternatives = Underpost.secret.sops
|
|
233
|
+
.keyFileCandidates()
|
|
234
|
+
.filter((candidate) => candidate !== keyFile && fs.existsSync(candidate));
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Age private key not found: ${keyFile} (running as uid ${process.getuid?.() ?? '?'})` +
|
|
237
|
+
(alternatives.length
|
|
238
|
+
? `. A key does exist at ${alternatives.join(', ')} — re-run with ` +
|
|
239
|
+
`SOPS_AGE_KEY_FILE=<path>, or copy it to ${keyFile}.`
|
|
240
|
+
: `. Run: underpost secret sops --init`),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const mode = fs.statSync(keyFile).mode & 0o777;
|
|
244
|
+
if (mode & 0o077)
|
|
245
|
+
throw new Error(
|
|
246
|
+
`Age private key ${keyFile} is group/world accessible (mode ${mode.toString(8)}). ` +
|
|
247
|
+
`Run: chmod 600 ${keyFile}`,
|
|
248
|
+
);
|
|
249
|
+
return keyFile;
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @method managedSecrets
|
|
254
|
+
* @description Names of every Secret wired to prefer the encrypted store, with the origin
|
|
255
|
+
* seed path as fallback. Used for coverage reporting.
|
|
256
|
+
* @returns {Array<string>} Managed Secret names.
|
|
257
|
+
* @memberof UnderpostSecret
|
|
258
|
+
*/
|
|
259
|
+
managedSecrets() {
|
|
260
|
+
return [...MANAGED_SECRETS];
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @method seedSources
|
|
265
|
+
* @description Origin seed files a secret can be onboarded from, as `{ dataKey: path }`.
|
|
266
|
+
* The mapping is the contract between the plaintext seeding in cluster init
|
|
267
|
+
* (`--from-file=<key>=<path>`) and the keys a workload's `secretKeyRef` expects, so an
|
|
268
|
+
* onboarded manifest carries exactly the keys the workload already reads.
|
|
269
|
+
* @param {string} name - Secret name (e.g. 'postgres-secret').
|
|
270
|
+
* @returns {Object<string, string>} Data key to seed file path; empty for unknown secrets.
|
|
271
|
+
* @memberof UnderpostSecret
|
|
272
|
+
*/
|
|
273
|
+
seedSources(name) {
|
|
274
|
+
return { ...(ORIGIN_SEED_SOURCES[name] || {}) };
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @method manifestPath
|
|
279
|
+
* @description Builds the canonical store path for an encrypted Secret manifest.
|
|
280
|
+
* @param {string} name - Secret name (e.g. 'postgres-secret').
|
|
281
|
+
* @param {string} [namespace='default'] - Kubernetes namespace.
|
|
282
|
+
* @returns {string} Path to the `.enc.yaml` manifest.
|
|
283
|
+
* @memberof UnderpostSecret
|
|
284
|
+
*/
|
|
285
|
+
manifestPath(name, namespace = 'default') {
|
|
286
|
+
return `${SOPS_SECRETS_DIR}/${namespace}/${name}${SOPS_MANIFEST_EXT}`;
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* @method has
|
|
291
|
+
* @description Reports whether an encrypted manifest exists for a secret. Lets callers
|
|
292
|
+
* prefer the SOPS store while keeping the origin seed path for clusters not yet onboarded.
|
|
293
|
+
* Existence only — integrity is {@link assertManifest}'s job, so a corrupt manifest is a
|
|
294
|
+
* hard failure rather than a silent slide back to the seed path.
|
|
295
|
+
* @param {string} name - Secret name.
|
|
296
|
+
* @param {string} [namespace='default'] - Kubernetes namespace.
|
|
297
|
+
* @returns {boolean} True when the encrypted manifest is present.
|
|
298
|
+
* @memberof UnderpostSecret
|
|
299
|
+
*/
|
|
300
|
+
has(name, namespace = 'default') {
|
|
301
|
+
return fs.existsSync(Underpost.secret.sops.manifestPath(name, namespace));
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* @method manifestMeta
|
|
306
|
+
* @description Reads the unencrypted envelope of a stored manifest: `kind`, `metadata.name`,
|
|
307
|
+
* `metadata.namespace`, and whether a `sops:` block is present. `encrypted_regex` leaves all
|
|
308
|
+
* of this in plaintext by design, so the check needs no private key and can run before any
|
|
309
|
+
* decrypt is attempted.
|
|
310
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
311
|
+
* @returns {{kind: string, name: string, namespace: string, encrypted: boolean}} Envelope facts.
|
|
312
|
+
* @memberof UnderpostSecret
|
|
313
|
+
*/
|
|
314
|
+
manifestMeta(manifestPath) {
|
|
315
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
316
|
+
const field = (pattern) => (content.match(pattern) || [])[1] || '';
|
|
317
|
+
return {
|
|
318
|
+
kind: field(/^kind:\s*(\S+)/m),
|
|
319
|
+
name: field(/^\s{2,}name:\s*(\S+)/m),
|
|
320
|
+
namespace: field(/^\s{2,}namespace:\s*(\S+)/m),
|
|
321
|
+
encrypted: /^sops:/m.test(content) && /ENC\[AES256_GCM/.test(content),
|
|
322
|
+
};
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @method assertManifest
|
|
327
|
+
* @description Fails closed on a manifest that exists but is not what the caller asked for.
|
|
328
|
+
* Guards three silent-failure modes that a plain existence check misses: a plaintext file
|
|
329
|
+
* that was never encrypted (credential disclosure in Git), a non-Secret resource, and a
|
|
330
|
+
* name/namespace mismatch — the last of which would otherwise apply cleanly while leaving
|
|
331
|
+
* the workload's `secretKeyRef` permanently unresolvable.
|
|
332
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
333
|
+
* @param {object} [expect={}] - Expected envelope.
|
|
334
|
+
* @param {string} [expect.name] - Required `metadata.name`.
|
|
335
|
+
* @param {string} [expect.namespace] - Required `metadata.namespace` when the manifest sets one.
|
|
336
|
+
* @memberof UnderpostSecret
|
|
337
|
+
*/
|
|
338
|
+
assertManifest(manifestPath, expect = {}) {
|
|
339
|
+
if (!fs.existsSync(manifestPath)) throw new Error(`Encrypted manifest not found: ${manifestPath}`);
|
|
340
|
+
const meta = Underpost.secret.sops.manifestMeta(manifestPath);
|
|
341
|
+
if (!meta.encrypted)
|
|
342
|
+
throw new Error(
|
|
343
|
+
`${manifestPath} carries no sops metadata — it is not encrypted. Refusing to apply; ` +
|
|
344
|
+
`treat any credential in it as disclosed and rotate it.`,
|
|
345
|
+
);
|
|
346
|
+
if (meta.kind && meta.kind !== 'Secret') throw new Error(`${manifestPath} is a ${meta.kind}, not a Secret`);
|
|
347
|
+
if (expect.name && meta.name && meta.name !== expect.name)
|
|
348
|
+
throw new Error(
|
|
349
|
+
`${manifestPath} declares metadata.name "${meta.name}" but is stored as "${expect.name}". ` +
|
|
350
|
+
`Applying it would leave secretKeyRef "${expect.name}" unresolved.`,
|
|
351
|
+
);
|
|
352
|
+
if (expect.namespace && meta.namespace && meta.namespace !== expect.namespace)
|
|
353
|
+
throw new Error(
|
|
354
|
+
`${manifestPath} declares metadata.namespace "${meta.namespace}" but is being applied to ` +
|
|
355
|
+
`"${expect.namespace}".`,
|
|
356
|
+
);
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* @method localRecipients
|
|
361
|
+
* @description Every Age recipient this host holds a private key for. A key file may carry
|
|
362
|
+
* more than one identity — that is exactly how a host joins a store it did not create, by
|
|
363
|
+
* appending the origin host's key alongside its own — so this returns all of them rather
|
|
364
|
+
* than assuming one. Never throws: an absent or unreadable key file is a legitimate state
|
|
365
|
+
* for a host that has not been onboarded yet, reported as an empty set.
|
|
366
|
+
* @returns {Array<string>} The `age1…` recipients derived from the local key file.
|
|
367
|
+
* @memberof UnderpostSecret
|
|
368
|
+
*/
|
|
369
|
+
localRecipients() {
|
|
370
|
+
const keyFile = Underpost.secret.sops.keyFile();
|
|
371
|
+
if (!fs.existsSync(keyFile)) return [];
|
|
372
|
+
const output = shellExec(`age-keygen -y "${keyFile}"`, {
|
|
373
|
+
stdout: true,
|
|
374
|
+
silent: true,
|
|
375
|
+
silentOnError: true,
|
|
376
|
+
disableLog: true,
|
|
377
|
+
});
|
|
378
|
+
return [...new Set(`${output || ''}`.match(/age1[0-9a-z]+/g) || [])];
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @method recipient
|
|
383
|
+
* @description Derives the primary Age public recipient from the private key — the one new
|
|
384
|
+
* manifests are encrypted to. The reverse is not possible, so this is safe to log and to
|
|
385
|
+
* commit into `.sops.yaml`.
|
|
386
|
+
* @returns {string} The `age1…` public recipient.
|
|
387
|
+
* @memberof UnderpostSecret
|
|
388
|
+
*/
|
|
389
|
+
recipient() {
|
|
390
|
+
const keyFile = Underpost.secret.sops.keyFile();
|
|
391
|
+
if (!fs.existsSync(keyFile)) throw new Error(`Age private key not found: ${keyFile}`);
|
|
392
|
+
const recipients = Underpost.secret.sops.localRecipients();
|
|
393
|
+
if (recipients.length === 0)
|
|
394
|
+
throw new Error(`No Age identity could be read from ${keyFile}. Run: underpost secret sops --init`);
|
|
395
|
+
return recipients[0];
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* @method decryptable
|
|
400
|
+
* @description Reports whether the local key can open a stored manifest, by set-intersecting
|
|
401
|
+
* the manifest's plaintext `sops:` recipients with the identities this host holds. Needs no
|
|
402
|
+
* decrypt attempt and no private key material, so it is safe to call as a pre-flight on every
|
|
403
|
+
* manifest before the first mutation.
|
|
404
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
405
|
+
* @param {Array<string>} [held] - Locally held recipients; resolved from the key file when omitted.
|
|
406
|
+
* @returns {boolean} True when at least one recipient of the manifest is held locally.
|
|
407
|
+
* @memberof UnderpostSecret
|
|
408
|
+
*/
|
|
409
|
+
decryptable(manifestPath, held = Underpost.secret.sops.localRecipients()) {
|
|
410
|
+
if (held.length === 0) return false;
|
|
411
|
+
return Underpost.secret.sops.manifestRecipients(manifestPath).some((recipient) => held.includes(recipient));
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @method assertDecryptable
|
|
416
|
+
* @description Fails closed, and legibly, on the store-adoption trap: a host that pulled an
|
|
417
|
+
* encrypted store created elsewhere holds a key that is not among the manifests' recipients.
|
|
418
|
+
* sops reports that as "no identity matched any of the recipients" from inside a decrypt
|
|
419
|
+
* pipe, which names neither the manifest nor a way out; this raises first, listing every
|
|
420
|
+
* unreadable manifest, the recipients it is sealed to, the identities this host actually
|
|
421
|
+
* holds, and the three ways to resolve it.
|
|
422
|
+
* @param {Array<{namespace: string, name: string, path: string}>} manifests - Manifests to check.
|
|
423
|
+
* @memberof UnderpostSecret
|
|
424
|
+
*/
|
|
425
|
+
assertDecryptable(manifests) {
|
|
426
|
+
const held = Underpost.secret.sops.localRecipients();
|
|
427
|
+
const unreadable = manifests.filter((manifest) => !Underpost.secret.sops.decryptable(manifest.path, held));
|
|
428
|
+
if (unreadable.length === 0) return;
|
|
429
|
+
const local =
|
|
430
|
+
held.length > 0 ? held.join(', ') : `(none — no readable Age identity at ${Underpost.secret.sops.keyFile()})`;
|
|
431
|
+
throw new Error(
|
|
432
|
+
`${unreadable.length} encrypted manifest(s) are sealed to Age recipients this host does not hold, ` +
|
|
433
|
+
`so they cannot be decrypted here:\n` +
|
|
434
|
+
unreadable
|
|
435
|
+
.map(
|
|
436
|
+
(manifest) =>
|
|
437
|
+
` ${manifest.namespace}/${manifest.name} -> ` +
|
|
438
|
+
`${Underpost.secret.sops.manifestRecipients(manifest.path).join(', ') || 'no age recipients'}`,
|
|
439
|
+
)
|
|
440
|
+
.join('\n') +
|
|
441
|
+
`\n this host holds: ${local}\n` +
|
|
442
|
+
`Resolve with exactly one of:\n` +
|
|
443
|
+
` 1. Install the key that already opens them — append the origin host's ` +
|
|
444
|
+
`${Underpost.secret.sops.keyFile()} to this host's own (one file may hold several identities), ` +
|
|
445
|
+
`chmod 600 it, then re-run.\n` +
|
|
446
|
+
` 2. Re-key the store from a host that still holds that key: ` +
|
|
447
|
+
`underpost secret sops --rotate --recipient <this host's recipient>, commit engine-private/secrets, ` +
|
|
448
|
+
`pull here, then re-run.\n` +
|
|
449
|
+
` 3. Re-onboard from this host's origin seed files, replacing the stored manifests: ` +
|
|
450
|
+
`underpost run sops-setup --force. Valid only when those seed files carry the credentials the ` +
|
|
451
|
+
`cluster already runs on — any regenerated value must also be applied to the running datastore.`,
|
|
452
|
+
);
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* @method init
|
|
457
|
+
* @description Generates the Age keypair and the `.sops.yaml` creation rule when absent.
|
|
458
|
+
* Idempotent, and never overwrites an existing key: regenerating would orphan every manifest
|
|
459
|
+
* already encrypted to the previous recipient, with no way to recover them.
|
|
460
|
+
* @memberof UnderpostSecret
|
|
461
|
+
*/
|
|
462
|
+
init() {
|
|
463
|
+
Underpost.secret.sops.assertTooling(['age-keygen', 'sops']);
|
|
464
|
+
const keyFile = Underpost.secret.sops.keyFile();
|
|
465
|
+
if (fs.existsSync(keyFile)) logger.info(`Age key already present; reusing ${keyFile}`);
|
|
466
|
+
else {
|
|
467
|
+
fs.ensureDirSync(keyFile.slice(0, keyFile.lastIndexOf('/')));
|
|
468
|
+
shellExec(`umask 077 && age-keygen -o "${keyFile}"`);
|
|
469
|
+
}
|
|
470
|
+
shellExec(`chmod 600 "${keyFile}"`);
|
|
471
|
+
|
|
472
|
+
const recipient = Underpost.secret.sops.recipient();
|
|
473
|
+
const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
474
|
+
if (fs.existsSync(sopsConfPath)) {
|
|
475
|
+
logger.info(`Creation rules already present; leaving ${sopsConfPath} intact`);
|
|
476
|
+
Underpost.secret.sops.repairCreationRules();
|
|
477
|
+
Underpost.secret.sops.ensureCreationRecipient(recipient);
|
|
478
|
+
} else {
|
|
479
|
+
fs.outputFileSync(
|
|
480
|
+
sopsConfPath,
|
|
481
|
+
[
|
|
482
|
+
'creation_rules:',
|
|
483
|
+
` - path_regex: ${SOPS_MANIFEST_PATH_REGEX}`,
|
|
484
|
+
` encrypted_regex: '${SOPS_ENCRYPTED_REGEX}'`,
|
|
485
|
+
` age: ${recipient}`,
|
|
486
|
+
'',
|
|
487
|
+
].join('\n'),
|
|
488
|
+
'utf8',
|
|
489
|
+
);
|
|
490
|
+
logger.info(`Created ${sopsConfPath}`);
|
|
491
|
+
}
|
|
492
|
+
logger.info(`Age recipient: ${recipient}`);
|
|
493
|
+
logger.warn(`Back up ${keyFile} offline. Without it every encrypted manifest is unrecoverable.`);
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* @method ensureCreationRecipient
|
|
498
|
+
* @description Registers this host's recipient in an inherited `.sops.yaml` so anything it
|
|
499
|
+
* encrypts from now on, it can also decrypt. Without this, a host that pulled a store created
|
|
500
|
+
* elsewhere encrypts to the *other* host's recipient only, producing manifests it cannot read
|
|
501
|
+
* back — a failure that surfaces later as an opaque decrypt error rather than at write time.
|
|
502
|
+
*
|
|
503
|
+
* Strictly additive: no existing recipient loses access, and existing manifests are left
|
|
504
|
+
* untouched, since re-keying them requires a private key that can still decrypt (see
|
|
505
|
+
* {@link rotate}). Left alone when the rule lists no `age:` recipients at all, which means a
|
|
506
|
+
* deliberately non-Age rule rather than a store this host should join.
|
|
507
|
+
* @param {string} recipient - This host's `age1…` public recipient.
|
|
508
|
+
* @returns {boolean} True when the creation rule was rewritten.
|
|
509
|
+
* @memberof UnderpostSecret
|
|
510
|
+
*/
|
|
511
|
+
ensureCreationRecipient(recipient) {
|
|
512
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
513
|
+
if (!fs.existsSync(confPath) || !recipient) return false;
|
|
514
|
+
const current = Underpost.secret.sops.creationRecipients();
|
|
515
|
+
if (current.includes(recipient)) return false;
|
|
516
|
+
if (current.length === 0) {
|
|
517
|
+
logger.warn(
|
|
518
|
+
`${confPath} declares no age recipients; leaving it untouched. Add ${recipient} manually if this ` +
|
|
519
|
+
`host is meant to encrypt into this store.`,
|
|
520
|
+
);
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
Underpost.secret.sops.writeCreationRecipients([...current, recipient]);
|
|
524
|
+
logger.warn(
|
|
525
|
+
`Registered this host's recipient in ${confPath} so manifests it encrypts stay readable here. ` +
|
|
526
|
+
`Existing manifests are NOT re-keyed by this — run \`underpost secret sops --rotate --recipient ` +
|
|
527
|
+
`${recipient}\` from a host that can still decrypt them, then commit ${SOPS_SECRETS_DIR}.`,
|
|
528
|
+
{ added: recipient, recipients: [...current, recipient] },
|
|
529
|
+
);
|
|
530
|
+
return true;
|
|
531
|
+
},
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* @method repairCreationRules
|
|
535
|
+
* @description Rewrites a `path_regex` that can never match, in place, preserving recipients
|
|
536
|
+
* and every other setting. Configs written before the relative-path semantics were understood
|
|
537
|
+
* carry an `engine-private/secrets/` prefix; because sops matches relative to the directory
|
|
538
|
+
* holding `.sops.yaml`, that rule matches nothing and every encrypt fails with
|
|
539
|
+
* "no matching creation rules found". Repairs only that known-broken form, so a deliberately
|
|
540
|
+
* customized rule is left alone.
|
|
541
|
+
* @returns {boolean} True when the file was rewritten.
|
|
542
|
+
* @memberof UnderpostSecret
|
|
543
|
+
*/
|
|
544
|
+
repairCreationRules() {
|
|
545
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
546
|
+
if (!fs.existsSync(confPath)) return false;
|
|
547
|
+
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
548
|
+
// The rule is the first key of a YAML list item, so the line carries a `- ` marker that
|
|
549
|
+
// has to be preserved: ` - path_regex: …`.
|
|
550
|
+
const brokenRule = /^(\s*(?:-\s*)?)path_regex:\s*.*engine-private\/secrets\//;
|
|
551
|
+
const index = lines.findIndex((line) => brokenRule.test(line));
|
|
552
|
+
if (index === -1) return false;
|
|
553
|
+
const prefix = lines[index].match(brokenRule)[1];
|
|
554
|
+
const previous = lines[index].trim();
|
|
555
|
+
lines[index] = `${prefix}path_regex: ${SOPS_MANIFEST_PATH_REGEX}`;
|
|
556
|
+
fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
|
|
557
|
+
logger.warn(
|
|
558
|
+
`Repaired an unmatchable creation rule in ${confPath}: sops matches path_regex relative to ` +
|
|
559
|
+
`that file's own directory, so the store prefix never matched.`,
|
|
560
|
+
{ from: previous, to: `path_regex: ${SOPS_MANIFEST_PATH_REGEX}` },
|
|
561
|
+
);
|
|
562
|
+
return true;
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* @method encrypt
|
|
567
|
+
* @description Encrypts a plaintext Secret manifest into the Git-tracked store and shreds the
|
|
568
|
+
* source. Recipients resolve from the committed `.sops.yaml`, so a manifest cannot be
|
|
569
|
+
* encrypted to an unlisted key. Author the plaintext under `/dev/shm` so it never touches
|
|
570
|
+
* persistent storage.
|
|
571
|
+
* Written via a staged temp file and moved into place only after the output validates. A
|
|
572
|
+
* bare `sops … > out` redirect has the shell truncate `out` before sops runs, so a failed
|
|
573
|
+
* encrypt would destroy an existing manifest and leave a zero-byte file in its place.
|
|
574
|
+
* @param {string} plaintextPath - Path to the plaintext Secret manifest.
|
|
575
|
+
* @param {string} [namespace='default'] - Target namespace directory in the store.
|
|
576
|
+
* @param {object} [options={}] - Encryption options.
|
|
577
|
+
* @param {boolean} [options.force=false] - Replace an existing manifest at the target path.
|
|
578
|
+
* @returns {string} Path of the written encrypted manifest.
|
|
579
|
+
* @memberof UnderpostSecret
|
|
580
|
+
*/
|
|
581
|
+
encrypt(plaintextPath, namespace = 'default', options = {}) {
|
|
582
|
+
Underpost.secret.sops.assertTooling(['sops']);
|
|
583
|
+
if (!plaintextPath || !fs.existsSync(plaintextPath))
|
|
584
|
+
throw new Error(`Plaintext manifest not found: ${plaintextPath}`);
|
|
585
|
+
const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
586
|
+
if (!fs.existsSync(sopsConfPath))
|
|
587
|
+
throw new Error(`Missing creation rules: ${sopsConfPath} (run: underpost secret sops --init)`);
|
|
588
|
+
|
|
589
|
+
const sourceMeta = Underpost.secret.sops.manifestMeta(plaintextPath);
|
|
590
|
+
if (sourceMeta.encrypted)
|
|
591
|
+
throw new Error(
|
|
592
|
+
`${plaintextPath} already carries sops metadata. Re-encrypting would double-wrap it; ` +
|
|
593
|
+
`edit it in place with: sops ${plaintextPath}`,
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const name = plaintextPath
|
|
597
|
+
.split('/')
|
|
598
|
+
.pop()
|
|
599
|
+
.replace(/\.ya?ml$/, '');
|
|
600
|
+
const outPath = Underpost.secret.sops.manifestPath(name, namespace);
|
|
601
|
+
if (fs.existsSync(outPath) && !options.force)
|
|
602
|
+
throw new Error(`${outPath} already exists. Edit it with \`sops ${outPath}\`, or pass --force to replace.`);
|
|
603
|
+
fs.ensureDirSync(`${SOPS_SECRETS_DIR}/${namespace}`);
|
|
604
|
+
|
|
605
|
+
// Encrypt to a temp file and move into place only on success. A bare `sops … > out` has the
|
|
606
|
+
// shell truncate `out` before sops runs, so a failed encrypt destroys the manifest that was
|
|
607
|
+
// already there and leaves an empty file the apply path would happily skip over.
|
|
608
|
+
const stagePath = `${outPath}.staged`;
|
|
609
|
+
try {
|
|
610
|
+
// `--filename-override` makes sops match creation_rules against the destination path
|
|
611
|
+
// rather than the tmpfs source. Without it the rule never matches, because the plaintext
|
|
612
|
+
// deliberately lives outside the store (in /dev/shm) and is not named `*.enc.yaml`.
|
|
613
|
+
shellExec(
|
|
614
|
+
`sops --config "${sopsConfPath}" --filename-override "${outPath}" ` +
|
|
615
|
+
`--encrypt "${plaintextPath}" > "${stagePath}"`,
|
|
616
|
+
);
|
|
617
|
+
Underpost.secret.sops.assertManifest(stagePath, { name });
|
|
618
|
+
// Sealing to a recipient held elsewhere is legitimate (encrypting *for* another host),
|
|
619
|
+
// so this warns rather than fails — but it is also the shape of the store-adoption trap,
|
|
620
|
+
// where it would otherwise only surface at the next apply.
|
|
621
|
+
if (!Underpost.secret.sops.decryptable(stagePath))
|
|
622
|
+
logger.warn(
|
|
623
|
+
`${outPath} is sealed to ${Underpost.secret.sops.manifestRecipients(stagePath).join(', ')}, none of ` +
|
|
624
|
+
`which this host holds a private key for — it cannot be decrypted here. Add this host's recipient ` +
|
|
625
|
+
`to ${sopsConfPath} and re-encrypt if that is not intended.`,
|
|
626
|
+
);
|
|
627
|
+
fs.moveSync(stagePath, outPath, { overwrite: true });
|
|
628
|
+
} finally {
|
|
629
|
+
fs.removeSync(stagePath);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
shellExec(`shred -u "${plaintextPath}" 2>/dev/null || rm -f "${plaintextPath}"`, { silentOnError: true });
|
|
633
|
+
logger.info(`Encrypted -> ${outPath}`);
|
|
634
|
+
return outPath;
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @method apply
|
|
639
|
+
* @description Decrypts every encrypted manifest for a namespace and streams each one
|
|
640
|
+
* directly into `kubectl apply -f -`. Plaintext exists only in an anonymous kernel pipe.
|
|
641
|
+
* @param {string} [namespace='default'] - Target namespace.
|
|
642
|
+
* @param {object} [options={}] - Apply options.
|
|
643
|
+
* @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
|
|
644
|
+
* @returns {number} Count of manifests applied.
|
|
645
|
+
* @memberof UnderpostSecret
|
|
646
|
+
*/
|
|
647
|
+
apply(namespace = 'default', options = {}) {
|
|
648
|
+
const dir = `${SOPS_SECRETS_DIR}/${namespace}`;
|
|
649
|
+
if (!fs.existsSync(dir)) throw new Error(`No encrypted secrets for namespace: ${namespace}`);
|
|
650
|
+
const manifests = Underpost.secret.sops.manifests(namespace);
|
|
651
|
+
if (manifests.length === 0) throw new Error(`No *${SOPS_MANIFEST_EXT} manifests under ${dir}`);
|
|
652
|
+
|
|
653
|
+
// Validate-then-commit. Applying in a single pass means manifest N failing to decrypt
|
|
654
|
+
// leaves 1..N-1 already live — a half-rotated namespace nobody asked for. The envelope
|
|
655
|
+
// check plus a server dry run of every manifest catches wrong-key, malformed-YAML, schema
|
|
656
|
+
// and RBAC failures before the first mutation.
|
|
657
|
+
for (const manifest of manifests)
|
|
658
|
+
Underpost.secret.sops.assertManifest(manifest.path, { name: manifest.name, namespace });
|
|
659
|
+
Underpost.secret.sops.assertDecryptable(manifests);
|
|
660
|
+
if (!options.dryRun)
|
|
661
|
+
for (const manifest of manifests)
|
|
662
|
+
Underpost.secret.sops.applyManifest(manifest.path, namespace, { ...options, dryRun: true, quiet: true });
|
|
663
|
+
|
|
664
|
+
for (const manifest of manifests) Underpost.secret.sops.applyManifest(manifest.path, namespace, options);
|
|
665
|
+
logger.info(`${options.dryRun ? 'Validated' : 'Applied'} ${manifests.length} manifest(s) in ns/${namespace}`);
|
|
666
|
+
return manifests.length;
|
|
667
|
+
},
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* @method applyManifest
|
|
671
|
+
* @description Streams one encrypted manifest through `sops --decrypt` into `kubectl apply`.
|
|
672
|
+
* Runs under an explicit `bash -c` with `pipefail`, which is load-bearing: without it a sops
|
|
673
|
+
* failure yields an empty stream and `kubectl apply -f -` exits 0, silently applying nothing.
|
|
674
|
+
* `disableLog` keeps the command (and therefore the key path) out of the log stream.
|
|
675
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
676
|
+
* @param {string} [namespace='default'] - Target namespace.
|
|
677
|
+
* @param {object} [options={}] - Apply options.
|
|
678
|
+
* @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
|
|
679
|
+
* @param {string} [options.expectName] - Require this `metadata.name` in the manifest envelope.
|
|
680
|
+
* @param {boolean} [options.quiet=false] - Suppress the per-manifest log line.
|
|
681
|
+
* @memberof UnderpostSecret
|
|
682
|
+
*/
|
|
683
|
+
applyManifest(manifestPath, namespace = 'default', options = {}) {
|
|
684
|
+
// Envelope first: it needs no private key, so a malformed or unencrypted store is reported
|
|
685
|
+
// as such even on a host whose key is missing or wrongly permissioned.
|
|
686
|
+
Underpost.secret.sops.assertManifest(manifestPath, { name: options.expectName, namespace });
|
|
687
|
+
// Recipient set next: a manifest sealed to a key this host does not hold fails inside the
|
|
688
|
+
// decrypt pipe with an error that names neither the file nor a remedy.
|
|
689
|
+
Underpost.secret.sops.assertDecryptable([
|
|
690
|
+
{ namespace, name: options.expectName || manifestPath.split('/').pop(), path: manifestPath },
|
|
691
|
+
]);
|
|
692
|
+
Underpost.secret.sops.assertTooling(['sops']);
|
|
693
|
+
const keyFile = Underpost.secret.sops.assertKeyFile();
|
|
694
|
+
const dryRun = options.dryRun ? ' --dry-run=server' : '';
|
|
695
|
+
shellExec(
|
|
696
|
+
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
|
|
697
|
+
`| kubectl apply -f -${dryRun} -n "${namespace}"'`,
|
|
698
|
+
{ disableLog: true },
|
|
699
|
+
);
|
|
700
|
+
if (!options.quiet) logger.info(`${options.dryRun ? 'Dry-run' : 'Applied'} ${manifestPath} -> ns/${namespace}`);
|
|
701
|
+
},
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* @method applyIfPresent
|
|
705
|
+
* @description Applies a secret from the SOPS store when an encrypted manifest exists,
|
|
706
|
+
* reporting whether it did. Single decision point for callers that keep an origin seed path
|
|
707
|
+
* for clusters not yet onboarded to the encrypted store.
|
|
708
|
+
*
|
|
709
|
+
* Falls back to the seed path only when the manifest is *absent*. A manifest that exists but
|
|
710
|
+
* is corrupt, unencrypted, or names a different Secret raises instead: sliding back to the
|
|
711
|
+
* seed path there would mask a tampered store and silently deploy stale credentials the
|
|
712
|
+
* operator believes were replaced.
|
|
713
|
+
* @param {string} name - Secret name.
|
|
714
|
+
* @param {string} [namespace='default'] - Kubernetes namespace.
|
|
715
|
+
* @param {object} [options={}] - Apply options forwarded to {@link applyManifest}.
|
|
716
|
+
* @returns {boolean} True when the encrypted manifest was applied.
|
|
717
|
+
* @memberof UnderpostSecret
|
|
718
|
+
*/
|
|
719
|
+
applyIfPresent(name, namespace = 'default', options = {}) {
|
|
720
|
+
if (!Underpost.secret.sops.has(name, namespace)) return false;
|
|
721
|
+
Underpost.secret.sops.applyManifest(Underpost.secret.sops.manifestPath(name, namespace), namespace, {
|
|
722
|
+
...options,
|
|
723
|
+
expectName: name,
|
|
724
|
+
});
|
|
725
|
+
return true;
|
|
726
|
+
},
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* @method list
|
|
730
|
+
* @description Lists encrypted manifests with their Age recipients. Reads only the plaintext
|
|
731
|
+
* `sops:` metadata block, so no private key is required and this is safe to run anywhere.
|
|
732
|
+
* @memberof UnderpostSecret
|
|
733
|
+
*/
|
|
734
|
+
list() {
|
|
735
|
+
const manifests = Underpost.secret.sops.manifests();
|
|
736
|
+
if (manifests.length === 0) return logger.warn(`No encrypted manifests under ${SOPS_SECRETS_DIR}`);
|
|
737
|
+
for (const manifest of manifests) {
|
|
738
|
+
const recipients = Underpost.secret.sops.manifestRecipients(manifest.path);
|
|
739
|
+
console.log(
|
|
740
|
+
`${manifest.namespace}/${manifest.name}${SOPS_MANIFEST_EXT} -> ${
|
|
741
|
+
recipients.join(', ') || 'no age recipients'
|
|
742
|
+
}`,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @method manifestRecipients
|
|
749
|
+
* @description Extracts the Age recipients an encrypted manifest is sealed to, from its
|
|
750
|
+
* plaintext `sops:` metadata block. Requires no private key.
|
|
751
|
+
* @param {string} manifestPath - Path to the `.enc.yaml` manifest.
|
|
752
|
+
* @returns {Array<string>} Recipients that can decrypt the manifest.
|
|
753
|
+
* @memberof UnderpostSecret
|
|
754
|
+
*/
|
|
755
|
+
manifestRecipients(manifestPath) {
|
|
756
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
757
|
+
return [...content.matchAll(/recipient:\s*(age1\S+)/g)].map((match) => match[1]);
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* @method rotate
|
|
762
|
+
* @description Re-keys every encrypted manifest onto a new Age recipient after key
|
|
763
|
+
* compromise or scheduled rotation. Secret *values* are untouched: `sops updatekeys` only
|
|
764
|
+
* re-wraps each file's data key, so no workload restart is needed. Requires a private key
|
|
765
|
+
* that can still decrypt, so rotation must run before the outgoing key is destroyed.
|
|
766
|
+
*
|
|
767
|
+
* Additive by default (the outgoing recipient keeps working, which is what a scheduled
|
|
768
|
+
* rotation wants). `options.pruneRecipients` makes the new recipient the only one, which is
|
|
769
|
+
* what a compromise wants — and which also revokes every *other* operator and CI/CD key in
|
|
770
|
+
* the rule, so it additionally requires `options.force` after showing exactly what is lost.
|
|
771
|
+
* @param {string} recipient - Incoming `age1…` public recipient.
|
|
772
|
+
* @param {object} [options={}] - Rotation options.
|
|
773
|
+
* @param {boolean} [options.pruneRecipients=false] - Drop all existing recipients.
|
|
774
|
+
* @param {Array<string>|string} [options.keepRecipients] - Recipients to retain while pruning
|
|
775
|
+
* (e.g. the CI/CD key), as an array or comma-separated list.
|
|
776
|
+
* @param {boolean} [options.force=false] - Confirm an irreversible prune.
|
|
777
|
+
* @param {boolean} [options.dryRun=false] - Report the plan without rewriting anything.
|
|
778
|
+
* @returns {{recipients: Array<string>, revoked: Array<string>, rekeyed: number}} Outcome.
|
|
779
|
+
* @memberof UnderpostSecret
|
|
780
|
+
*/
|
|
781
|
+
rotate(recipient, options = {}) {
|
|
782
|
+
if (!recipient) throw new Error('Rotation requires --recipient <age-public-key>');
|
|
783
|
+
if (!/^age1[0-9a-z]{20,}$/.test(recipient))
|
|
784
|
+
throw new Error(`Not a valid Age public recipient: ${recipient} (expected age1…)`);
|
|
785
|
+
|
|
786
|
+
Underpost.secret.sops.assertTooling(['sops']);
|
|
787
|
+
const keyFile = Underpost.secret.sops.assertKeyFile();
|
|
788
|
+
|
|
789
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
790
|
+
if (!fs.existsSync(confPath))
|
|
791
|
+
throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);
|
|
792
|
+
|
|
793
|
+
const keep = (
|
|
794
|
+
Array.isArray(options.keepRecipients) ? options.keepRecipients : `${options.keepRecipients || ''}`.split(',')
|
|
795
|
+
)
|
|
796
|
+
.map((value) => value.trim())
|
|
797
|
+
.filter(Boolean);
|
|
798
|
+
const current = Underpost.secret.sops.creationRecipients();
|
|
799
|
+
const next = options.pruneRecipients
|
|
800
|
+
? [...new Set([recipient, ...keep])]
|
|
801
|
+
: [...new Set([...current, recipient])];
|
|
802
|
+
const revoked = current.filter((existing) => !next.includes(existing));
|
|
803
|
+
const manifests = Underpost.secret.sops.manifests();
|
|
804
|
+
// `updatekeys` re-wraps each data key, which means decrypting it first. A host that cannot
|
|
805
|
+
// read the store cannot rotate it, dry run included — reporting a plan that can never run
|
|
806
|
+
// is what sends an operator down the wrong remedy.
|
|
807
|
+
Underpost.secret.sops.assertDecryptable(manifests);
|
|
808
|
+
|
|
809
|
+
if (options.dryRun) {
|
|
810
|
+
logger.info('Rotation plan (dry run)', { from: current, to: next, revoked, manifests: manifests.length });
|
|
811
|
+
if (revoked.length)
|
|
812
|
+
logger.warn(
|
|
813
|
+
`${revoked.length} recipient(s) would permanently lose access. Confirm none is a CI/CD or ` +
|
|
814
|
+
`co-operator key before re-running with --force.`,
|
|
815
|
+
{ revoked },
|
|
816
|
+
);
|
|
817
|
+
return { recipients: next, revoked, rekeyed: 0 };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// A prune revokes every recipient not explicitly retained — including CI/CD keys the
|
|
821
|
+
// operator may not have in mind. Irreversible for anyone holding only a revoked key, so it
|
|
822
|
+
// is gated behind an explicit confirmation that has to be made after seeing the list.
|
|
823
|
+
if (revoked.length && !options.force)
|
|
824
|
+
throw new Error(
|
|
825
|
+
`Refusing to revoke ${revoked.length} recipient(s) without --force: ${revoked.join(', ')}. ` +
|
|
826
|
+
`Review with --dry-run, retain any CI/CD key via --keep-recipients <age1…>, then re-run with --force.`,
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
Underpost.secret.sops.writeCreationRecipients(next);
|
|
830
|
+
for (const manifest of manifests) {
|
|
831
|
+
// `updatekeys` decrypts the data key with a held private key and re-wraps it for the
|
|
832
|
+
// recipients now in `.sops.yaml`. disableLog keeps the key path out of the log stream.
|
|
833
|
+
shellExec(
|
|
834
|
+
`bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" ` +
|
|
835
|
+
`sops --config "${confPath}" updatekeys --yes "${manifest.path}"'`,
|
|
836
|
+
{ disableLog: true, silent: true },
|
|
837
|
+
);
|
|
838
|
+
// updatekeys is a no-op when it decides nothing changed, and its exit code does not
|
|
839
|
+
// distinguish that from a successful re-key. Confirm against the file itself, so a
|
|
840
|
+
// rotation can never be reported as done while a manifest stays on the old recipient.
|
|
841
|
+
const sealed = Underpost.secret.sops.manifestRecipients(manifest.path);
|
|
842
|
+
if (!sealed.includes(recipient))
|
|
843
|
+
throw new Error(
|
|
844
|
+
`${manifest.path} is still sealed to ${sealed.join(', ') || 'no recipients'} after updatekeys; ` +
|
|
845
|
+
`expected ${recipient}. Rotation aborted with the store partially re-keyed — re-run once resolved.`,
|
|
846
|
+
);
|
|
847
|
+
logger.info(`Re-keyed ${manifest.namespace}/${manifest.name}`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
logger.info(`Rotated ${manifests.length} manifest(s)`, { recipients: next, revoked });
|
|
851
|
+
if (revoked.length)
|
|
852
|
+
logger.warn(
|
|
853
|
+
`Revoked ${revoked.length} recipient(s); those keys can no longer decrypt any manifest. ` +
|
|
854
|
+
`Every host applying these secrets now needs a private key for one of: ${next.join(', ')}.`,
|
|
855
|
+
{ revoked },
|
|
856
|
+
);
|
|
857
|
+
return { recipients: next, revoked, rekeyed: manifests.length };
|
|
858
|
+
},
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* @method purge
|
|
862
|
+
* @description Emergency removal of one secret: deletes the live Kubernetes Secret and takes
|
|
863
|
+
* its encrypted manifest out of the store. The manifest is archived rather than deleted so
|
|
864
|
+
* the purge stays reversible; `options.force` deletes it outright.
|
|
865
|
+
*
|
|
866
|
+
* Removing the manifest is what re-arms the origin seed path — with no `.enc.yaml`,
|
|
867
|
+
* `applyIfPresent` returns false and cluster init seeds the secret from the plaintext
|
|
868
|
+
* credential files instead. Whether that seed path is actually available is reported, not
|
|
869
|
+
* assumed: purging a secret whose seed files are gone leaves workloads with an unresolvable
|
|
870
|
+
* `secretKeyRef`, so the gap is surfaced at purge time rather than at the next deploy.
|
|
871
|
+
* @param {string} name - Secret name (e.g. 'postgres-secret').
|
|
872
|
+
* @param {object} [options={}] - Purge options.
|
|
873
|
+
* @param {string} [options.namespace='default'] - Namespace of the live Secret.
|
|
874
|
+
* @param {boolean} [options.force=false] - Delete the manifest instead of archiving it.
|
|
875
|
+
* @param {boolean} [options.dryRun=false] - Report what would happen without changing anything.
|
|
876
|
+
* @returns {{deleted: boolean, archived: string, seedFallback: boolean}} Purge outcome.
|
|
877
|
+
* @memberof UnderpostSecret
|
|
878
|
+
*/
|
|
879
|
+
purge(name, options = {}) {
|
|
880
|
+
if (!name) throw new Error('Purge requires a secret name');
|
|
881
|
+
const namespace = options.namespace || 'default';
|
|
882
|
+
const manifestPath = Underpost.secret.sops.manifestPath(name, namespace);
|
|
883
|
+
const seedSources = Object.values(Underpost.secret.sops.seedSources(name));
|
|
884
|
+
const seedFallback = seedSources.length > 0 && seedSources.every((source) => fs.existsSync(source));
|
|
885
|
+
|
|
886
|
+
if (options.dryRun) {
|
|
887
|
+
logger.info('Purge plan (dry run)', {
|
|
888
|
+
secret: `${namespace}/${name}`,
|
|
889
|
+
manifest: fs.existsSync(manifestPath) ? manifestPath : 'absent',
|
|
890
|
+
disposition: options.force ? 'delete' : 'archive',
|
|
891
|
+
seedFallbackAvailable: seedFallback,
|
|
892
|
+
});
|
|
893
|
+
return { deleted: false, archived: '', seedFallback };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
shellExec(`kubectl delete secret ${name} -n ${namespace} --ignore-not-found`);
|
|
897
|
+
|
|
898
|
+
let archived = '';
|
|
899
|
+
if (!fs.existsSync(manifestPath)) logger.warn(`No encrypted manifest to remove at ${manifestPath}`);
|
|
900
|
+
else if (options.force) {
|
|
901
|
+
fs.removeSync(manifestPath);
|
|
902
|
+
logger.warn(`Deleted ${manifestPath}`);
|
|
903
|
+
} else {
|
|
904
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
905
|
+
archived = `${SOPS_ARCHIVE_DIR}/${namespace}/${name}.${stamp}${SOPS_MANIFEST_EXT}`;
|
|
906
|
+
fs.ensureDirSync(`${SOPS_ARCHIVE_DIR}/${namespace}`);
|
|
907
|
+
fs.moveSync(manifestPath, archived);
|
|
908
|
+
logger.info(`Archived ${manifestPath} -> ${archived}`);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (seedFallback)
|
|
912
|
+
logger.info(`Origin seed path is available for ${name}; cluster init will seed from it.`, {
|
|
913
|
+
sources: seedSources,
|
|
914
|
+
});
|
|
915
|
+
else if (seedSources.length > 0)
|
|
916
|
+
logger.warn(
|
|
917
|
+
`No origin seed path for ${name}. Re-encrypt a manifest or create the secret manually ` +
|
|
918
|
+
`before redeploying workloads that mount it.`,
|
|
919
|
+
{ expected: seedSources },
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
return { deleted: true, archived, seedFallback };
|
|
923
|
+
},
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* @method manifests
|
|
927
|
+
* @description Enumerates every encrypted manifest in the store, or in one namespace.
|
|
928
|
+
* Dot-prefixed entries (`.archive`, `.sops.yaml`) are never treated as namespaces.
|
|
929
|
+
* @param {string} [namespace] - Restrict to one namespace; omit for the whole store.
|
|
930
|
+
* @returns {Array<{namespace: string, name: string, path: string}>} Manifest descriptors, sorted.
|
|
931
|
+
* @memberof UnderpostSecret
|
|
932
|
+
*/
|
|
933
|
+
manifests(namespace) {
|
|
934
|
+
if (!fs.existsSync(SOPS_SECRETS_DIR)) return [];
|
|
935
|
+
const namespaces = namespace
|
|
936
|
+
? [namespace]
|
|
937
|
+
: fs
|
|
938
|
+
.readdirSync(SOPS_SECRETS_DIR)
|
|
939
|
+
.filter((entry) => !entry.startsWith('.') && fs.statSync(`${SOPS_SECRETS_DIR}/${entry}`).isDirectory())
|
|
940
|
+
.sort();
|
|
941
|
+
const found = [];
|
|
942
|
+
for (const ns of namespaces) {
|
|
943
|
+
const dir = `${SOPS_SECRETS_DIR}/${ns}`;
|
|
944
|
+
if (!fs.existsSync(dir)) continue;
|
|
945
|
+
for (const file of fs
|
|
946
|
+
.readdirSync(dir)
|
|
947
|
+
.filter((entry) => entry.endsWith(SOPS_MANIFEST_EXT))
|
|
948
|
+
.sort())
|
|
949
|
+
found.push({ namespace: ns, name: file.slice(0, -SOPS_MANIFEST_EXT.length), path: `${dir}/${file}` });
|
|
950
|
+
}
|
|
951
|
+
return found;
|
|
952
|
+
},
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* @method creationRecipients
|
|
956
|
+
* @description Reads the Age recipients from the committed `.sops.yaml` creation rule,
|
|
957
|
+
* accepting both the single-line (`age: k1,k2`) and folded (`age: >-`) forms sops permits.
|
|
958
|
+
* @returns {Array<string>} Recipients currently configured for encryption.
|
|
959
|
+
* @memberof UnderpostSecret
|
|
960
|
+
*/
|
|
961
|
+
creationRecipients() {
|
|
962
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
963
|
+
if (!fs.existsSync(confPath)) return [];
|
|
964
|
+
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
965
|
+
const index = lines.findIndex((line) => /^\s*age:/.test(line));
|
|
966
|
+
if (index === -1) return [];
|
|
967
|
+
const indent = lines[index].match(/^\s*/)[0].length;
|
|
968
|
+
const chunk = [lines[index].replace(/^\s*age:\s*>?-?\s*/, '')];
|
|
969
|
+
for (let i = index + 1; i < lines.length; i++) {
|
|
970
|
+
if (!lines[i].trim()) break;
|
|
971
|
+
if (lines[i].match(/^\s*/)[0].length <= indent) break;
|
|
972
|
+
chunk.push(lines[i].trim());
|
|
973
|
+
}
|
|
974
|
+
return chunk
|
|
975
|
+
.join(',')
|
|
976
|
+
.split(',')
|
|
977
|
+
.map((value) => value.trim())
|
|
978
|
+
.filter(Boolean);
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* @method writeCreationRecipients
|
|
983
|
+
* @description Rewrites the `age:` recipients of the `.sops.yaml` creation rule in place,
|
|
984
|
+
* collapsing any folded form to a single canonical line. Line-scoped on purpose: a YAML
|
|
985
|
+
* round-trip would strip the comments operators keep in this file.
|
|
986
|
+
* @param {Array<string>} recipients - Recipients to encrypt to from now on.
|
|
987
|
+
* @memberof UnderpostSecret
|
|
988
|
+
*/
|
|
989
|
+
writeCreationRecipients(recipients) {
|
|
990
|
+
const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
|
|
991
|
+
if (!fs.existsSync(confPath))
|
|
992
|
+
throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);
|
|
993
|
+
const lines = fs.readFileSync(confPath, 'utf8').split('\n');
|
|
994
|
+
const index = lines.findIndex((line) => /^\s*age:/.test(line));
|
|
995
|
+
if (index === -1) throw new Error(`No 'age:' recipients entry in ${confPath}`);
|
|
996
|
+
const indent = lines[index].match(/^\s*/)[0];
|
|
997
|
+
let end = index + 1;
|
|
998
|
+
while (end < lines.length && lines[end].trim() && lines[end].match(/^\s*/)[0].length > indent.length) end++;
|
|
999
|
+
lines.splice(index, end - index, `${indent}age: ${recipients.join(',')}`);
|
|
1000
|
+
fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
|
|
1001
|
+
},
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* @method hasBinary
|
|
1005
|
+
* @description Reports whether a binary resolves on PATH. Single probe reused by
|
|
1006
|
+
* {@link assertTooling} and {@link installTooling} so both agree on what "installed" means.
|
|
1007
|
+
* @param {string} bin - Binary name.
|
|
1008
|
+
* @returns {boolean} True when the binary is on PATH.
|
|
1009
|
+
* @memberof UnderpostSecret
|
|
1010
|
+
*/
|
|
1011
|
+
hasBinary(bin) {
|
|
1012
|
+
return (
|
|
1013
|
+
shellExec(`command -v ${bin} >/dev/null 2>&1 && echo exists || echo missing`, {
|
|
1014
|
+
stdout: true,
|
|
1015
|
+
silent: true,
|
|
1016
|
+
disableLog: true,
|
|
1017
|
+
}).trim() === 'exists'
|
|
1018
|
+
);
|
|
1019
|
+
},
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* @method assertTooling
|
|
1023
|
+
* @description Fails fast with an actionable message when a required binary is missing,
|
|
1024
|
+
* rather than surfacing an opaque shell exit code mid-apply.
|
|
1025
|
+
* @param {Array<string>} bins - Binaries that must resolve on PATH.
|
|
1026
|
+
* @memberof UnderpostSecret
|
|
1027
|
+
*/
|
|
1028
|
+
assertTooling(bins) {
|
|
1029
|
+
for (const bin of bins)
|
|
1030
|
+
if (!Underpost.secret.sops.hasBinary(bin))
|
|
1031
|
+
throw new Error(`${bin} not found in PATH (install via: underpost secret --install-tools)`);
|
|
1032
|
+
},
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* @method installTooling
|
|
1036
|
+
* @description Installs the `sops` and `age` host binaries from their pinned upstream static
|
|
1037
|
+
* builds. Idempotent: an already-resolvable binary is left untouched, so this is safe to
|
|
1038
|
+
* re-run and safe to call from both the secrets CLI and cluster host initialization.
|
|
1039
|
+
* Verifies both binaries resolve before returning, so a partial install fails loudly here
|
|
1040
|
+
* rather than mid-decrypt.
|
|
1041
|
+
* @returns {{sops: boolean, age: boolean}} Which binaries this run actually installed.
|
|
1042
|
+
* @memberof UnderpostSecret
|
|
1043
|
+
*/
|
|
1044
|
+
installTooling() {
|
|
1045
|
+
const archData = Underpost.baremetal.getHostArch();
|
|
1046
|
+
logger.info('Installing SOPS and Age host tooling...', { ...archData, SOPS_VERSION, AGE_VERSION });
|
|
1047
|
+
const installed = { sops: false, age: false };
|
|
1048
|
+
|
|
1049
|
+
if (Underpost.secret.sops.hasBinary('sops')) logger.info('SOPS is already installed; skipping.');
|
|
1050
|
+
else {
|
|
1051
|
+
shellExec(
|
|
1052
|
+
`curl -fsSL -o /tmp/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${archData.alias}`,
|
|
1053
|
+
);
|
|
1054
|
+
shellExec(`sudo install -m 0755 /tmp/sops /usr/local/bin/sops`);
|
|
1055
|
+
shellExec(`sudo ln -sf /usr/local/bin/sops /bin/sops`);
|
|
1056
|
+
shellExec(`sudo rm -f /tmp/sops`);
|
|
1057
|
+
installed.sops = true;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (Underpost.secret.sops.hasBinary('age-keygen')) logger.info('Age is already installed; skipping.');
|
|
1061
|
+
else {
|
|
1062
|
+
shellExec(
|
|
1063
|
+
`curl -fsSL -o /tmp/age.tar.gz https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-${archData.alias}.tar.gz`,
|
|
1064
|
+
);
|
|
1065
|
+
shellExec(`tar -xzf /tmp/age.tar.gz -C /tmp`);
|
|
1066
|
+
shellExec(`sudo install -m 0755 /tmp/age/age /usr/local/bin/age`);
|
|
1067
|
+
shellExec(`sudo install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen`);
|
|
1068
|
+
shellExec(`sudo ln -sf /usr/local/bin/age /bin/age`);
|
|
1069
|
+
shellExec(`sudo ln -sf /usr/local/bin/age-keygen /bin/age-keygen`);
|
|
1070
|
+
shellExec(`sudo rm -rf /tmp/age /tmp/age.tar.gz`);
|
|
1071
|
+
installed.age = true;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
Underpost.secret.sops.assertTooling(['sops', 'age', 'age-keygen']);
|
|
1075
|
+
logger.info('SOPS and Age tooling ready.', installed);
|
|
1076
|
+
return installed;
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
1079
|
+
|
|
111
1080
|
/**
|
|
112
1081
|
* @method sanitizeSecretEnvFile
|
|
113
1082
|
* @description Strips shell/runtime-critical and Kubernetes-injected keys (PATH, HOME, …) from
|