underpost 3.2.70 → 3.2.90

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 (60) hide show
  1. package/.github/workflows/publish.ci.yml +3 -3
  2. package/.github/workflows/release.cd.yml +1 -1
  3. package/CHANGELOG.md +1358 -1038
  4. package/CLI-HELP.md +39 -16
  5. package/README.md +3 -3
  6. package/bin/build.js +10 -4
  7. package/bin/deploy.js +18 -16
  8. package/docker-compose.yml +1 -1
  9. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  10. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  11. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  12. package/manifests/deployment/playwright/deployment.yaml +1 -1
  13. package/manifests/mongodb/kustomization.yaml +4 -1
  14. package/manifests/mongodb/statefulset.yaml +4 -0
  15. package/manifests/mongodb/storage-class.yaml +9 -2
  16. package/package.json +20 -20
  17. package/scripts/nat-iptables.sh +10 -4
  18. package/scripts/test-monitor.sh +4 -3
  19. package/src/api/core/core.controller.js +4 -65
  20. package/src/api/core/core.router.js +8 -14
  21. package/src/api/default/default.controller.js +2 -70
  22. package/src/api/default/default.router.js +7 -17
  23. package/src/api/document/document.controller.js +5 -77
  24. package/src/api/document/document.router.js +9 -13
  25. package/src/api/file/file.controller.js +9 -53
  26. package/src/api/file/file.router.js +14 -6
  27. package/src/api/test/test.controller.js +8 -53
  28. package/src/api/test/test.router.js +1 -4
  29. package/src/cli/cluster.js +771 -66
  30. package/src/cli/db.js +6 -4
  31. package/src/cli/deploy.js +1715 -168
  32. package/src/cli/docker-compose.js +19 -24
  33. package/src/cli/fs.js +0 -1
  34. package/src/cli/image.js +40 -13
  35. package/src/cli/index.js +129 -35
  36. package/src/cli/ipfs.js +82 -11
  37. package/src/cli/monitor.js +1 -1
  38. package/src/cli/release.js +4 -0
  39. package/src/cli/repository.js +14 -3
  40. package/src/cli/run.js +2253 -439
  41. package/src/cli/secrets.js +969 -0
  42. package/src/cli/ssh.js +38 -39
  43. package/src/client/components/core/Modal.js +38 -4
  44. package/src/client-builder/client-build.js +94 -11
  45. package/src/client-builder/ssr.js +27 -73
  46. package/src/db/mongo/MongoBootstrap.js +295 -54
  47. package/src/db/mongo/MongooseDB.js +47 -32
  48. package/src/index.js +1 -1
  49. package/src/server/conf.js +1307 -6
  50. package/src/server/cri.js +70 -0
  51. package/src/server/downloader.js +3 -3
  52. package/src/server/middlewares.js +152 -0
  53. package/src/server/underpost-gateway.js +1073 -0
  54. package/src/server/underpost-ingress.js +364 -0
  55. package/test/cluster-instances.test.js +435 -0
  56. package/test/deploy-node-placement.test.js +45 -0
  57. package/test/instance-traffic-plan.test.js +710 -0
  58. package/test/sops-secret-store.test.js +612 -0
  59. package/test/underpost-gateway.test.js +469 -0
  60. package/test/underpost-ingress.test.js +253 -0
@@ -11,16 +11,29 @@
11
11
  import fs from 'fs-extra';
12
12
  import { loggerFactory } from '../../server/logger.js';
13
13
  import { shellExec } from '../../server/process.js';
14
+ import { crictlCommandFactory } from '../../server/cri.js';
15
+ import { resolveReplicaCount } from '../../server/conf.js';
16
+ // Cyclic by construction (index -> cluster -> MongoBootstrap -> index), same as cluster.js.
17
+ // Safe because the binding is only dereferenced inside method bodies, never at module scope.
18
+ import Underpost from '../../index.js';
14
19
  import {
20
+ MONGODB_DATA_ROOT,
15
21
  MONGODB_DEFAULT_REPLICA_COUNT,
16
22
  MONGODB_DEFAULT_REPLICA_SET,
17
23
  MONGODB_SERVICE_NAME,
18
24
  MONGODB_STATEFULSET_NAME,
25
+ MONGODB_STORAGE_CLASS_NAME,
26
+ MONGODB_STORAGE_CLASS_PROVISIONER,
19
27
  resolveMongoReplicaHosts,
20
28
  } from './MongooseDB.js';
21
29
 
22
30
  const logger = loggerFactory(import.meta);
23
31
 
