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.
Files changed (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -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