UNPKG

underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

1,121 lines 56.9 kB
/**
 * Secrets module for managing the secrets of the application.
 * @module src/cli/secrets.js
 * @namespace UnderpostSecret
 */

import { shellExec } from '../server/process.js';
import fs from 'fs-extra';
import os from 'os';
import dotenv from 'dotenv';
import Underpost from '../index.js';
import { loadConf } from '../server/conf.js';
import { loggerFactory } from '../server/logger.js';

const logger = loggerFactory(import.meta);

// Git-tracked store of SOPS/Age encrypted Kubernetes Secret manifests, laid out as
// `<SOPS_SECRETS_DIR>/<namespace>/<name>.enc.yaml`. Lives inside the per-deploy private
// repo so encrypted manifests version alongside the conf they belong to. Only the
// ciphertext is committed; the Age private key never enters this tree.
const SOPS_SECRETS_DIR = './engine-private/secrets';
const SOPS_MANIFEST_EXT = '.enc.yaml';
// `creation_rules[].path_regex` is matched against the manifest path **relative to the directory
// holding .sops.yaml**, not the repo root. Since .sops.yaml lives at the store root, an
// `engine-private/secrets/` prefix here can never match — sops sees `<namespace>/<name>.enc.yaml`
// and reports "no matching creation rules found".
const SOPS_MANIFEST_PATH_REGEX = `.*${SOPS_MANIFEST_EXT.replace(/\./g, '\\.')}$`;
// Purged manifests are moved here rather than deleted, so an emergency purge stays
// reversible. Dot-prefixed so it is never mistaken for a namespace directory.
const SOPS_ARCHIVE_DIR = `${SOPS_SECRETS_DIR}/.archive`;
// Encrypting only the value tree under `data`/`stringData` keeps apiVersion/kind/metadata
// readable, so kubectl, kustomize, and code review still work on the encrypted file.
const SOPS_ENCRYPTED_REGEX = '^(data|stringData)$';
// Pinned SOPS/Age release binaries. Neither has an RHEL package, so both are installed
// from upstream static builds. Single source of truth for host provisioning: both
// `underpost secret --install-tools` and `underpost cluster --init-host` resolve here.
const SOPS_VERSION = 'v3.10.2';
const AGE_VERSION = 'v1.2.1';
// Origin seed paths: the plaintext credential files a secret is seeded from before SOPS/Age
// onboarding, and the path cluster init falls back to when no encrypted manifest exists.
// Keyed by Secret name, then by the Secret data key each file supplies — stated explicitly
// rather than derived from the filename, because `mongodb-keyfile` supplies a key of that same
// full name while `postgresql-password` supplies `password`.
// Mirrors the seed-fallback branches in UnderpostCluster.API.init() and
// MongoBootstrap.ensureMongoSecrets(); kept here so a purge can report whether the cluster
// still has a working seed path for that secret.
// Every Secret whose creation goes through `applyIfPresent` — i.e. the encrypted store is
// consulted first and the origin seed path is the fallback. Single source of truth for coverage
// reporting; keep in step with the call sites in UnderpostCluster.API.init(),
// MongoBootstrap.ensureMongoSecrets(), and UnderpostIPFS.applySecrets().
const MANAGED_SECRETS = [
  'postgres-secret',
  'mariadb-secret',
  'mysql-secret',
  'mongodb-secret',
  'mongodb-keyfile',
  'ipfs-cluster-secret',
];
const ORIGIN_SEED_SOURCES = {
  'mariadb-secret': {
    username: './engine-private/mariadb-username',
    password: './engine-private/mariadb-password',
  },
  'mysql-secret': {
    username: './engine-private/mysql-username',
    password: './engine-private/mysql-password',
  },
  'postgres-secret': { password: './engine-private/postgresql-password' },
  'mongodb-secret': {
    username: './engine-private/mongodb-username',
    password: './engine-private/mongodb-password',
  },
  // Shared replica-set auth keyfile, mounted as a volume rather than injected as env.
  'mongodb-keyfile': { 'mongodb-keyfile': './engine-private/mongodb-keyfile' },
};

// Shell/runtime-critical and Kubernetes-injected env keys that must never be persisted as
// application secrets nor injected into a pod via `envFrom`. An injected PATH (or HOME, etc.)
// overrides the container image's own and breaks coreutils/sudo resolution inside the pod
// ("rm: command not found"). Single source of truth for both container-env capture and the
// `underpost-config` secret built from an env file.
const RESERVED_ENV_KEYS = new Set([
  'HOME',
  'HOSTNAME',
  'PATH',
  'TERM',
  'SHLVL',
  'PWD',
  '_',
  'LANG',
  'LANGUAGE',
  'LC_ALL',
  'container',
  'SHELL',
  'USER',
  'LOGNAME',
  'MAIL',
  'OLDPWD',
  'LESSOPEN',
  'LESSCLOSE',
  'LS_COLORS',
  'DISPLAY',
  'COLORTERM',
  'EDITOR',
  'VISUAL',
  'TERM_PROGRAM',
  'TERM_PROGRAM_VERSION',
  'SSH_AUTH_SOCK',
  'SSH_CLIENT',
  'SSH_CONNECTION',
  'SSH_TTY',
  'XDG_SESSION_ID',
  'XDG_RUNTIME_DIR',
  'XDG_DATA_DIRS',
  'XDG_CONFIG_DIRS',
  'DBUS_SESSION_BUS_ADDRESS',
  'GPG_AGENT_INFO',
  'WINDOWID',
  'DESKTOP_SESSION',
  'SESSION_MANAGER',
  'XAUTHORITY',
  'WAYLAND_DISPLAY',
  'which_declare',
]);
const RESERVED_ENV_KEY_PREFIXES = ['KUBERNETES_', 'npm_', 'NODE_'];
const isReservedEnvKey = (key) =>
  RESERVED_ENV_KEYS.has(key) || RESERVED_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));

/**
 * @class UnderpostSecret
 * @description Manages the secrets of the application.
 * @memberof UnderpostSecret
 */
