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
254 lines (227 loc) • 12.4 kB
JavaScript
/**
* IPFS Cluster module for managing ipfs-cluster StatefulSet deployment on Kubernetes.
* @module src/cli/ipfs.js
* @namespace UnderpostIPFS
*/
import { loggerFactory } from '../server/logger.js';
import { shellExec } from '../server/process.js';
import { resolveReplicaCount } from '../server/conf.js';
import fs from 'fs-extra';
import Underpost from '../index.js';
const logger = loggerFactory(import.meta);
const IPFS_DEFAULT_REPLICA_COUNT = 3;
/**
* @class UnderpostIPFS
* @description Manages deployment of an ipfs-cluster StatefulSet on Kubernetes.
* Credentials (cluster secret + peer identity) are generated once and persisted
* to engine-private/ so the cluster identity survives redeployments.
* @memberof UnderpostIPFS
*/
class UnderpostIPFS {
static API = {
/**
* @method resolveCredentials
* @description Resolves the IPFS cluster credentials from engine-private/ if they
* already exist, otherwise generates new ones (hex cluster secret + peer identity
* via ipfs-cluster-service init) and persists them with mode 0o600.
* Idempotent and self-healing: an existing pair is reused untouched, and a missing or
* unreadable one is regenerated. Both files are always rewritten together — the peer id and
* the private key are two halves of one identity, so reusing one with a freshly minted other
* would advertise a peer id that does not match the key.
* @param {string} privateDir - Absolute path to the engine-private directory.
* @returns {{ CLUSTER_SECRET: string, IDENTITY_JSON: { id: string, private_key: string }, generated: boolean }}
* @memberof UnderpostIPFS
*/
resolveCredentials(privateDir) {
const secretPath = `${privateDir}/ipfs-cluster-secret`;
const identityPath = `${privateDir}/ipfs-cluster-identity.json`;
if (fs.existsSync(secretPath) && fs.existsSync(identityPath)) {
try {
const CLUSTER_SECRET = fs.readFileSync(secretPath, 'utf8').trim();
const IDENTITY_JSON = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
if (CLUSTER_SECRET && IDENTITY_JSON?.id && IDENTITY_JSON?.private_key) {
logger.info('Reusing existing IPFS cluster credentials from engine-private/');
return { CLUSTER_SECRET, IDENTITY_JSON, generated: false };
}
logger.warn('Existing IPFS cluster credentials are incomplete; regenerating');
} catch (error) {
logger.warn(`Existing IPFS cluster credentials are unreadable (${error.message}); regenerating`);
}
}
logger.info('Generating new IPFS cluster credentials and persisting to engine-private/');
// ipfs-cluster-service requires CLUSTER_SECRET as a 64-char hex string.
// base64 (openssl rand -base64 32) contains '/', '+', '=' which are invalid hex bytes.
const CLUSTER_SECRET = shellExec("od -vN 32 -An -tx1 /dev/urandom | tr -d ' \\n'", {
stdout: true,
}).trim();
const tmpDir = '/tmp/ipfs-cluster-identity';
shellExec(`rm -rf ${tmpDir} && mkdir -p ${tmpDir}`);
shellExec(`docker run --rm -v ${tmpDir}:/data/ipfs-cluster ipfs/ipfs-cluster init -f`);
const IDENTITY_JSON = JSON.parse(shellExec(`cat ${tmpDir}/identity.json`, { stdout: true }).trim());
shellExec(`rm -rf ${tmpDir}`);
fs.ensureDirSync(privateDir);
fs.writeFileSync(secretPath, CLUSTER_SECRET, { mode: 0o600 });
fs.writeFileSync(identityPath, JSON.stringify(IDENTITY_JSON, null, 2), { mode: 0o600 });
logger.info(`IPFS cluster credentials saved (peer ID: ${IDENTITY_JSON.id})`);
return { CLUSTER_SECRET, IDENTITY_JSON, generated: true };
},
/**
* @method storeCredentials
* @description Encrypts the current IPFS credentials into the SOPS store, replacing whatever
* is there. Called after a regeneration so the encrypted Secret cannot drift from the peer id
* the `env-config` ConfigMap advertises — the id lives only in the local identity file, so a
* stale manifest would pair someone else's private key with the new id and the cluster would
* never form.
* @param {{ CLUSTER_SECRET: string, IDENTITY_JSON: { private_key: string } }} credentials
* @param {object} options
* @param {string} options.namespace - Kubernetes namespace.
* @memberof UnderpostIPFS
*/
storeCredentials({ CLUSTER_SECRET, IDENTITY_JSON }, options) {
const stageDir = '/dev/shm/underpost-secrets';
const stagePath = `${stageDir}/ipfs-cluster-secret.yaml`;
fs.ensureDirSync(stageDir);
fs.chmodSync(stageDir, 0o700);
try {
fs.outputFileSync(
stagePath,
[
'apiVersion: v1',
'kind: Secret',
'metadata:',
' name: ipfs-cluster-secret',
` namespace: ${options.namespace}`,
' labels:',
' app.kubernetes.io/managed-by: underpost',
'type: Opaque',
'stringData:',
` cluster-secret: '${CLUSTER_SECRET.replace(/'/g, "''")}'`,
` bootstrap-peer-priv-key: '${IDENTITY_JSON.private_key.replace(/'/g, "''")}'`,
'',
].join('\n'),
'utf8',
);
fs.chmodSync(stagePath, 0o600);
// encrypt() stages, validates, moves into place, and shreds the plaintext source.
Underpost.secret.sops.encrypt(stagePath, options.namespace, { force: true });
logger.info('Re-encrypted regenerated IPFS credentials into the SOPS store');
} finally {
fs.removeSync(stageDir);
}
},
/**
* @method teardown
* @description Deletes the existing ipfs-cluster StatefulSet, its Kubernetes Secret,
* env ConfigMap, and all PVCs so the next deployment initialises a clean data volume
* (ensuring the correct datastore profile is applied by the init container).
* @param {object} options
* @param {string} options.namespace - Kubernetes namespace.
* @param {number} ipfsReplicas - Number of replicas whose PVCs must be removed.
* @memberof UnderpostIPFS
*/
teardown(options, ipfsReplicas) {
logger.info(`Tearing down existing ipfs-cluster deployment in namespace '${options.namespace}'`);
shellExec(`kubectl delete statefulset ipfs-cluster -n ${options.namespace} --ignore-not-found`);
shellExec(`kubectl delete secret ipfs-cluster-secret -n ${options.namespace} --ignore-not-found`);
shellExec(`kubectl delete configmap env-config -n ${options.namespace} --ignore-not-found`);
for (let i = 0; i < ipfsReplicas; i++) {
shellExec(
`kubectl delete pvc cluster-storage-ipfs-cluster-${i} ipfs-storage-ipfs-cluster-${i} -n ${options.namespace} --ignore-not-found`,
);
}
},
/**
* @method applySecrets
* @description Creates (or idempotently updates) the Kubernetes Secret and env ConfigMap
* that the StatefulSet pods read at startup.
* - Secret `ipfs-cluster-secret`: cluster-secret + bootstrap-peer-priv-key
* - ConfigMap `env-config`: bootstrap-peer-id + CLUSTER_SVC_NAME
* @param {{ CLUSTER_SECRET: string, IDENTITY_JSON: { id: string, private_key: string } }} credentials
* @param {object} options
* @param {string} options.namespace - Kubernetes namespace.
* @memberof UnderpostIPFS
*/
applySecrets({ CLUSTER_SECRET, IDENTITY_JSON }, options) {
logger.info('Applying IPFS cluster Kubernetes Secret and env ConfigMap');
// Encrypted store first, origin generate/read logic only when no manifest is stored.
// `--from-literal` places the cluster secret and the peer private key in the command
// string, so the seed path is kept out of the command log; the encrypted path never
// exposes them at all.
if (!Underpost.secret.sops.applyIfPresent('ipfs-cluster-secret', options.namespace))
shellExec(
`kubectl create secret generic ipfs-cluster-secret \
--from-literal=cluster-secret=${CLUSTER_SECRET} \
--from-literal=bootstrap-peer-priv-key=${IDENTITY_JSON.private_key} \
--dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
{ disableLog: true },
);
shellExec(
`kubectl create configmap env-config \
--from-literal=bootstrap-peer-id=${IDENTITY_JSON.id} \
--from-literal=CLUSTER_SVC_NAME=ipfs-cluster \
--dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
);
},
/**
* @method applyManifests
* @description Applies host-level sysctl tuning (Kind clusters only), the storage class,
* the kustomize manifests, and scales the StatefulSet to the requested replica count.
* @param {object} options
* @param {string} options.namespace - Kubernetes namespace.
* @param {boolean} [options.kubeadm] - Whether the cluster is Kubeadm-based.
* @param {boolean} [options.k3s] - Whether the cluster is K3s-based.
* @param {string} underpostRoot - Absolute path to the underpost project root.
* @param {number} ipfsReplicas - Desired replica count.
* @memberof UnderpostIPFS
*/
applyManifests(options, underpostRoot, ipfsReplicas) {
// Apply UDP buffer sysctl on every Kind node so QUIC (used by IPFS) can reach the
// recommended 7.5 MB buffer size. Kind nodes are containers and do NOT inherit the
// host sysctl values, so this must be set via docker exec on each node directly.
shellExec(
`sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000`,
);
shellExec(`kubectl apply -f ${underpostRoot}/manifests/ipfs/storage-class.yaml`);
shellExec(`kubectl apply -k ${underpostRoot}/manifests/ipfs -n ${options.namespace}`);
// statefulset.yaml hardcodes replicas: 3 as the ceiling; scale down here if needed.
shellExec(`kubectl scale statefulset ipfs-cluster --replicas=${ipfsReplicas} -n ${options.namespace}`);
},
/**
* @method deploy
* @description Full orchestration of the ipfs-cluster StatefulSet deployment:
* optionally pulls images, resolves or generates credentials, tears down any existing
* deployment, applies secrets, applies manifests, and waits for all pods to be Running.
* @param {object} options - Cluster init options forwarded from UnderpostCluster.API.init.
* @param {string} options.namespace - Kubernetes namespace.
* @param {boolean} [options.pullImage] - Whether to pull container images first.
* @param {boolean} [options.kubeadm] - Whether the cluster is Kubeadm-based.
* @param {boolean} [options.k3s] - Whether the cluster is K3s-based.
* @param {string|number} [options.replicas] - Override replica count (defaults to 3).
* @param {string} underpostRoot - Absolute path to the underpost project root.
* @memberof UnderpostIPFS
*/
async deploy(options, underpostRoot) {
if (options.pullImage === true) {
Underpost.cluster.pullImage('ipfs/kubo:latest', options);
Underpost.cluster.pullImage('ipfs/ipfs-cluster:latest', options);
}
const credentials = Underpost.ipfs.resolveCredentials(`${underpostRoot}/engine-private`);
// `env-config` advertises `bootstrap-peer-id` from the local identity file — the peer id is
// not carried in the Secret. So a regeneration invalidates any stored manifest: it would
// pair the previous private key with the new id and the cluster would never form. Re-encrypt
// the fresh pair so the store and the ConfigMap stay one identity.
if (credentials.generated && Underpost.secret.sops.has('ipfs-cluster-secret', options.namespace))
Underpost.ipfs.storeCredentials(credentials, options);
const ipfsReplicas = resolveReplicaCount(options.replicas, IPFS_DEFAULT_REPLICA_COUNT);
Underpost.ipfs.teardown(options, ipfsReplicas);
Underpost.ipfs.applySecrets(credentials, options);
Underpost.ipfs.applyManifests(options, underpostRoot, ipfsReplicas);
logger.info(`Waiting for ${ipfsReplicas} ipfs-cluster pod(s) to reach Running state`);
for (let i = 0; i < ipfsReplicas; i++) {
await Underpost.test.statusMonitor(`ipfs-cluster-${i}`, 'Running', 'pods', 1000, 60 * 15);
}
},
};
}
export default UnderpostIPFS;