32
+ // Teardown sweeps ordinals rather than a live replica count: the deployed count is not knowable
33
+ // during a reset, and a member left behind from a larger previous deployment keeps its volume and
34
+ // stale replica-set config, which a later deploy then trips over.
35
+ const MONGODB_ORDINAL_SWEEP = 10;
36
+
24
37
  /**
25
38
  * @typedef {Object} MongoBootstrapOptions
26
39
  * @property {string} [namespace='default'] - Kubernetes namespace.
@@ -135,6 +148,30 @@ class MongoBootstrap {
135
148
  ` return true;`,
136
149
  `};`,
137
150
 
151
+ // Recovery for a node holding a config it is not a member of. A forced
152
+ // reconfig is the only way out: it is accepted on a non-primary node,
153
+ // which an ordinary reconfig is not. Auth is best-effort — when the set
154
+ // already has users the unauthenticated pass fails here and the caller's
155
+ // authenticated pass performs the recovery.
156
+ //
157
+ // The recovery config has ONE member. Forcing the full member list back
158
+ // in place leaves the node needing a majority of the others to elect it,
159
+ // and they are typically holding their own stale configs from the same
160
+ // dead cluster, so no election ever completes. A single-member set needs
161
+ // only its own vote and becomes writable at once; reconfigure() then
162
+ // widens it — the same two-step the pristine path takes after rs.initiate.
163
+ `const currentConfigVersion = () => { try { return rs.conf().version || 0; } catch(e) { return 0; } };`,
164
+ `const forceReconfig = () => {`,
165
+ ` try { ensureAdminAuth(); } catch(e) {}`,
166
+ ` const soloConfig = {`,
167
+ ` _id: desiredConfig._id,`,
168
+ ` version: currentConfigVersion() + 1,`,
169
+ ` members: [{ _id: 0, host: "localhost:" + mePort }],`,
170
+ ` };`,
171
+ ` rs.reconfig(soloConfig, { force: true });`,
172
+ ` print("SUCCESS_FORCE_RECONFIGURED");`,
173
+ `};`,
174
+
138
175
  `const reconfigure = () => {`,
139
176
  ` if (!ensureAdminAuth()) return false;`,
140
177
  ` const cur = rs.conf();`,
@@ -149,28 +186,39 @@ class MongoBootstrap {
149
186
  ` return true;`,
150
187
  `};`,
151
188
 
152
- // Determine if replica set is already initialized
153
- `let initialized = false;`,
189
+ // Classify before acting. Three distinct states reach this script:
190
+ // pristine no config yet (fresh volume)
191
+ // orphaned — a config exists but this node is not a member of it, which
192
+ // is what a volume retained from an earlier cluster leaves
193
+ // behind; mongod parks in REMOVED and never elects itself
194
+ // live — a usable config, or one hidden behind auth
195
+ // The distinction matters for ordering: an orphaned node must be force
196
+ // reconfigured BEFORE waiting for a primary, because it can never become
197
+ // writable on its own. Waiting first is an unconditional timeout.
198
+ `const matchesAny = (msg, list) => list.some(s => msg.includes(s));`,
199
+ `let state = "live";`,
154
200
  `try {`,
155
201
  ` const s = rs.status();`,
156
- ` if (s && s.ok === 1) initialized = true;`,
202
+ ` if (!s || s.ok !== 1) state = "pristine";`,
157
203
  `} catch(e) {`,
158
204
  ` const msg = String(e);`,
159
- ` if (msg.includes("requires authentication") || msg.includes("Unauthorized") || msg.includes("not authorized")) {`,
160
- ` initialized = true;`,
161
- ` } else if (!msg.includes("NotYetInitialized") && !msg.includes("no replset config") && !msg.includes("maps to this node")) {`,
162
- ` throw e;`,
163
- ` }`,
205
+ ` const ORPHANED = ["not a member of it", "InvalidReplicaSetConfig", "maps to this node"];`,
206
+ ` if (matchesAny(msg, ORPHANED)) state = "orphaned";`,
207
+ ` else if (matchesAny(msg, ["NotYetInitialized", "no replset config"])) state = "pristine";`,
208
+ ` else if (matchesAny(msg, ["requires authentication", "Unauthorized", "not authorized"])) state = "live";`,
209
+ ` else throw e;`,
164
210
  `}`,
211
+ `print("REPLSET_STATE_" + state.toUpperCase());`,
165
212
 
166
- // Not initialized: bootstrap via localhost
167
- `if (!initialized) {`,
213
+ `if (state === "pristine") {`,
168
214
  ` try {`,
169
215
  ` rs.initiate({ _id: desiredConfig._id, members: [{ _id: 0, host: "localhost:" + mePort }] });`,
170
216
  ` } catch(e) {`,
171
217
  ` const msg = String(e);`,
172
218
  ` if (!msg.includes("already initialized") && !msg.includes("AlreadyInitialized")) throw e;`,
173
219
  ` }`,
220
+ `} else if (state === "orphaned") {`,
221
+ ` forceReconfig();`,
174
222
  `}`,
175
223
 
176
224
  // Wait for primary, create user, then reconfig to full host list
@@ -189,7 +237,7 @@ class MongoBootstrap {
189
237
  static findNodesMissingMongoMount(kindNodes) {
190
238
  return kindNodes.filter((node) => {
191
239
  const inspect = shellExec(
192
- `sudo docker inspect ${node} --format '{{range .Mounts}}{{if eq .Destination "/data/mongodb"}}yes{{end}}{{end}}'`,
240
+ `sudo docker inspect ${node} --format '{{range .Mounts}}{{if eq .Destination "${MONGODB_DATA_ROOT}"}}yes{{end}}{{end}}'`,
193
241
  { stdout: true, silent: true, silentOnError: true },
194
242
  );
195
243
  return !inspect.trim().includes('yes');
@@ -210,7 +258,7 @@ class MongoBootstrap {
210
258
  logger.info('No Kind nodes detected for hostPath cleanup.');
211
259
  return;
212
260
  }
213
- const basePath = '/data/mongodb';
261
+ const basePath = MONGODB_DATA_ROOT;
214
262
  for (const node of nodes) {
215
263
  const prepareCmd = Array.from(
216
264
  { length: replicaCount },
@@ -232,12 +280,14 @@ class MongoBootstrap {
232
280
  * an empty /data/db regardless of bind-mount staleness.
233
281
  *
234
282
  * @param {string[]} kindNodes - List of Kind node container names.
235
- * @param {string} [basePath='/data/mongodb'] - The base path containing v0/v1/v2 subdirs.
283
+ * @param {number} [replicaCount=3] - Number of replica ordinal directories to clean. Must track
284
+ * the deployed replica count, or members above the third start on stale data.
285
+ * @param {string} [basePath='/data/mongodb'] - The base path containing the v<ordinal> subdirs.
236
286
  */