class UnderpostSecret {
  static API = {
    /**
     * @method underpost
     * @description Manages the secrets of the application.
     * @memberof UnderpostSecret
     */
    underpost: {
      /**
       * @method createFromEnvFile
       * @description Reads application secrets from a .env file and writes them to the underpost .env file. Used for local development and testing.
       * @param {string} envPath - The path to the .env file to read secrets from. Defaults to './.env'.
       * @memberof UnderpostSecret
       */
      createFromEnvFile(envPath = './.env') {
        Underpost.env.clean();
        const envObj = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
        for (const key of Object.keys(envObj)) {
          Underpost.env.set(key, envObj[key]);
        }
      },
      /**
       * @method createFromContainerEnv
       * @description Reads application secrets from process.env (injected via envFrom: secretRef)
       *  and writes them to the underpost .env file, filtering out known system and
       *  Kubernetes-injected environment variables. Replaces the fragile shell-based
       *  `printenv | grep -vE` pattern with a maintainable Node.js blocklist.
       * @memberof UnderpostSecret
       */
      createFromContainerEnv() {
        Underpost.env.clean();
        for (const [key, value] of Object.entries(process.env)) {
          if (isReservedEnvKey(key)) continue;
          Underpost.env.set(key, value);
        }
      },
    },

    /**
     * @method sops
     * @description Git-native encrypted secret management backed by Mozilla SOPS and Age, for
     * fully self-hosted clusters with no cloud KMS or external secret store. Encrypted manifests
     * live in `engine-private/secrets/<namespace>/<name>.enc.yaml` and are safe to commit; the Age
     * private key stays at `~/.config/sops/age/keys.txt` (or `$SOPS_AGE_KEY_FILE`) and is never
     * committed, rendered into a manifest, or shipped into a container. Decryption is always
     * streamed straight into `kubectl apply -f -`, so plaintext never reaches persistent storage.
     * @memberof UnderpostSecret
     */
    sops: {
      /**
       * @method keyFileCandidates
       * @description Ordered paths the Age private key is looked for, matching what sops itself
       * resolves. Surfaced separately so a "key not found" error can name every location tried —
       * the identity-context trap is a key generated as an unprivileged user but read back under
       * `sudo`, where `os.homedir()` points at root's home instead.
       * @returns {Array<string>} Candidate paths, highest precedence first.
       * @memberof UnderpostSecret
       */
      keyFileCandidates() {
        const candidates = [];
        if (process.env.SOPS_AGE_KEY_FILE) candidates.push(process.env.SOPS_AGE_KEY_FILE);
        if (process.env.XDG_CONFIG_HOME) candidates.push(`${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`);
        candidates.push(`${os.homedir()}/.config/sops/age/keys.txt`);
        // Under `sudo`, the invoking user's key is the one the operator actually generated.
        // Offered as a diagnostic hint only — never resolved implicitly, since silently reading a
        // different user's private key would make the effective identity non-obvious.
        if (process.env.SUDO_USER) candidates.push(`/home/${process.env.SUDO_USER}/.config/sops/age/keys.txt`);
        return [...new Set(candidates)];
      },

      /**
       * @method keyFile
       * @description Resolves the Age private key path. Honors `SOPS_AGE_KEY_FILE` then
       * `XDG_CONFIG_HOME`, matching sops' own resolution so key location has a single source of
       * truth. Deliberately resolves a *path*, never key material — `SOPS_AGE_KEY` would expose the
       * private key in `/proc/<pid>/environ` and in any process listing.
       * @returns {string} Path to the Age private key file (may not exist yet).
       * @memberof UnderpostSecret
       */
      keyFile() {
        if (process.env.SOPS_AGE_KEY_FILE) return process.env.SOPS_AGE_KEY_FILE;
        if (process.env.XDG_CONFIG_HOME) return `${process.env.XDG_CONFIG_HOME}/sops/age/keys.txt`;
        return `${os.homedir()}/.config/sops/age/keys.txt`;
      },

      /**
       * @method assertKeyFile
       * @description Resolves the Age private key and refuses to proceed unless it exists and is
       * unreadable by group/other. A key at mode 0644 is a disclosed key, so this fails closed
       * rather than warning. When the resolved path is missing it names every candidate checked,
       * including the invoking user's home under `sudo`, so the identity mismatch is diagnosable
       * instead of presenting as a decrypt failure.
       * @returns {string} Verified key file path.
       * @memberof UnderpostSecret
       */
      assertKeyFile() {
        const keyFile = Underpost.secret.sops.keyFile();
        if (!fs.existsSync(keyFile)) {
          const alternatives = Underpost.secret.sops
            .keyFileCandidates()
            .filter((candidate) => candidate !== keyFile && fs.existsSync(candidate));
          throw new Error(
            `Age private key not found: ${keyFile} (running as uid ${process.getuid?.() ?? '?'})` +
              (alternatives.length
                ? `. A key does exist at ${alternatives.join(', ')} — re-run with ` +
                  `SOPS_AGE_KEY_FILE=<path>, or copy it to ${keyFile}.`
                : `. Run: underpost secret sops --init`),
          );
        }
        const mode = fs.statSync(keyFile).mode & 0o777;
        if (mode & 0o077)
          throw new Error(
            `Age private key ${keyFile} is group/world accessible (mode ${mode.toString(8)}). ` +
              `Run: chmod 600 ${keyFile}`,
          );
        return keyFile;
      },

      /**
       * @method managedSecrets
       * @description Names of every Secret wired to prefer the encrypted store, with the origin
       * seed path as fallback. Used for coverage reporting.
       * @returns {Array<string>} Managed Secret names.
       * @memberof UnderpostSecret
       */
      managedSecrets() {
        return [...MANAGED_SECRETS];
      },

      /**
       * @method seedSources
       * @description Origin seed files a secret can be onboarded from, as `{ dataKey: path }`.
       * The mapping is the contract between the plaintext seeding in cluster init
       * (`--from-file=<key>=<path>`) and the keys a workload's `secretKeyRef` expects, so an
       * onboarded manifest carries exactly the keys the workload already reads.
       * @param {string} name - Secret name (e.g. 'postgres-secret').
       * @returns {Object<string, string>} Data key to seed file path; empty for unknown secrets.
       * @memberof UnderpostSecret
       */
      seedSources(name) {
        return { ...(ORIGIN_SEED_SOURCES[name] || {}) };
      },

      /**
       * @method manifestPath
       * @description Builds the canonical store path for an encrypted Secret manifest.
       * @param {string} name - Secret name (e.g. 'postgres-secret').
       * @param {string} [namespace='default'] - Kubernetes namespace.
       * @returns {string} Path to the `.enc.yaml` manifest.
       * @memberof UnderpostSecret
       */
      manifestPath(name, namespace = 'default') {
        return `${SOPS_SECRETS_DIR}/${namespace}/${name}${SOPS_MANIFEST_EXT}`;
      },

      /**
       * @method has
       * @description Reports whether an encrypted manifest exists for a secret. Lets callers
       * prefer the SOPS store while keeping the origin seed path for clusters not yet onboarded.
       * Existence only — integrity is {@link assertManifest}'s job, so a corrupt manifest is a
       * hard failure rather than a silent slide back to the seed path.
       * @param {string} name - Secret name.
       * @param {string} [namespace='default'] - Kubernetes namespace.
       * @returns {boolean} True when the encrypted manifest is present.
       * @memberof UnderpostSecret
       */
      has(name, namespace = 'default') {
        return fs.existsSync(Underpost.secret.sops.manifestPath(name, namespace));
      },

      /**
       * @method manifestMeta
       * @description Reads the unencrypted envelope of a stored manifest: `kind`, `metadata.name`,
       * `metadata.namespace`, and whether a `sops:` block is present. `encrypted_regex` leaves all
       * of this in plaintext by design, so the check needs no private key and can run before any
       * decrypt is attempted.
       * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
       * @returns {{kind: string, name: string, namespace: string, encrypted: boolean}} Envelope facts.
       * @memberof UnderpostSecret
       */
      manifestMeta(manifestPath) {
        const content = fs.readFileSync(manifestPath, 'utf8');
        const field = (pattern) => (content.match(pattern) || [])[1] || '';
        return {
          kind: field(/^kind:\s*(\S+)/m),
          name: field(/^\s{2,}name:\s*(\S+)/m),
          namespace: field(/^\s{2,}namespace:\s*(\S+)/m),
          encrypted: /^sops:/m.test(content) && /ENC\[AES256_GCM/.test(content),
        };
      },

      /**
       * @method assertManifest
       * @description Fails closed on a manifest that exists but is not what the caller asked for.
       * Guards three silent-failure modes that a plain existence check misses: a plaintext file
       * that was never encrypted (credential disclosure in Git), a non-Secret resource, and a
       * name/namespace mismatch — the last of which would otherwise apply cleanly while leaving
       * the workload's `secretKeyRef` permanently unresolvable.
       * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
       * @param {object} [expect={}] - Expected envelope.
       * @param {string} [expect.name] - Required `metadata.name`.
       * @param {string} [expect.namespace] - Required `metadata.namespace` when the manifest sets one.
       * @memberof UnderpostSecret
       */
      assertManifest(manifestPath, expect = {}) {
        if (!fs.existsSync(manifestPath)) throw new Error(`Encrypted manifest not found: ${manifestPath}`);
        const meta = Underpost.secret.sops.manifestMeta(manifestPath);
        if (!meta.encrypted)
          throw new Error(
            `${manifestPath} carries no sops metadata — it is not encrypted. Refusing to apply; ` +
              `treat any credential in it as disclosed and rotate it.`,
          );
        if (meta.kind && meta.kind !== 'Secret') throw new Error(`${manifestPath} is a ${meta.kind}, not a Secret`);
        if (expect.name && meta.name && meta.name !== expect.name)
          throw new Error(
            `${manifestPath} declares metadata.name "${meta.name}" but is stored as "${expect.name}". ` +
              `Applying it would leave secretKeyRef "${expect.name}" unresolved.`,
          );
        if (expect.namespace && meta.namespace && meta.namespace !== expect.namespace)
          throw new Error(
            `${manifestPath} declares metadata.namespace "${meta.namespace}" but is being applied to ` +
              `"${expect.namespace}".`,
          );
      },

      /**
       * @method localRecipients
       * @description Every Age recipient this host holds a private key for. A key file may carry
       * more than one identity — that is exactly how a host joins a store it did not create, by
       * appending the origin host's key alongside its own — so this returns all of them rather
       * than assuming one. Never throws: an absent or unreadable key file is a legitimate state
       * for a host that has not been onboarded yet, reported as an empty set.
       * @returns {Array<string>} The `age1…` recipients derived from the local key file.
       * @memberof UnderpostSecret
       */
      localRecipients() {
        const keyFile = Underpost.secret.sops.keyFile();
        if (!fs.existsSync(keyFile)) return [];
        const output = shellExec(`age-keygen -y "${keyFile}"`, {
          stdout: true,
          silent: true,
          silentOnError: true,
          disableLog: true,
        });
        return [...new Set(`${output || ''}`.match(/age1[0-9a-z]+/g) || [])];
      },

      /**
       * @method recipient
       * @description Derives the primary Age public recipient from the private key — the one new
       * manifests are encrypted to. The reverse is not possible, so this is safe to log and to
       * commit into `.sops.yaml`.
       * @returns {string} The `age1…` public recipient.
       * @memberof UnderpostSecret
       */
      recipient() {
        const keyFile = Underpost.secret.sops.keyFile();
        if (!fs.existsSync(keyFile)) throw new Error(`Age private key not found: ${keyFile}`);
        const recipients = Underpost.secret.sops.localRecipients();
        if (recipients.length === 0)
          throw new Error(`No Age identity could be read from ${keyFile}. Run: underpost secret sops --init`);
        return recipients[0];
      },

      /**
       * @method decryptable
       * @description Reports whether the local key can open a stored manifest, by set-intersecting
       * the manifest's plaintext `sops:` recipients with the identities this host holds. Needs no
       * decrypt attempt and no private key material, so it is safe to call as a pre-flight on every
       * manifest before the first mutation.
       * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
       * @param {Array<string>} [held] - Locally held recipients; resolved from the key file when omitted.
       * @returns {boolean} True when at least one recipient of the manifest is held locally.
       * @memberof UnderpostSecret
       */
      decryptable(manifestPath, held = Underpost.secret.sops.localRecipients()) {
        if (held.length === 0) return false;
        return Underpost.secret.sops.manifestRecipients(manifestPath).some((recipient) => held.includes(recipient));
      },

      /**
       * @method assertDecryptable
       * @description Fails closed, and legibly, on the store-adoption trap: a host that pulled an
       * encrypted store created elsewhere holds a key that is not among the manifests' recipients.
       * sops reports that as "no identity matched any of the recipients" from inside a decrypt
       * pipe, which names neither the manifest nor a way out; this raises first, listing every
       * unreadable manifest, the recipients it is sealed to, the identities this host actually
       * holds, and the three ways to resolve it.
       * @param {Array<{namespace: string, name: string, path: string}>} manifests - Manifests to check.
       * @memberof UnderpostSecret
       */
      assertDecryptable(manifests) {
        const held = Underpost.secret.sops.localRecipients();
        const unreadable = manifests.filter((manifest) => !Underpost.secret.sops.decryptable(manifest.path, held));
        if (unreadable.length === 0) return;
        const local =
          held.length > 0 ? held.join(', ') : `(none — no readable Age identity at ${Underpost.secret.sops.keyFile()})`;
        throw new Error(
          `${unreadable.length} encrypted manifest(s) are sealed to Age recipients this host does not hold, ` +
            `so they cannot be decrypted here:\n` +
            unreadable
              .map(
                (manifest) =>
                  `  ${manifest.namespace}/${manifest.name} -> ` +
                  `${Underpost.secret.sops.manifestRecipients(manifest.path).join(', ') || 'no age recipients'}`,
              )
              .join('\n') +
            `\n  this host holds: ${local}\n` +
            `Resolve with exactly one of:\n` +
            `  1. Install the key that already opens them — append the origin host's ` +
            `${Underpost.secret.sops.keyFile()} to this host's own (one file may hold several identities), ` +
            `chmod 600 it, then re-run.\n` +
            `  2. Re-key the store from a host that still holds that key: ` +
            `underpost secret sops --rotate --recipient <this host's recipient>, commit engine-private/secrets, ` +
            `pull here, then re-run.\n` +
            `  3. Re-onboard from this host's origin seed files, replacing the stored manifests: ` +
            `underpost run sops-setup --force. Valid only when those seed files carry the credentials the ` +
            `cluster already runs on — any regenerated value must also be applied to the running datastore.`,
        );
      },

      /**
       * @method init
       * @description Generates the Age keypair and the `.sops.yaml` creation rule when absent.
       * Idempotent, and never overwrites an existing key: regenerating would orphan every manifest
       * already encrypted to the previous recipient, with no way to recover them.
       * @memberof UnderpostSecret
       */
      init() {
        Underpost.secret.sops.assertTooling(['age-keygen', 'sops']);
        const keyFile = Underpost.secret.sops.keyFile();
        if (fs.existsSync(keyFile)) logger.info(`Age key already present; reusing ${keyFile}`);
        else {
          fs.ensureDirSync(keyFile.slice(0, keyFile.lastIndexOf('/')));
          shellExec(`umask 077 && age-keygen -o "${keyFile}"`);
        }
        shellExec(`chmod 600 "${keyFile}"`);

        const recipient = Underpost.secret.sops.recipient();
        const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
        if (fs.existsSync(sopsConfPath)) {
          logger.info(`Creation rules already present; leaving ${sopsConfPath} intact`);
          Underpost.secret.sops.repairCreationRules();
          Underpost.secret.sops.ensureCreationRecipient(recipient);
        } else {
          fs.outputFileSync(
            sopsConfPath,
            [
              'creation_rules:',
              `  - path_regex: ${SOPS_MANIFEST_PATH_REGEX}`,
              `    encrypted_regex: '${SOPS_ENCRYPTED_REGEX}'`,
              `    age: ${recipient}`,
              '',
            ].join('\n'),
            'utf8',
          );
          logger.info(`Created ${sopsConfPath}`);
        }
        logger.info(`Age recipient: ${recipient}`);
        logger.warn(`Back up ${keyFile} offline. Without it every encrypted manifest is unrecoverable.`);
      },

      /**
       * @method ensureCreationRecipient
       * @description Registers this host's recipient in an inherited `.sops.yaml` so anything it
       * encrypts from now on, it can also decrypt. Without this, a host that pulled a store created
       * elsewhere encrypts to the *other* host's recipient only, producing manifests it cannot read
       * back — a failure that surfaces later as an opaque decrypt error rather than at write time.
       *
       * Strictly additive: no existing recipient loses access, and existing manifests are left
       * untouched, since re-keying them requires a private key that can still decrypt (see
       * {@link rotate}). Left alone when the rule lists no `age:` recipients at all, which means a
       * deliberately non-Age rule rather than a store this host should join.
       * @param {string} recipient - This host's `age1…` public recipient.
       * @returns {boolean} True when the creation rule was rewritten.
       * @memberof UnderpostSecret
       */
      ensureCreationRecipient(recipient) {
        const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
        if (!fs.existsSync(confPath) || !recipient) return false;
        const current = Underpost.secret.sops.creationRecipients();
        if (current.includes(recipient)) return false;
        if (current.length === 0) {
          logger.warn(
            `${confPath} declares no age recipients; leaving it untouched. Add ${recipient} manually if this ` +
              `host is meant to encrypt into this store.`,
          );
          return false;
        }
        Underpost.secret.sops.writeCreationRecipients([...current, recipient]);
        logger.warn(
          `Registered this host's recipient in ${confPath} so manifests it encrypts stay readable here. ` +
            `Existing manifests are NOT re-keyed by this — run \`underpost secret sops --rotate --recipient ` +
            `${recipient}\` from a host that can still decrypt them, then commit ${SOPS_SECRETS_DIR}.`,
          { added: recipient, recipients: [...current, recipient] },
        );
        return true;
      },

      /**
       * @method repairCreationRules
       * @description Rewrites a `path_regex` that can never match, in place, preserving recipients
       * and every other setting. Configs written before the relative-path semantics were understood
       * carry an `engine-private/secrets/` prefix; because sops matches relative to the directory
       * holding `.sops.yaml`, that rule matches nothing and every encrypt fails with
       * "no matching creation rules found". Repairs only that known-broken form, so a deliberately
       * customized rule is left alone.
       * @returns {boolean} True when the file was rewritten.
       * @memberof UnderpostSecret
       */
      repairCreationRules() {
        const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
        if (!fs.existsSync(confPath)) return false;
        const lines = fs.readFileSync(confPath, 'utf8').split('\n');
        // The rule is the first key of a YAML list item, so the line carries a `- ` marker that
        // has to be preserved: `  - path_regex: …`.
        const brokenRule = /^(\s*(?:-\s*)?)path_regex:\s*.*engine-private\/secrets\//;
        const index = lines.findIndex((line) => brokenRule.test(line));
        if (index === -1) return false;
        const prefix = lines[index].match(brokenRule)[1];
        const previous = lines[index].trim();
        lines[index] = `${prefix}path_regex: ${SOPS_MANIFEST_PATH_REGEX}`;
        fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
        logger.warn(
          `Repaired an unmatchable creation rule in ${confPath}: sops matches path_regex relative to ` +
            `that file's own directory, so the store prefix never matched.`,
          { from: previous, to: `path_regex: ${SOPS_MANIFEST_PATH_REGEX}` },
        );
        return true;
      },

      /**
       * @method encrypt
       * @description Encrypts a plaintext Secret manifest into the Git-tracked store and shreds the
       * source. Recipients resolve from the committed `.sops.yaml`, so a manifest cannot be
       * encrypted to an unlisted key. Author the plaintext under `/dev/shm` so it never touches
       * persistent storage.
       * Written via a staged temp file and moved into place only after the output validates. A
       * bare `sops … > out` redirect has the shell truncate `out` before sops runs, so a failed
       * encrypt would destroy an existing manifest and leave a zero-byte file in its place.
       * @param {string} plaintextPath - Path to the plaintext Secret manifest.
       * @param {string} [namespace='default'] - Target namespace directory in the store.
       * @param {object} [options={}] - Encryption options.
       * @param {boolean} [options.force=false] - Replace an existing manifest at the target path.
       * @returns {string} Path of the written encrypted manifest.
       * @memberof UnderpostSecret
       */
      encrypt(plaintextPath, namespace = 'default', options = {}) {
        Underpost.secret.sops.assertTooling(['sops']);
        if (!plaintextPath || !fs.existsSync(plaintextPath))
          throw new Error(`Plaintext manifest not found: ${plaintextPath}`);
        const sopsConfPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
        if (!fs.existsSync(sopsConfPath))
          throw new Error(`Missing creation rules: ${sopsConfPath} (run: underpost secret sops --init)`);

        const sourceMeta = Underpost.secret.sops.manifestMeta(plaintextPath);
        if (sourceMeta.encrypted)
          throw new Error(
            `${plaintextPath} already carries sops metadata. Re-encrypting would double-wrap it; ` +
              `edit it in place with: sops ${plaintextPath}`,
          );

        const name = plaintextPath
          .split('/')
          .pop()
          .replace(/\.ya?ml$/, '');
        const outPath = Underpost.secret.sops.manifestPath(name, namespace);
        if (fs.existsSync(outPath) && !options.force)
          throw new Error(`${outPath} already exists. Edit it with \`sops ${outPath}\`, or pass --force to replace.`);
        fs.ensureDirSync(`${SOPS_SECRETS_DIR}/${namespace}`);

        // Encrypt to a temp file and move into place only on success. A bare `sops … > out` has the
        // shell truncate `out` before sops runs, so a failed encrypt destroys the manifest that was
        // already there and leaves an empty file the apply path would happily skip over.
        const stagePath = `${outPath}.staged`;
        try {
          // `--filename-override` makes sops match creation_rules against the destination path
          // rather than the tmpfs source. Without it the rule never matches, because the plaintext
          // deliberately lives outside the store (in /dev/shm) and is not named `*.enc.yaml`.
          shellExec(
            `sops --config "${sopsConfPath}" --filename-override "${outPath}" ` +
              `--encrypt "${plaintextPath}" > "${stagePath}"`,
          );
          Underpost.secret.sops.assertManifest(stagePath, { name });
          // Sealing to a recipient held elsewhere is legitimate (encrypting *for* another host),
          // so this warns rather than fails — but it is also the shape of the store-adoption trap,
          // where it would otherwise only surface at the next apply.
          if (!Underpost.secret.sops.decryptable(stagePath))
            logger.warn(
              `${outPath} is sealed to ${Underpost.secret.sops.manifestRecipients(stagePath).join(', ')}, none of ` +
                `which this host holds a private key for — it cannot be decrypted here. Add this host's recipient ` +
                `to ${sopsConfPath} and re-encrypt if that is not intended.`,
            );
          fs.moveSync(stagePath, outPath, { overwrite: true });
        } finally {
          fs.removeSync(stagePath);
        }

        shellExec(`shred -u "${plaintextPath}" 2>/dev/null || rm -f "${plaintextPath}"`, { silentOnError: true });
        logger.info(`Encrypted -> ${outPath}`);
        return outPath;
      },

      /**
       * @method apply
       * @description Decrypts every encrypted manifest for a namespace and streams each one
       * directly into `kubectl apply -f -`. Plaintext exists only in an anonymous kernel pipe.
       * @param {string} [namespace='default'] - Target namespace.
       * @param {object} [options={}] - Apply options.
       * @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
       * @returns {number} Count of manifests applied.
       * @memberof UnderpostSecret
       */
      apply(namespace = 'default', options = {}) {
        const dir = `${SOPS_SECRETS_DIR}/${namespace}`;
        if (!fs.existsSync(dir)) throw new Error(`No encrypted secrets for namespace: ${namespace}`);
        const manifests = Underpost.secret.sops.manifests(namespace);
        if (manifests.length === 0) throw new Error(`No *${SOPS_MANIFEST_EXT} manifests under ${dir}`);

        // Validate-then-commit. Applying in a single pass means manifest N failing to decrypt
        // leaves 1..N-1 already live — a half-rotated namespace nobody asked for. The envelope
        // check plus a server dry run of every manifest catches wrong-key, malformed-YAML, schema
        // and RBAC failures before the first mutation.
        for (const manifest of manifests)
          Underpost.secret.sops.assertManifest(manifest.path, { name: manifest.name, namespace });
        Underpost.secret.sops.assertDecryptable(manifests);
        if (!options.dryRun)
          for (const manifest of manifests)
            Underpost.secret.sops.applyManifest(manifest.path, namespace, { ...options, dryRun: true, quiet: true });

        for (const manifest of manifests) Underpost.secret.sops.applyManifest(manifest.path, namespace, options);
        logger.info(`${options.dryRun ? 'Validated' : 'Applied'} ${manifests.length} manifest(s) in ns/${namespace}`);
        return manifests.length;
      },

      /**
       * @method applyManifest
       * @description Streams one encrypted manifest through `sops --decrypt` into `kubectl apply`.
       * Runs under an explicit `bash -c` with `pipefail`, which is load-bearing: without it a sops
       * failure yields an empty stream and `kubectl apply -f -` exits 0, silently applying nothing.
       * `disableLog` keeps the command (and therefore the key path) out of the log stream.
       * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
       * @param {string} [namespace='default'] - Target namespace.
       * @param {object} [options={}] - Apply options.
       * @param {boolean} [options.dryRun=false] - Perform a server-side dry run instead of applying.
       * @param {string} [options.expectName] - Require this `metadata.name` in the manifest envelope.
       * @param {boolean} [options.quiet=false] - Suppress the per-manifest log line.
       * @memberof UnderpostSecret
       */
      applyManifest(manifestPath, namespace = 'default', options = {}) {
        // Envelope first: it needs no private key, so a malformed or unencrypted store is reported
        // as such even on a host whose key is missing or wrongly permissioned.
        Underpost.secret.sops.assertManifest(manifestPath, { name: options.expectName, namespace });
        // Recipient set next: a manifest sealed to a key this host does not hold fails inside the
        // decrypt pipe with an error that names neither the file nor a remedy.
        Underpost.secret.sops.assertDecryptable([
          { namespace, name: options.expectName || manifestPath.split('/').pop(), path: manifestPath },
        ]);
        Underpost.secret.sops.assertTooling(['sops']);
        const keyFile = Underpost.secret.sops.assertKeyFile();
        const dryRun = options.dryRun ? ' --dry-run=server' : '';
        shellExec(
          `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" sops --decrypt "${manifestPath}" ` +
            `| kubectl apply -f -${dryRun} -n "${namespace}"'`,
          { disableLog: true },
        );
        if (!options.quiet) logger.info(`${options.dryRun ? 'Dry-run' : 'Applied'} ${manifestPath} -> ns/${namespace}`);
      },

      /**
       * @method applyIfPresent
       * @description Applies a secret from the SOPS store when an encrypted manifest exists,
       * reporting whether it did. Single decision point for callers that keep an origin seed path
       * for clusters not yet onboarded to the encrypted store.
       *
       * Falls back to the seed path only when the manifest is *absent*. A manifest that exists but
       * is corrupt, unencrypted, or names a different Secret raises instead: sliding back to the
       * seed path there would mask a tampered store and silently deploy stale credentials the
       * operator believes were replaced.
       * @param {string} name - Secret name.
       * @param {string} [namespace='default'] - Kubernetes namespace.
       * @param {object} [options={}] - Apply options forwarded to {@link applyManifest}.
       * @returns {boolean} True when the encrypted manifest was applied.
       * @memberof UnderpostSecret
       */
      applyIfPresent(name, namespace = 'default', options = {}) {
        if (!Underpost.secret.sops.has(name, namespace)) return false;
        Underpost.secret.sops.applyManifest(Underpost.secret.sops.manifestPath(name, namespace), namespace, {
          ...options,
          expectName: name,
        });
        return true;
      },

      /**
       * @method list
       * @description Lists encrypted manifests with their Age recipients. Reads only the plaintext
       * `sops:` metadata block, so no private key is required and this is safe to run anywhere.
       * @memberof UnderpostSecret
       */
      list() {
        const manifests = Underpost.secret.sops.manifests();
        if (manifests.length === 0) return logger.warn(`No encrypted manifests under ${SOPS_SECRETS_DIR}`);
        for (const manifest of manifests) {
          const recipients = Underpost.secret.sops.manifestRecipients(manifest.path);
          console.log(
            `${manifest.namespace}/${manifest.name}${SOPS_MANIFEST_EXT}  ->  ${
              recipients.join(', ') || 'no age recipients'
            }`,
          );
        }
      },

      /**
       * @method manifestRecipients
       * @description Extracts the Age recipients an encrypted manifest is sealed to, from its
       * plaintext `sops:` metadata block. Requires no private key.
       * @param {string} manifestPath - Path to the `.enc.yaml` manifest.
       * @returns {Array<string>} Recipients that can decrypt the manifest.
       * @memberof UnderpostSecret
       */
      manifestRecipients(manifestPath) {
        const content = fs.readFileSync(manifestPath, 'utf8');
        return [...content.matchAll(/recipient:\s*(age1\S+)/g)].map((match) => match[1]);
      },

      /**
       * @method rotate
       * @description Re-keys every encrypted manifest onto a new Age recipient after key
       * compromise or scheduled rotation. Secret *values* are untouched: `sops updatekeys` only
       * re-wraps each file's data key, so no workload restart is needed. Requires a private key
       * that can still decrypt, so rotation must run before the outgoing key is destroyed.
       *
       * Additive by default (the outgoing recipient keeps working, which is what a scheduled
       * rotation wants). `options.pruneRecipients` makes the new recipient the only one, which is
       * what a compromise wants — and which also revokes every *other* operator and CI/CD key in
       * the rule, so it additionally requires `options.force` after showing exactly what is lost.
       * @param {string} recipient - Incoming `age1…` public recipient.
       * @param {object} [options={}] - Rotation options.
       * @param {boolean} [options.pruneRecipients=false] - Drop all existing recipients.
       * @param {Array<string>|string} [options.keepRecipients] - Recipients to retain while pruning
       *   (e.g. the CI/CD key), as an array or comma-separated list.
       * @param {boolean} [options.force=false] - Confirm an irreversible prune.
       * @param {boolean} [options.dryRun=false] - Report the plan without rewriting anything.
       * @returns {{recipients: Array<string>, revoked: Array<string>, rekeyed: number}} Outcome.
       * @memberof UnderpostSecret
       */
      rotate(recipient, options = {}) {
        if (!recipient) throw new Error('Rotation requires --recipient <age-public-key>');
        if (!/^age1[0-9a-z]{20,}$/.test(recipient))
          throw new Error(`Not a valid Age public recipient: ${recipient} (expected age1…)`);

        Underpost.secret.sops.assertTooling(['sops']);
        const keyFile = Underpost.secret.sops.assertKeyFile();

        const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
        if (!fs.existsSync(confPath))
          throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);

        const keep = (
          Array.isArray(options.keepRecipients) ? options.keepRecipients : `${options.keepRecipients || ''}`.split(',')
        )
          .map((value) => value.trim())
          .filter(Boolean);
        const current = Underpost.secret.sops.creationRecipients();
        const next = options.pruneRecipients
          ? [...new Set([recipient, ...keep])]
          : [...new Set([...current, recipient])];
        const revoked = current.filter((existing) => !next.includes(existing));
        const manifests = Underpost.secret.sops.manifests();
        // `updatekeys` re-wraps each data key, which means decrypting it first. A host that cannot
        // read the store cannot rotate it, dry run included — reporting a plan that can never run
        // is what sends an operator down the wrong remedy.
        Underpost.secret.sops.assertDecryptable(manifests);

        if (options.dryRun) {
          logger.info('Rotation plan (dry run)', { from: current, to: next, revoked, manifests: manifests.length });
          if (revoked.length)
            logger.warn(
              `${revoked.length} recipient(s) would permanently lose access. Confirm none is a CI/CD or ` +
                `co-operator key before re-running with --force.`,
              { revoked },
            );
          return { recipients: next, revoked, rekeyed: 0 };
        }

        // A prune revokes every recipient not explicitly retained — including CI/CD keys the
        // operator may not have in mind. Irreversible for anyone holding only a revoked key, so it
        // is gated behind an explicit confirmation that has to be made after seeing the list.
        if (revoked.length && !options.force)
          throw new Error(
            `Refusing to revoke ${revoked.length} recipient(s) without --force: ${revoked.join(', ')}. ` +
              `Review with --dry-run, retain any CI/CD key via --keep-recipients <age1…>, then re-run with --force.`,
          );

        Underpost.secret.sops.writeCreationRecipients(next);
        for (const manifest of manifests) {
          // `updatekeys` decrypts the data key with a held private key and re-wraps it for the
          // recipients now in `.sops.yaml`. disableLog keeps the key path out of the log stream.
          shellExec(
            `bash -c 'set -o pipefail; SOPS_AGE_KEY_FILE="${keyFile}" ` +
              `sops --config "${confPath}" updatekeys --yes "${manifest.path}"'`,
            { disableLog: true, silent: true },
          );
          // updatekeys is a no-op when it decides nothing changed, and its exit code does not
          // distinguish that from a successful re-key. Confirm against the file itself, so a
          // rotation can never be reported as done while a manifest stays on the old recipient.
          const sealed = Underpost.secret.sops.manifestRecipients(manifest.path);
          if (!sealed.includes(recipient))
            throw new Error(
              `${manifest.path} is still sealed to ${sealed.join(', ') || 'no recipients'} after updatekeys; ` +
                `expected ${recipient}. Rotation aborted with the store partially re-keyed — re-run once resolved.`,
            );
          logger.info(`Re-keyed ${manifest.namespace}/${manifest.name}`);
        }

        logger.info(`Rotated ${manifests.length} manifest(s)`, { recipients: next, revoked });
        if (revoked.length)
          logger.warn(
            `Revoked ${revoked.length} recipient(s); those keys can no longer decrypt any manifest. ` +
              `Every host applying these secrets now needs a private key for one of: ${next.join(', ')}.`,
            { revoked },
          );
        return { recipients: next, revoked, rekeyed: manifests.length };
      },

      /**
       * @method purge
       * @description Emergency removal of one secret: deletes the live Kubernetes Secret and takes
       * its encrypted manifest out of the store. The manifest is archived rather than deleted so
       * the purge stays reversible; `options.force` deletes it outright.
       *
       * Removing the manifest is what re-arms the origin seed path — with no `.enc.yaml`,
       * `applyIfPresent` returns false and cluster init seeds the secret from the plaintext
       * credential files instead. Whether that seed path is actually available is reported, not
       * assumed: purging a secret whose seed files are gone leaves workloads with an unresolvable
       * `secretKeyRef`, so the gap is surfaced at purge time rather than at the next deploy.
       * @param {string} name - Secret name (e.g. 'postgres-secret').
       * @param {object} [options={}] - Purge options.
       * @param {string} [options.namespace='default'] - Namespace of the live Secret.
       * @param {boolean} [options.force=false] - Delete the manifest instead of archiving it.
       * @param {boolean} [options.dryRun=false] - Report what would happen without changing anything.
       * @returns {{deleted: boolean, archived: string, seedFallback: boolean}} Purge outcome.
       * @memberof UnderpostSecret
       */
      purge(name, options = {}) {
        if (!name) throw new Error('Purge requires a secret name');
        const namespace = options.namespace || 'default';
        const manifestPath = Underpost.secret.sops.manifestPath(name, namespace);
        const seedSources = Object.values(Underpost.secret.sops.seedSources(name));
        const seedFallback = seedSources.length > 0 && seedSources.every((source) => fs.existsSync(source));

        if (options.dryRun) {
          logger.info('Purge plan (dry run)', {
            secret: `${namespace}/${name}`,
            manifest: fs.existsSync(manifestPath) ? manifestPath : 'absent',
            disposition: options.force ? 'delete' : 'archive',
            seedFallbackAvailable: seedFallback,
          });
          return { deleted: false, archived: '', seedFallback };
        }

        shellExec(`kubectl delete secret ${name} -n ${namespace} --ignore-not-found`);

        let archived = '';
        if (!fs.existsSync(manifestPath)) logger.warn(`No encrypted manifest to remove at ${manifestPath}`);
        else if (options.force) {
          fs.removeSync(manifestPath);
          logger.warn(`Deleted ${manifestPath}`);
        } else {
          const stamp = new Date().toISOString().replace(/[:.]/g, '-');
          archived = `${SOPS_ARCHIVE_DIR}/${namespace}/${name}.${stamp}${SOPS_MANIFEST_EXT}`;
          fs.ensureDirSync(`${SOPS_ARCHIVE_DIR}/${namespace}`);
          fs.moveSync(manifestPath, archived);
          logger.info(`Archived ${manifestPath} -> ${archived}`);
        }

        if (seedFallback)
          logger.info(`Origin seed path is available for ${name}; cluster init will seed from it.`, {
            sources: seedSources,
          });
        else if (seedSources.length > 0)
          logger.warn(
            `No origin seed path for ${name}. Re-encrypt a manifest or create the secret manually ` +
              `before redeploying workloads that mount it.`,
            { expected: seedSources },
          );

        return { deleted: true, archived, seedFallback };
      },

      /**
       * @method manifests
       * @description Enumerates every encrypted manifest in the store, or in one namespace.
       * Dot-prefixed entries (`.archive`, `.sops.yaml`) are never treated as namespaces.
       * @param {string} [namespace] - Restrict to one namespace; omit for the whole store.
       * @returns {Array<{namespace: string, name: string, path: string}>} Manifest descriptors, sorted.
       * @memberof UnderpostSecret
       */
      manifests(namespace) {
        if (!fs.existsSync(SOPS_SECRETS_DIR)) return [];
        const namespaces = namespace
          ? [namespace]
          : fs
              .readdirSync(SOPS_SECRETS_DIR)
              .filter((entry) => !entry.startsWith('.') && fs.statSync(`${SOPS_SECRETS_DIR}/${entry}`).isDirectory())
              .sort();
        const found = [];
        for (const ns of namespaces) {
          const dir = `${SOPS_SECRETS_DIR}/${ns}`;
          if (!fs.existsSync(dir)) continue;
          for (const file of fs
            .readdirSync(dir)
            .filter((entry) => entry.endsWith(SOPS_MANIFEST_EXT))
            .sort())
            found.push({ namespace: ns, name: file.slice(0, -SOPS_MANIFEST_EXT.length), path: `${dir}/${file}` });
        }
        return found;
      },

      /**
       * @method creationRecipients
       * @description Reads the Age recipients from the committed `.sops.yaml` creation rule,
       * accepting both the single-line (`age: k1,k2`) and folded (`age: >-`) forms sops permits.
       * @returns {Array<string>} Recipients currently configured for encryption.
       * @memberof UnderpostSecret
       */
      creationRecipients() {
        const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
        if (!fs.existsSync(confPath)) return [];
        const lines = fs.readFileSync(confPath, 'utf8').split('\n');
        const index = lines.findIndex((line) => /^\s*age:/.test(line));
        if (index === -1) return [];
        const indent = lines[index].match(/^\s*/)[0].length;
        const chunk = [lines[index].replace(/^\s*age:\s*>?-?\s*/, '')];
        for (let i = index + 1; i < lines.length; i++) {
          if (!lines[i].trim()) break;
          if (lines[i].match(/^\s*/)[0].length <= indent) break;
          chunk.push(lines[i].trim());
        }
        return chunk
          .join(',')
          .split(',')
          .map((value) => value.trim())
          .filter(Boolean);
      },

      /**
       * @method writeCreationRecipients
       * @description Rewrites the `age:` recipients of the `.sops.yaml` creation rule in place,
       * collapsing any folded form to a single canonical line. Line-scoped on purpose: a YAML
       * round-trip would strip the comments operators keep in this file.
       * @param {Array<string>} recipients - Recipients to encrypt to from now on.
       * @memberof UnderpostSecret
       */
      writeCreationRecipients(recipients) {
        const confPath = `${SOPS_SECRETS_DIR}/.sops.yaml`;
        if (!fs.existsSync(confPath))
          throw new Error(`Missing creation rules: ${confPath} (run: underpost secret sops --init)`);
        const lines = fs.readFileSync(confPath, 'utf8').split('\n');
        const index = lines.findIndex((line) => /^\s*age:/.test(line));
        if (index === -1) throw new Error(`No 'age:' recipients entry in ${confPath}`);
        const indent = lines[index].match(/^\s*/)[0];
        let end = index + 1;
        while (end < lines.length && lines[end].trim() && lines[end].match(/^\s*/)[0].length > indent.length) end++;
        lines.splice(index, end - index, `${indent}age: ${recipients.join(',')}`);
        fs.writeFileSync(confPath, lines.join('\n'), 'utf8');
      },

      /**
       * @method hasBinary
       * @description Reports whether a binary resolves on PATH. Single probe reused by
       * {@link assertTooling} and {@link installTooling} so both agree on what "installed" means.
       * @param {string} bin - Binary name.
       * @returns {boolean} True when the binary is on PATH.
       * @memberof UnderpostSecret
       */
      hasBinary(bin) {
        return (
          shellExec(`command -v ${bin} >/dev/null 2>&1 && echo exists || echo missing`, {
            stdout: true,
            silent: true,
            disableLog: true,
          }).trim() === 'exists'
        );
      },

      /**
       * @method assertTooling
       * @description Fails fast with an actionable message when a required binary is missing,
       * rather than surfacing an opaque shell exit code mid-apply.
       * @param {Array<string>} bins - Binaries that must resolve on PATH.
       * @memberof UnderpostSecret
       */
      assertTooling(bins) {
        for (const bin of bins)
          if (!Underpost.secret.sops.hasBinary(bin))
            throw new Error(`${bin} not found in PATH (install via: underpost secret --install-tools)`);
      },

      /**
       * @method installTooling
       * @description Installs the `sops` and `age` host binaries from their pinned upstream static
       * builds. Idempotent: an already-resolvable binary is left untouched, so this is safe to
       * re-run and safe to call from both the secrets CLI and cluster host initialization.
       * Verifies both binaries resolve before returning, so a partial install fails loudly here
       * rather than mid-decrypt.
       * @returns {{sops: boolean, age: boolean}} Which binaries this run actually installed.
       * @memberof UnderpostSecret
       */
      installTooling() {
        const archData = Underpost.baremetal.getHostArch();
        logger.info('Installing SOPS and Age host tooling...', { ...archData, SOPS_VERSION, AGE_VERSION });
        const installed = { sops: false, age: false };

        if (Underpost.secret.sops.hasBinary('sops')) logger.info('SOPS is already installed; skipping.');
        else {
          shellExec(
            `curl -fsSL -o /tmp/sops https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${archData.alias}`,
          );
          shellExec(`sudo install -m 0755 /tmp/sops /usr/local/bin/sops`);
          shellExec(`sudo ln -sf /usr/local/bin/sops /bin/sops`);
          shellExec(`sudo rm -f /tmp/sops`);
          installed.sops = true;
        }

        if (Underpost.secret.sops.hasBinary('age-keygen')) logger.info('Age is already installed; skipping.');
        else {
          shellExec(
            `curl -fsSL -o /tmp/age.tar.gz https://github.com/FiloSottile/age/releases/download/${AGE_VERSION}/age-${AGE_VERSION}-linux-${archData.alias}.tar.gz`,
          );
          shellExec(`tar -xzf /tmp/age.tar.gz -C /tmp`);
          shellExec(`sudo install -m 0755 /tmp/age/age /usr/local/bin/age`);
          shellExec(`sudo install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen`);
          shellExec(`sudo ln -sf /usr/local/bin/age /bin/age`);
          shellExec(`sudo ln -sf /usr/local/bin/age-keygen /bin/age-keygen`);
          shellExec(`sudo rm -rf /tmp/age /tmp/age.tar.gz`);
          installed.age = true;
        }

        Underpost.secret.sops.assertTooling(['sops', 'age', 'age-keygen']);
        logger.info('SOPS and Age tooling ready.', installed);
        return installed;
      },
    },

    /**
     * @method sanitizeSecretEnvFile
     * @description Strips shell/runtime-critical and Kubernetes-injected keys (PATH, HOME, …) from
     * raw `.env` file content so the resulting `underpost-config` secret can be safely injected via
     * `envFrom` without clobbering the container image's own PATH. Blank lines and comments are
     * preserved. Uses the same {@link RESERVED_ENV_KEYS} blocklist as container-env capture.
     * @param {string} envFileContent - Raw contents of a `.env.<env>` file.
     * @returns {string} Filtered env-file content.
     * @memberof UnderpostSecret
     */
    sanitizeSecretEnvFile(envFileContent) {
      return envFileContent
        .split('\n')
        .filter((line) => {
          const trimmed = line.trimStart();
          if (!trimmed || trimmed.startsWith('#')) return true;
          const key = line.slice(0, line.indexOf('=')).trim();
          return !key || !isReservedEnvKey(key);
        })
        .join('\n');
    },

    /**
     * Removes all filesystem traces of secrets after deployment startup.
     * Centralizes the defense-in-depth cleanup performed
     * @param {object} options - Options for cleaning the environment.
     * @param {Array<string>} [options.keepKeys=[]] - List of keys to keep in the environment file. If provided, only these keys will be retained.
     * @memberof UnderpostSecret
     */
    globalSecretClean(options = { keepKeys: [] }) {
      const { keepKeys } = options;
      loadConf('clean');
      Underpost.repo.cleanupPrivateEngineRepo();
      Underpost.env.clean({
        keepKeys: keepKeys.length > 0 ? keepKeys : ['container-status', 'start-container-status'],
      });
    },
  };
}

export default UnderpostSecret;