237
- static remountKindMongoVolume(kindNodes, basePath = '/data/mongodb') {
287
+ static remountKindMongoVolume(kindNodes, replicaCount = MONGODB_DEFAULT_REPLICA_COUNT, basePath = MONGODB_DATA_ROOT) {
238
288
  for (const node of kindNodes) {
239
289
  logger.info(`Cleaning MongoDB data dirs inside Kind node '${node}'...`);
240
- for (let i = 0; i < 3; i++) {
290
+ for (let i = 0; i < replicaCount; i++) {
241
291
  const dir = `${basePath}/v${i}`;
242
292
  // Ensure directory exists, wipe all contents (including hidden files), set open permissions
243
293
  // so the pod's initContainer chown can run without issues.
@@ -264,23 +314,174 @@ class MongoBootstrap {
264
314
 
265
315
  /**
266
316
  * Creates or updates Kubernetes secrets required by the MongoDB statefulset.
317
+ *
318
+ * Prefers the SOPS/Age encrypted store, exactly like the MariaDB/MySQL/PostgreSQL branches of
319
+ * cluster init: when `engine-private/secrets/<ns>/<name>.enc.yaml` exists it is decrypted
320
+ * straight into `kubectl apply`, and only otherwise is the secret seeded from its plaintext
321
+ * origin seed file.
322
+ *
323
+ * The seed path uses `--from-literal`, which places the credential in the command string and so
324
+ * in the process table and the command log; `disableLog` keeps it out of the log at least. The
325
+ * encrypted path has no such exposure — the value only ever crosses an anonymous pipe.
267
326
  * @param {string} namespace - Target namespace.
268
327
  * @param {string} enginePrivateRoot - Path to engine-private directory.
269
328
  */
270
329
  static ensureMongoSecrets(namespace, enginePrivateRoot) {
271
- const keyfile = MongoBootstrap.readCredential(`${enginePrivateRoot}/mongodb-keyfile`);
272
- const { username, password } = MongoBootstrap.readMongoCredentials(enginePrivateRoot);
330
+ if (!Underpost.secret.sops.applyIfPresent('mongodb-keyfile', namespace)) {
331
+ const keyfile = MongoBootstrap.readCredential(`${enginePrivateRoot}/mongodb-keyfile`);
332
+ shellExec(
333
+ `sudo kubectl create secret generic mongodb-keyfile` +
334
+ ` --from-literal=mongodb-keyfile="${keyfile.replace(/'/g, "'\\''")}"` +
335
+ ` --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
336
+ { disableLog: true },
337
+ );
338
+ logger.info(`Seeded mongodb-keyfile from ${enginePrivateRoot}/mongodb-keyfile`);
339
+ }
273
340
 
274
- shellExec(
275
- `sudo kubectl create secret generic mongodb-keyfile` +
276
- ` --from-literal=mongodb-keyfile="${keyfile.replace(/'/g, "'\\''")}"` +
277
- ` --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
278
- );
279
- shellExec(
280
- `sudo kubectl create secret generic mongodb-secret` +
281
- ` --from-literal=username="${username}" --from-literal=password="${password}"` +
282
- ` --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
283
- );
341
+ if (!Underpost.secret.sops.applyIfPresent('mongodb-secret', namespace)) {
342
+ const { username, password } = MongoBootstrap.readMongoCredentials(enginePrivateRoot);
343
+ shellExec(
344
+ `sudo kubectl create secret generic mongodb-secret` +
345
+ ` --from-literal=username="${username}" --from-literal=password="${password}"` +
346
+ ` --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
347
+ { disableLog: true },
348
+ );
349
+ logger.info(`Seeded mongodb-secret from ${enginePrivateRoot}/mongodb-{username,password}`);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Renders one hostPath PersistentVolume per replica, each pre-bound to that member's claim.
355
+ *
356
+ * The volume set is a function of the replica count, so it is generated rather than read from a
357
+ * fixed manifest — a static file can only describe a fixed number, and any `--replicas` above
358
+ * it leaves the surplus members unbindable.
359
+ * @param {number} replicaCount - Number of members to provision volumes for.
360
+ * @param {string} namespace - Namespace the claims live in.
361
+ * @returns {string} Multi-document PersistentVolume YAML.
362
+ */
363
+ static buildReplicaVolumeManifest(replicaCount, namespace) {
364
+ return Array.from({ length: replicaCount }, (_, i) =>
365
+ [
366
+ 'apiVersion: v1',
367
+ 'kind: PersistentVolume',
368
+ 'metadata:',
369
+ ` name: ${MONGODB_STATEFULSET_NAME}-pv-${i}`,
370
+ ' labels:',
371
+ ` app: ${MONGODB_STATEFULSET_NAME}`,
372
+ 'spec:',
373
+ ' capacity:',
374
+ ' storage: 5Gi',
375
+ ' accessModes:',
376
+ ' - ReadWriteOnce',
377
+ ' persistentVolumeReclaimPolicy: Retain',
378
+ ` storageClassName: ${MONGODB_STORAGE_CLASS_NAME}`,
379
+ // claimRef pins each volume to exactly one member, so ordinals can never cross-bind and
380
+ // land two mongod processes on one data directory.
381
+ ' claimRef:',
382
+ ` namespace: ${namespace}`,
383
+ ` name: ${MONGODB_STATEFULSET_NAME}-storage-${MONGODB_STATEFULSET_NAME}-${i}`,
384
+ ' hostPath:',
385
+ ` path: ${MONGODB_DATA_ROOT}/v${i}`,
386
+ ' type: DirectoryOrCreate',
387
+ ].join('\n'),
388
+ ).join('\n---\n');
389
+ }
390
+
391
+ /**
392
+ * Applies the generated replica volumes, and removes any volume left over from a larger previous
393
+ * replica count so a scale-down does not strand a PV bound to a claim that no longer exists.
394
+ * @param {string} namespace - Target namespace.
395
+ * @param {number} replicaCount - Number of members to provision volumes for.
396
+ */
397
+ static applyReplicaVolumes(namespace, replicaCount) {
398
+ const manifest = MongoBootstrap.buildReplicaVolumeManifest(replicaCount, namespace);
399
+ shellExec(`kubectl apply -f - <<'UNDERPOST_MONGO_PV_EOF'\n${manifest}\nUNDERPOST_MONGO_PV_EOF`);
400
+ logger.info(`Applied ${replicaCount} MongoDB replica volume(s)`);
401
+
402
+ const stale = shellExec(`kubectl get pv -l app=${MONGODB_STATEFULSET_NAME} -o name 2>/dev/null || true`, {
403
+ stdout: true,
404
+ silent: true,
405
+ silentOnError: true,
406
+ })
407
+ .split('\n')
408
+ .map((name) => name.replace('persistentvolume/', '').trim())
409
+ .filter((name) => {
410
+ const ordinal = name.startsWith(`${MONGODB_STATEFULSET_NAME}-pv-`)
411
+ ? Number(name.slice(`${MONGODB_STATEFULSET_NAME}-pv-`.length))
412
+ : NaN;
413
+ return Number.isInteger(ordinal) && ordinal >= replicaCount;
414
+ });
415
+ for (const name of stale) {
416
+ shellExec(`kubectl delete pv ${name} --ignore-not-found`);
417
+ logger.info(`Removed stale replica volume ${name} (beyond replica count ${replicaCount})`);
418
+ }
419
+ }
420
+
421
+ /**
422
+ * Reads the claim-to-volume-to-path mapping for every replica and logs it. Non-throwing, so it
423
+ * can enrich a failure path without masking the original error.
424
+ * @param {string} namespace - Target namespace.
425
+ * @param {number} replicaCount - Expected number of members.
426
+ * @returns {Array<{claim: string, volume: string, path: string}>} Bindings in ordinal order.
427
+ */
428
+ static reportReplicaVolumeBindings(namespace, replicaCount) {
429
+ const bindings = [];
430
+ for (let i = 0; i < replicaCount; i++) {
431
+ const claim = `${MONGODB_STATEFULSET_NAME}-storage-${MONGODB_STATEFULSET_NAME}-${i}`;
432
+ const volume = shellExec(
433
+ `kubectl get pvc ${claim} -n ${namespace} -o jsonpath='{.spec.volumeName}' 2>/dev/null || true`,
434
+ { stdout: true, silent: true, silentOnError: true },
435
+ ).trim();
436
+ const path = volume
437
+ ? shellExec(`kubectl get pv ${volume} -o jsonpath='{.spec.hostPath.path}' 2>/dev/null || true`, {
438
+ stdout: true,
439
+ silent: true,
440
+ silentOnError: true,
441
+ }).trim()
442
+ : '';
443
+ bindings.push({ claim, volume, path });
444
+ }
445
+ logger.info('MongoDB replica volume bindings', bindings);
446
+ return bindings;
447
+ }
448
+
449
+ /**
450
+ * Verifies every replica PVC bound to its own distinct hostPath.
451
+ *
452
+ * Two members sharing one directory is unrecoverable at the mongod level — the second dies on
453
+ * `WiredTiger.lock: fcntl: Resource temporarily unavailable` after the readiness wait has
454
+ * already burned its timeout, and the real cause (a volume binding, not MongoDB) is invisible
455
+ * in the pod logs. Checking the binding directly turns that into an immediate, named failure.
456
+ * @param {string} namespace - Target namespace.
457
+ * @param {number} replicaCount - Expected number of members.
458
+ * @throws {Error} When a claim is unbound, or two claims share a backing path.
459
+ */
460
+ static assertReplicaVolumeBindings(namespace, replicaCount) {
461
+ const bindings = MongoBootstrap.reportReplicaVolumeBindings(namespace, replicaCount);
462
+
463
+ const unbound = bindings.filter((binding) => !binding.volume);
464
+ if (unbound.length > 0)
465
+ throw new Error(
466
+ `MongoDB claims are not bound to a PersistentVolume: ${unbound.map((b) => b.claim).join(', ')}. ` +
467
+ `Expected one volume per replica (${replicaCount} total); check that the generated PVs applied ` +
468
+ `and that each claimRef matches.`,
469
+ );
470
+
471
+ // Only hostPath-backed volumes expose a path to compare; a dynamically provisioned volume
472
+ // reports none, which is itself worth surfacing since these PVs are meant to be static.
473
+ const byPath = bindings.reduce((acc, binding) => {
474
+ if (binding.path) (acc[binding.path] = acc[binding.path] || []).push(binding.claim);
475
+ return acc;
476
+ }, {});
477
+ const shared = Object.entries(byPath).filter(([, claims]) => claims.length > 1);
478
+ if (shared.length > 0)
479
+ throw new Error(
480
+ `MongoDB members would share a data directory, which mongod cannot survive: ` +
481
+ shared.map(([path, claims]) => `${path} <- ${claims.join(' + ')}`).join('; ') +
482
+ `. Delete the claims and PVs, then redeploy: ` +
483
+ `kubectl delete pvc -n ${namespace} -l app=mongodb; kubectl delete pv -l app=mongodb`,
484
+ );
284
485
  }
285
486
 
286
487
  /**
@@ -323,7 +524,15 @@ class MongoBootstrap {
323
524
  } = options;
324
525
 
325
526
  const enginePrivateRoot = `${process.cwd()}/engine-private`;
326
- const effectiveReplicaCount = Math.max(Number(replicaCount) || MONGODB_DEFAULT_REPLICA_COUNT, 3);
527
+ // No upward clamp: an explicit `--replicas 2` must deploy two members, not be silently
528
+ // raised to the default.
529
+ const effectiveReplicaCount = resolveReplicaCount(replicaCount, MONGODB_DEFAULT_REPLICA_COUNT);
530
+ if (effectiveReplicaCount % 2 === 0)
531
+ logger.warn(
532
+ `Deploying ${effectiveReplicaCount} MongoDB members. An even-sized replica set has no ` +
533
+ `majority when one member is down, so the set becomes read-only on a single failure. ` +
534
+ `Odd counts (3, 5) are recommended.`,
535
+ );
327
536
  const mongoRootUsername = MongoBootstrap.readCredential(`${enginePrivateRoot}/mongodb-username`);
328
537
  const mongoRootPassword = MongoBootstrap.readCredential(`${enginePrivateRoot}/mongodb-password`);
329
538
  const mongoReplicaHosts = resolveMongoReplicaHosts({
@@ -364,16 +573,7 @@ class MongoBootstrap {
364
573
  }
365
574
  shellExec(`rm -f ${tarPath}`);
366
575
  } else {
367
- const criSock =
368
- shellExec('test -S /var/run/crio/crio.sock && echo crio || echo containerd', {
369
- stdout: true,
370
- silent: true,
371
- }).trim() === 'crio'
372
- ? 'unix:///var/run/crio/crio.sock'
373
- : 'unix:///run/containerd/containerd.sock';
374
- shellExec(
375
- `sudo env PATH="$PATH:/usr/local/bin:/usr/bin" crictl --runtime-endpoint ${criSock} pull mongo:latest`,
376
- );
576
+ shellExec(crictlCommandFactory('pull mongo:latest', { k3s: clusterType === 'k3s' }));
377
577
  }
378
578
  }
379
579
 
@@ -386,24 +586,61 @@ class MongoBootstrap {
386
586
 
387
587
  // Clean data if reset or kind
388
588
  if (reset || isKind) {
589
+ // Delete the StatefulSet's PVCs by name. A label selector cannot reach them: the
590
+ // `volumeClaimTemplates` entry carries no labels, so `-l app=mongodb` matches nothing and
591
+ // silently leaves the previous run's PVCs Bound. With `persistentVolumeReclaimPolicy:
592
+ // Retain` those stale claims keep their old PVs, so the freshly created `mongodb-pv-N`
593
+ // (whose `claimRef` names the same PVCs) never binds and pods mount the previous volumes —
594
+ // which is how two members end up on one hostPath and mongod dies on the WiredTiger lock.
595
+ for (let i = 0; i < MONGODB_ORDINAL_SWEEP; i++)
596
+ shellExec(
597
+ `kubectl delete pvc ${MONGODB_STATEFULSET_NAME}-storage-${MONGODB_STATEFULSET_NAME}-${i} -n ${namespace} --ignore-not-found`,
598
+ );
389
599
  shellExec(`kubectl delete pvc -l app=mongodb -n ${namespace} --ignore-not-found`);
390
600
  shellExec(`kubectl delete pvc mongodb-pvc -n ${namespace} --ignore-not-found`);
391
601
  shellExec(`kubectl delete pv -l app=mongodb --ignore-not-found`);
392
602
  shellExec(`kubectl delete pv mongodb-pv --ignore-not-found`);
393
603
 
394
- if (isKind) {
395
- shellExec('sudo mkdir -p /data/mongodb');
396
- for (let i = 0; i < effectiveReplicaCount; i++) {
397
- shellExec(`sudo rm -rf /data/mongodb/v${i}`);
398
- shellExec(`sudo mkdir -p /data/mongodb/v${i}`);
399
- }
400
- // Fix any stale bind mounts caused by prior deletion of /data/mongodb on the host
401
- MongoBootstrap.remountKindMongoVolume(kindNodes);
604
+ // The data itself, for every cluster type. The PVs are hostPath with
605
+ // `persistentVolumeReclaimPolicy: Retain`, so deleting the objects frees
606
+ // nothing: the next run re-binds the same directories and mongod boots
607
+ // with the previous cluster's replica set config, ending up outside it.
608
+ // Only this node's copy is removed — on a multi-node cluster, wipe the
609
+ // other nodes' /data/mongodb before rebuilding there.
610
+ logger.info('Removing retained MongoDB hostPath data', {
611
+ path: MONGODB_DATA_ROOT,
612
+ replicas: effectiveReplicaCount,
613
+ });
614
+ shellExec(`sudo mkdir -p ${MONGODB_DATA_ROOT}`);
615
+ for (let i = 0; i < effectiveReplicaCount; i++) {
616
+ shellExec(`sudo rm -rf ${MONGODB_DATA_ROOT}/v${i}`);
617
+ shellExec(`sudo mkdir -p ${MONGODB_DATA_ROOT}/v${i}`);
402
618
  }
619
+ // Fix any stale bind mounts caused by prior deletion of /data/mongodb on the host
620
+ if (isKind) MongoBootstrap.remountKindMongoVolume(kindNodes, effectiveReplicaCount);
403
621
  }
404
622
 
405
623
  // Apply manifests
624
+ // A StorageClass `provisioner` is immutable, so a plain apply fails against a class created
625
+ // with a different one. Clusters provisioned before the switch to `kubernetes.io/no-provisioner`
626
+ // carry the dynamic `rancher.io/local-path`; recreate in that case. Deleting the class is safe
627
+ // — bound PVs and PVCs reference it by name only and are untouched.
628
+ const storageClassProvisioner = shellExec(
629
+ `kubectl get storageclass ${MONGODB_STORAGE_CLASS_NAME} -o jsonpath='{.provisioner}' 2>/dev/null || true`,
630
+ { stdout: true, silent: true, silentOnError: true },
631
+ ).trim();
632
+ if (storageClassProvisioner && storageClassProvisioner !== MONGODB_STORAGE_CLASS_PROVISIONER) {
633
+ logger.warn(
634
+ `StorageClass ${MONGODB_STORAGE_CLASS_NAME} uses provisioner '${storageClassProvisioner}'; recreating as ` +
635
+ `'${MONGODB_STORAGE_CLASS_PROVISIONER}' so the static replica PVs bind deterministically.`,
636
+ );
637
+ shellExec(`kubectl delete storageclass ${MONGODB_STORAGE_CLASS_NAME} --ignore-not-found`);
638
+ }
406
639
  shellExec(`kubectl apply -f ${underpostRoot}/manifests/mongodb/storage-class.yaml -n ${namespace}`);
640
+ // One PV per member, generated from the effective replica count. A static manifest can only
641
+ // ever describe a fixed number, so any `--replicas` above it leaves the extra members with no
642
+ // volume to bind and the StatefulSet stalls forever on Pending.
643
+ MongoBootstrap.applyReplicaVolumes(namespace, effectiveReplicaCount);
407
644
  shellExec(`kubectl apply -k ${underpostRoot}/manifests/mongodb -n ${namespace}`);
408
645
  shellExec(
409
646
  `kubectl scale statefulset/${MONGODB_STATEFULSET_NAME} --replicas=${effectiveReplicaCount} -n ${namespace}`,
@@ -412,12 +649,17 @@ class MongoBootstrap {
412
649
  // Wait for all pods
413
650
  const failedCount = await MongoBootstrap.waitForPods(namespace, effectiveReplicaCount);
414
651
  if (failedCount > 0) {
652
+ // Surface the volume topology before failing: a stalled rollout is far more often a binding
653
+ // problem than a MongoDB one, and the mapping names it immediately.
654
+ MongoBootstrap.reportReplicaVolumeBindings(namespace, effectiveReplicaCount);
415
655
  throw new Error(
416
656
  `MongoDB replica pods did not reach Running state in time. ` +
417
657
  `Ensure podManagementPolicy is set to OrderedReady in statefulset.yaml.`,
418
658
  );
419
659
  }
420
660
 
661
+ MongoBootstrap.assertReplicaVolumeBindings(namespace, effectiveReplicaCount);
662
+
421
663
  // Build the bootstrap script
422
664
  const defaultHosts = Array.from(
423
665
  { length: effectiveReplicaCount },
@@ -528,13 +770,12 @@ class MongoBootstrap {
528
770
  // Phase 4: Delete MongoDB PVCs and PVs (both current and legacy mongodb-4.4)
529
771
  logger.info('Phase 4/6: Deleting MongoDB PersistentVolumeClaims and PersistentVolumes...');
530
772
  // Delete PVCs from volumeClaimTemplates
531
- for (let i = 0; i < 10; i++) {
773
+ for (let i = 0; i < MONGODB_ORDINAL_SWEEP; i++) {
532
774
  shellExec(`kubectl delete pvc mongodb-storage-mongodb-${i} -n ${namespace} --ignore-not-found`);
533
775
  }
534
776
  shellExec(`kubectl delete pvc mongodb-pvc -n ${namespace} --ignore-not-found`);
535
- shellExec(`kubectl delete pv mongodb-pv-0 --ignore-not-found`);
536
- shellExec(`kubectl delete pv mongodb-pv-1 --ignore-not-found`);
537
- shellExec(`kubectl delete pv mongodb-pv-2 --ignore-not-found`);
777
+ for (let i = 0; i < MONGODB_ORDINAL_SWEEP; i++)
778
+ shellExec(`kubectl delete pv ${MONGODB_STATEFULSET_NAME}-pv-${i} --ignore-not-found`);
538
779
  shellExec(`kubectl delete pv mongodb-pv --ignore-not-found`);
539
780
  // Also catch any remaining PVs with the app=mongodb label
540
781
  shellExec(`kubectl delete pv -l app=mongodb --ignore-not-found`);
@@ -550,10 +791,10 @@ class MongoBootstrap {
550
791
  // IMPORTANT: Do NOT remove /data/mongodb itself — it is bind-mounted into Kind node
551
792
  // containers by inode. Removing it makes the bind mount stale; only clear subdirs.
552
793
  logger.info('Phase 5/6: Cleaning up MongoDB hostPath data...');
553
- shellExec(`sudo mkdir -p /data/mongodb`);
554
- for (let i = 0; i < 3; i++) {
555
- shellExec(`sudo rm -rf /data/mongodb/v${i}`);
556
- shellExec(`sudo mkdir -p /data/mongodb/v${i}`);
794
+ shellExec(`sudo mkdir -p ${MONGODB_DATA_ROOT}`);
795
+ for (let i = 0; i < MONGODB_ORDINAL_SWEEP; i++) {
796
+ shellExec(`sudo rm -rf ${MONGODB_DATA_ROOT}/v${i}`);
797
+ shellExec(`sudo mkdir -p ${MONGODB_DATA_ROOT}/v${i}`);
557
798
  }
558
799
  // For Kind: repair any stale bind mounts via nsenter (overmounts with current host inode)
559
800
  if (isKind) {
@@ -15,6 +15,30 @@ const MONGODB_STATEFULSET_NAME = 'mongodb';
15
15
  const MONGODB_DEFAULT_AUTH_SOURCE = 'admin';
16
16
  const MONGODB_DEFAULT_REPLICA_SET = 'rs0';
17
17
  const MONGODB_DEFAULT_REPLICA_COUNT = 3;
18
+ // Node-local base path backing the hostPath PVs (`<root>/v<replica index>`),
19
+ // generated one-per-replica by MongoBootstrap.applyReplicaVolumes().
20
+ const MONGODB_DATA_ROOT = '/data/mongodb';
21
+ // Replica volumes are hostPath PVs generated one per member, each pinned by `claimRef`. The class
22
+ // must stay static: a dynamic provisioner competes with the static binder and makes
23
+ // member-to-directory assignment non-deterministic.
24
+ const MONGODB_STORAGE_CLASS_NAME = 'mongodb-storage-class';
25
+ const MONGODB_STORAGE_CLASS_PROVISIONER = 'kubernetes.io/no-provisioner';
26
+ /**
27
+ * Mongoose connection options for MongoDB, with sensible defaults for production and development environments.
28
+ * @type {import('mongoose').ConnectOptions}
29
+ */
30
+ const MONGODB_CONNECTION_OPTIONS = {
31
+ autoIndex: process.env.NODE_ENV !== 'production',
32
+ heartbeatFrequencyMS: 10000,
33
+ maxConnecting: 2,
34
+ maxPoolSize: 10,
35
+ minPoolSize: 0,
36
+ retryReads: true,
37
+ retryWrites: true,
38
+ serverSelectionTimeoutMS: 30000,
39
+ socketTimeoutMS: 120000,
40
+ waitQueueTimeoutMS: 10000,
41
+ };
18
42
 
19
43
  /**
20
44
  * Resolves MongoDB replica hosts from explicit input or StatefulSet defaults.
@@ -49,6 +73,26 @@ const resolveMongoReplicaHosts = ({ hostList = '', replicaCount = MONGODB_DEFAUL
49
73
  * 3. No built-in defaults — both `host` and `name` are required from the caller or environment.
50
74
  */
51
75
  class MongooseDBService {
76
+ /**
77
+ * Establishes a Mongoose connection to the specified MongoDB instance.
78
+ *
79
+ * @async
80
+ * @param {object|string} configOrHost - Either a db config object or a legacy host string.
81
+ * @param {string} [configOrHost.host] - Legacy single host or comma-separated host list.
82
+ * @param {string} [configOrHost.name] - The database name.
83
+ * @param {string} [configOrHost.replicaSet] - The MongoDB replica set name.
84
+ * @param {string} [configOrHost.authSource] - The authentication database.
85
+ * @param {string} [configOrHost.user] - The MongoDB username.
86
+ * @param {string} [configOrHost.password] - The MongoDB password.
87
+ * @param {string} [name] - Legacy database name when a host string is passed.
88
+ * @returns {Promise<mongoose.Connection>} A promise that resolves to the established Mongoose connection object.
89
+ * @throws {Error} If neither the argument nor the corresponding environment variable supplies a value.
90
+ */
91
+ async connect(configOrHost, name) {
92
+ const uri = this.buildUri(configOrHost, name);
93
+ // if (process.env.NODE_ENV === 'development') logger.info(`Connecting to MongoDB with URI`, uri);
94
+ return await mongoose.createConnection(uri, MONGODB_CONNECTION_OPTIONS).asPromise();
95
+ }
52
96
  /**
53
97
  * Normalizes Mongo host inputs into plain host:port entries.
54
98
  * @param {Array<string>|string} hosts - Host input as list or comma-separated string.
@@ -125,38 +169,6 @@ class MongooseDBService {
125
169
  return `mongodb://${credentials}${config.hosts.join(',')}/${config.dbName}${query.size ? `?${query.toString()}` : ''}`;
126
170
  }
127
171
 
128
- /**
129
- * Establishes a Mongoose connection to the specified MongoDB instance.
130
- *
131
- * @async
132
- * @param {object|string} configOrHost - Either a db config object or a legacy host string.
133
- * @param {string} [configOrHost.host] - Legacy single host or comma-separated host list.
134
- * @param {string} [configOrHost.name] - The database name.
135
- * @param {string} [configOrHost.replicaSet] - The MongoDB replica set name.
136
- * @param {string} [configOrHost.authSource] - The authentication database.
137
- * @param {string} [configOrHost.user] - The MongoDB username.
138
- * @param {string} [configOrHost.password] - The MongoDB password.
139
- * @param {string} [name] - Legacy database name when a host string is passed.
140
- * @returns {Promise<mongoose.Connection>} A promise that resolves to the established Mongoose connection object.
141
- * @throws {Error} If neither the argument nor the corresponding environment variable supplies a value.
142
- */
143
- async connect(configOrHost, name) {
144
- const uri = this.buildUri(configOrHost, name);
145
- if (process.env.NODE_ENV === 'development') logger.info(`Connecting to MongoDB with URI`, uri);
146
- return await mongoose
147
- .createConnection(uri, {
148
- autoIndex: process.env.NODE_ENV !== 'production',
149
- heartbeatFrequencyMS: 10000,
150
- maxPoolSize: 20,
151
- minPoolSize: 2,
152
- retryReads: true,
153
- retryWrites: true,
154
- serverSelectionTimeoutMS: 5000,
155
- socketTimeoutMS: 45000,
156
- })
157
- .asPromise();
158
- }
159
-
160
172
  /**
161
173
  * Dynamically loads Mongoose models for a list of APIs and binds them to the given connection.
162
174
  *
@@ -191,9 +203,12 @@ const MongooseDB = new MongooseDBService();
191
203
  export {
192
204
  MongooseDB,
193
205
  MongooseDBService as MongooseDBClass,
206
+ MONGODB_DATA_ROOT,
194
207
  MONGODB_DEFAULT_REPLICA_COUNT,
195
208
  MONGODB_DEFAULT_REPLICA_SET,
196
209
  MONGODB_SERVICE_NAME,
197
210
  MONGODB_STATEFULSET_NAME,
211
+ MONGODB_STORAGE_CLASS_NAME,
212
+ MONGODB_STORAGE_CLASS_PROVISIONER,
198
213
  resolveMongoReplicaHosts,
199
214
  };
package/src/index.js CHANGED
@@ -45,7 +45,7 @@ class Underpost {
45
45
  * @type {String}
46
46
  * @memberof Underpost
47
47
  */
48
- static version = 'v3.2.70';
48
+ static version = 'v3.2.90';
49
49
 
50
50
  /**
51
51
  * Required Node.js major version