underpost 3.1.3 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.env.example +0 -2
  2. package/.github/workflows/ghpkg.ci.yml +4 -4
  3. package/.github/workflows/npmpkg.ci.yml +28 -11
  4. package/.github/workflows/pwa-microservices-template-page.cd.yml +3 -4
  5. package/.github/workflows/pwa-microservices-template-test.ci.yml +3 -3
  6. package/.github/workflows/release.cd.yml +4 -4
  7. package/CHANGELOG.md +324 -1
  8. package/CLI-HELP.md +49 -3
  9. package/README.md +3 -2
  10. package/bin/build.js +18 -12
  11. package/bin/deploy.js +177 -124
  12. package/bin/file.js +3 -0
  13. package/conf.js +3 -2
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  17. package/manifests/deployment/dd-test-development/deployment.yaml +72 -50
  18. package/manifests/deployment/dd-test-development/proxy.yaml +13 -4
  19. package/manifests/deployment/playwright/deployment.yaml +1 -1
  20. package/nodemon.json +1 -1
  21. package/package.json +22 -15
  22. package/scripts/rhel-grpc-setup.sh +56 -0
  23. package/src/api/file/file.ref.json +18 -0
  24. package/src/api/user/user.service.js +8 -7
  25. package/src/cli/cluster.js +7 -7
  26. package/src/cli/db.js +76 -242
  27. package/src/cli/deploy.js +104 -65
  28. package/src/cli/env.js +1 -0
  29. package/src/cli/fs.js +2 -1
  30. package/src/cli/index.js +42 -1
  31. package/src/cli/kubectl.js +211 -0
  32. package/src/cli/release.js +284 -0
  33. package/src/cli/repository.js +291 -75
  34. package/src/cli/run.js +188 -33
  35. package/src/cli/test.js +3 -3
  36. package/src/client/Default.index.js +3 -4
  37. package/src/client/components/core/AppStore.js +69 -0
  38. package/src/client/components/core/CalendarCore.js +2 -2
  39. package/src/client/components/core/DropDown.js +129 -17
  40. package/src/client/components/core/Keyboard.js +2 -2
  41. package/src/client/components/core/LogIn.js +2 -2
  42. package/src/client/components/core/LogOut.js +2 -2
  43. package/src/client/components/core/Modal.js +0 -1
  44. package/src/client/components/core/Panel.js +0 -1
  45. package/src/client/components/core/PanelForm.js +19 -19
  46. package/src/client/components/core/SocketIo.js +82 -29
  47. package/src/client/components/core/SocketIoHandler.js +75 -0
  48. package/src/client/components/core/Stream.js +143 -95
  49. package/src/client/components/core/Webhook.js +40 -7
  50. package/src/client/components/default/AppStoreDefault.js +5 -0
  51. package/src/client/components/default/LogInDefault.js +3 -3
  52. package/src/client/components/default/LogOutDefault.js +2 -2
  53. package/src/client/components/default/MenuDefault.js +5 -5
  54. package/src/client/components/default/SocketIoDefault.js +3 -51
  55. package/src/client/services/core/core.service.js +20 -8
  56. package/src/client/services/user/user.management.js +2 -2
  57. package/src/index.js +24 -1
  58. package/src/runtime/express/Express.js +18 -1
  59. package/src/runtime/lampp/Dockerfile +9 -2
  60. package/src/runtime/lampp/Lampp.js +4 -3
  61. package/src/runtime/wp/Dockerfile +64 -0
  62. package/src/runtime/wp/Wp.js +497 -0
  63. package/src/server/auth.js +24 -1
  64. package/src/server/backup.js +19 -1
  65. package/src/server/client-build-docs.js +9 -2
  66. package/src/server/client-build.js +31 -31
  67. package/src/server/client-formatted.js +109 -57
  68. package/src/server/ipfs-client.js +24 -1
  69. package/src/server/peer.js +8 -0
  70. package/src/server/runtime.js +25 -1
  71. package/src/server/start.js +6 -0
  72. package/src/ws/IoInterface.js +1 -10
  73. package/src/ws/IoServer.js +14 -33
  74. package/src/ws/core/channels/core.ws.chat.js +65 -20
  75. package/src/ws/core/channels/core.ws.mailer.js +113 -32
  76. package/src/ws/core/channels/core.ws.stream.js +90 -31
  77. package/src/ws/core/core.ws.connection.js +12 -33
  78. package/src/ws/core/core.ws.emit.js +10 -26
  79. package/src/ws/core/core.ws.server.js +25 -58
  80. package/src/ws/default/channels/default.ws.main.js +53 -12
  81. package/src/ws/default/default.ws.connection.js +26 -13
  82. package/src/ws/default/default.ws.server.js +30 -12
  83. package/src/client/components/default/ElementsDefault.js +0 -38
  84. package/src/ws/core/management/core.ws.chat.js +0 -8
  85. package/src/ws/core/management/core.ws.mailer.js +0 -16
  86. package/src/ws/core/management/core.ws.stream.js +0 -8
  87. package/src/ws/default/management/default.ws.main.js +0 -8
package/src/cli/deploy.js CHANGED
@@ -155,6 +155,7 @@ metadata:
155
155
  namespace: ${namespace ? namespace : 'default'}
156
156
  labels:
157
157
  app: ${deployId}-${env}-${suffix}
158
+ deploy-id: ${deployId}-${env}
158
159
  spec:
159
160
  replicas: ${replicas}
160
161
  selector:
@@ -164,6 +165,7 @@ spec:
164
165
  metadata:
165
166
  labels:
166
167
  app: ${deployId}-${env}-${suffix}
168
+ deploy-id: ${deployId}-${env}
167
169
  spec:
168
170
  containers:
169
171
  - name: ${deployId}-${env}-${suffix}
@@ -260,6 +262,14 @@ ${Underpost.deploy
260
262
  }
261
263
  fs.writeFileSync(`./engine-private/conf/${deployId}/build/${env}/deployment.yaml`, deploymentYamlParts, 'utf8');
262
264
 
265
+ Underpost.deploy.buildGrpcServiceManifest({
266
+ deployId,
267
+ env,
268
+ confServer,
269
+ namespace: options.namespace,
270
+ traffic: options.traffic && typeof options.traffic === 'string' ? options.traffic.split(',') : ['blue'],
271
+ });
272
+
263
273
  const confVolume = fs.existsSync(`./engine-private/conf/${deployId}/conf.volume.json`)
264
274
  ? JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.volume.json`, 'utf8'))
265
275
  : [];
@@ -369,6 +379,67 @@ ${Underpost.deploy
369
379
  }
370
380
  }
371
381
  },
382
+ /**
383
+ * Builds and writes a gRPC ClusterIP service YAML for a deployment.
384
+ * Scans conf.server.json for gRPC ports and emits grpc-service.yaml under
385
+ * `engine-private/conf/<deployId>/build/<env>/`. The selector always uses the
386
+ * explicit `app: <deployId>-<env>-<traffic>` label to target only the active
387
+ * colour (blue or green).
388
+ * @param {string} deployId - Deployment ID.
389
+ * @param {string} env - Environment ('development' or 'production').
390
+ * @param {object} confServer - Parsed conf.server.json content.
391
+ * @param {string} [namespace='default'] - Kubernetes namespace.
392
+ * @param {string[]} [traffic=['blue']] - Active traffic colour(s) ('blue', 'green', or both).
393
+ * @param {string|null} [host=null] - Specific host to scan for gRPC ports. If null, all hosts are scanned.
394
+ * @returns {string|null} - Path to the written YAML file, or null if no gRPC ports found.
395
+ * @memberof UnderpostDeploy
396
+ */
397
+ buildGrpcServiceManifest({ deployId, env, confServer, namespace = 'default', traffic = ['blue'], host = null }) {
398
+ const grpcPorts = new Set();
399
+ const hostsToScan = host ? [host] : Object.keys(confServer);
400
+ for (const h of hostsToScan) {
401
+ if (!confServer[h]) continue;
402
+ for (const path of Object.keys(confServer[h])) {
403
+ const grpc = confServer[h][path].grpc;
404
+ if (grpc && grpc.port) grpcPorts.add(parseInt(grpc.port));
405
+ }
406
+ }
407
+ if (grpcPorts.size === 0) return null;
408
+ const grpcPortsList = [...grpcPorts]
409
+ .map(
410
+ (port) => ` - name: grpc-${port}
411
+ protocol: TCP
412
+ port: ${port}
413
+ targetPort: ${port}`,
414
+ )
415
+ .join('\n');
416
+ let grpcServiceYaml = '';
417
+ for (const color of traffic) {
418
+ const grpcServiceName = `${deployId}-grpc-service-${env}-${color}`;
419
+ const selectorYaml = `app: ${deployId}-${env}-${color}`;
420
+ grpcServiceYaml += `---
421
+ apiVersion: v1
422
+ kind: Service
423
+ metadata:
424
+ name: ${grpcServiceName}
425
+ namespace: ${namespace}
426
+ labels:
427
+ app: ${grpcServiceName}
428
+ spec:
429
+ type: ClusterIP
430
+ selector:
431
+ ${selectorYaml}
432
+ ports:
433
+ ${grpcPortsList}
434
+ `;
435
+ logger.info(
436
+ `gRPC ClusterIP service YAML written: ${grpcServiceName} (selector: ${selectorYaml}, ports: ${[...grpcPorts].join(', ')})`,
437
+ );
438
+ }
439
+ const yamlPath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
440
+ fs.writeFileSync(yamlPath, grpcServiceYaml, 'utf8');
441
+ return yamlPath;
442
+ },
372
443
  /**
373
444
  * Builds a Certificate resource for a host using cert-manager.
374
445
  * @param {string} host - Hostname for which the certificate is being built.
@@ -478,6 +549,10 @@ spec:
478
549
  * @param {string} [options.kindType] - Type of Kubernetes resource to retrieve information for.
479
550
  * @param {number} [options.port] - Port number for exposing the deployment.
480
551
  * @param {string} [options.cmd] - Custom initialization command for deploymentYamlPartsFactory (comma-separated commands).
552
+ * @param {boolean} [options.k3s] - Whether to use k3s cluster context.
553
+ * @param {boolean} [options.kubeadm] - Whether to use kubeadm cluster context.
554
+ * @param {boolean} [options.kind] - Whether to use kind cluster context.
555
+ * @param {boolean} [options.gitClean] - Whether to run git clean on volume mount paths before copying.
481
556
  * @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
482
557
  * @memberof UnderpostDeploy
483
558
  */
@@ -515,6 +590,10 @@ spec:
515
590
  port: 0,
516
591
  exposePort: 0,
517
592
  cmd: '',
593
+ k3s: false,
594
+ kubeadm: false,
595
+ kind: false,
596
+ gitClean: false,
518
597
  },
519
598
  ) {
520
599
  const namespace = options.namespace ? options.namespace : 'default';
@@ -553,7 +632,7 @@ EOF`);
553
632
  env,
554
633
  traffic: Underpost.deploy.getCurrentTraffic(deployId, { namespace }),
555
634
  router: await Underpost.deploy.routerFactory(deployId, env),
556
- pods: await Underpost.deploy.get(deployId),
635
+ pods: await Underpost.kubectl.get(deployId),
557
636
  instances,
558
637
  });
559
638
  }
@@ -595,7 +674,7 @@ EOF`);
595
674
  if (!deployId) continue;
596
675
  if (options.expose === true) {
597
676
  const kindType = options.kindType ? options.kindType : 'svc';
598
- const svc = Underpost.deploy.get(deployId, kindType)[0];
677
+ const svc = Underpost.kubectl.get(deployId, kindType)[0];
599
678
  const port = options.exposePort
600
679
  ? parseInt(options.exposePort)
601
680
  : options.port
@@ -634,6 +713,8 @@ EOF`);
634
713
  version,
635
714
  namespace,
636
715
  nodeName: options.node ? options.node : env === 'development' ? 'kind-worker' : os.hostname(),
716
+ clusterContext: options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : 'kind',
717
+ gitClean: options.gitClean || false,
637
718
  });
638
719
  }
639
720
 
@@ -652,8 +733,11 @@ EOF`);
652
733
  : `manifests/deployment/${deployId}-${env}`;
653
734
 
654
735
  if (!options.remove) {
655
- if (!options.disableUpdateDeployment)
736
+ if (!options.disableUpdateDeployment) {
656
737
  shellExec(`sudo kubectl apply -f ./${manifestsPath}/deployment.yaml -n ${namespace}`);
738
+ const grpcServicePath = `./${manifestsPath}/grpc-service.yaml`;
739
+ if (fs.existsSync(grpcServicePath)) shellExec(`sudo kubectl apply -f ${grpcServicePath} -n ${namespace}`);
740
+ }
657
741
  if (!options.disableUpdateProxy)
658
742
  shellExec(`sudo kubectl apply -f ./${manifestsPath}/proxy.yaml -n ${namespace}`);
659
743
 
@@ -671,65 +755,6 @@ EOF`);
671
755
  ` + renderHosts,
672
756
  );
673
757
  },
674
- /**
675
- * Retrieves information about a deployment.
676
- * @param {string} deployId - Deployment ID for which information is being retrieved.
677
- * @param {string} kindType - Type of Kubernetes resource to retrieve information for (e.g. 'pods').
678
- * @param {string} namespace - Kubernetes namespace to retrieve information from.
679
- * @returns {Array<object>} - Array of objects containing information about the deployment.
680
- * @memberof UnderpostDeploy
681
- */
682
- get(deployId, kindType = 'pods', namespace = '') {
683
- const raw = shellExec(
684
- `sudo kubectl get ${kindType}${namespace ? ` -n ${namespace}` : ` --all-namespaces`} -o wide`,
685
- {
686
- stdout: true,
687
- disableLog: true,
688
- silent: true,
689
- },
690
- );
691
-
692
- const heads = raw
693
- .split(`\n`)[0]
694
- .split(' ')
695
- .filter((_r) => _r.trim());
696
-
697
- const pods = raw
698
- .split(`\n`)
699
- .filter((r) => (deployId ? r.match(deployId) : r.trim() && !r.match('NAME')))
700
- .map((r) => r.split(' ').filter((_r) => _r.trim()));
701
-
702
- const result = [];
703
-
704
- for (const row of pods) {
705
- const pod = {};
706
- let index = -1;
707
- for (const head of heads) {
708
- index++;
709
- pod[head] = row[index];
710
- }
711
- result.push(pod);
712
- }
713
-
714
- return result;
715
- },
716
-
717
- /**
718
- * Checks if a container file exists in a pod.
719
- * @param {object} options - Options for the check.
720
- * @param {string} options.podName - Name of the pod to check.
721
- * @param {string} options.path - Path to the container file to check.
722
- * @returns {boolean} - True if the container file exists, false otherwise.
723
- * @memberof UnderpostDeploy
724
- */
725
- existsContainerFile({ podName, path }) {
726
- const result = shellExec(`kubectl exec ${podName} -- test -f ${path} && echo "true" || echo "false"`, {
727
- stdout: true,
728
- disableLog: true,
729
- silent: true,
730
- }).trim();
731
- return result === 'true';
732
- },
733
758
  /**
734
759
  * Checks the status of a deployment.
735
760
  * @param {string} deployId - Deployment ID for which the status is being checked.
@@ -742,7 +767,7 @@ EOF`);
742
767
  */
743
768
  async checkDeploymentReadyStatus(deployId, env, traffic, ignoresNames = [], namespace = 'default') {
744
769
  const cmd = `underpost config get container-status`;
745
- const pods = Underpost.deploy.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
770
+ const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
746
771
  const readyPods = [];
747
772
  const notReadyPods = [];
748
773
  for (const pod of pods) {
@@ -814,6 +839,9 @@ EOF`);
814
839
 
815
840
  shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
816
841
 
842
+ const grpcServicePath = `./engine-private/conf/${deployId}/build/${env}/grpc-service.yaml`;
843
+ if (fs.existsSync(grpcServicePath)) shellExec(`kubectl apply -f ${grpcServicePath} -n ${namespace}`);
844
+
817
845
  Underpost.env.set(`${deployId}-${env}-traffic`, targetTraffic);
818
846
  },
819
847
 
@@ -829,6 +857,8 @@ EOF`);
829
857
  * @param {string} options.version - Version of the deployment.
830
858
  * @param {string} options.namespace - Kubernetes namespace for the deployment.
831
859
  * @param {string} options.nodeName - Node name for the deployment.
860
+ * @param {string} [options.clusterContext='kind'] - Cluster context type ('kind', 'kubeadm', or 'k3s').
861
+ * @param {boolean} [options.gitClean=false] - Whether to run git clean on volumeMountPath before copying.
832
862
  * @memberof UnderpostDeploy
833
863
  */
834
864
  deployVolume(
@@ -839,6 +869,8 @@ EOF`);
839
869
  version: '',
840
870
  namespace: '',
841
871
  nodeName: '',
872
+ clusterContext: 'kind',
873
+ gitClean: false,
842
874
  },
843
875
  ) {
844
876
  if (!volume.claimName) {
@@ -846,19 +878,26 @@ EOF`);
846
878
  return;
847
879
  }
848
880
  const { deployId, env, version, namespace } = options;
881
+ const clusterContext = options.clusterContext || 'kind';
849
882
  const pvcId = `${volume.claimName}-${deployId}-${env}-${version}`;
850
883
  const pvId = `${volume.claimName.replace('pvc-', 'pv-')}-${deployId}-${env}-${version}`;
851
884
  const rootVolumeHostPath = `/home/dd/engine/volume/${pvId}`;
885
+ if (options.gitClean && volume.volumeMountPath) {
886
+ Underpost.repo.clean({ paths: [volume.volumeMountPath] });
887
+ }
852
888
  if (options.nodeName) {
853
889
  if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
854
890
  fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
855
- } else {
891
+ } else if (clusterContext === 'kind') {
856
892
  shellExec(`docker exec -i kind-worker bash -c "mkdir -p ${rootVolumeHostPath}"`);
857
893
  // shellExec(`docker cp ${volume.volumeMountPath} kind-worker:${rootVolumeHostPath}`);
858
894
  shellExec(`tar -C ${volume.volumeMountPath} -c . | docker cp - kind-worker:${rootVolumeHostPath}`);
859
895
  shellExec(
860
896
  `docker exec -i kind-worker bash -c "chown -R 1000:1000 ${rootVolumeHostPath}; chmod -R 755 ${rootVolumeHostPath}"`,
861
897
  );
898
+ } else {
899
+ if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
900
+ fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
862
901
  }
863
902
  shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
864
903
  shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
@@ -1041,7 +1080,7 @@ ${renderHosts}`,
1041
1080
  async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default', outLogType = '') {
1042
1081
  let checkStatusIteration = 0;
1043
1082
  const checkStatusIterationMsDelay = 1000;
1044
- const maxIterations = 500;
1083
+ const maxIterations = 3000;
1045
1084
  const deploymentId = `${deployId}-${env}-${targetTraffic}`;
1046
1085
  const iteratorTag = `[${deploymentId}]`;
1047
1086
  logger.info('Deployment init', { deployId, env, targetTraffic, checkStatusIterationMsDelay, namespace });
package/src/cli/env.js CHANGED
@@ -48,6 +48,7 @@ class UnderpostRootEnv {
48
48
  return;
49
49
  }
50
50
  const exeRootPath = `${getNpmRootPath()}/underpost`;
51
+ fs.ensureDirSync(exeRootPath);
51
52
  const envPath = `${exeRootPath}/.env`;
52
53
  _set(envPath, key, value);
53
54
  },
package/src/cli/fs.js CHANGED
@@ -107,7 +107,8 @@ class UnderpostFileStorage {
107
107
  await Underpost.fs.pull(_path, options);
108
108
  } else logger.warn(`Pull path already exists`, _path);
109
109
  }
110
- shellExec(`cd ${path} && git init && git add . && git commit -m "Base pull state"`);
110
+ Underpost.repo.initLocalRepo({ path });
111
+ shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`);
111
112
  } else {
112
113
  const files =
113
114
  options.git === true ? Underpost.repo.getChangedFiles(path) : await fs.readdir(path, { recursive: true });
package/src/cli/index.js CHANGED
@@ -87,7 +87,7 @@ program
87
87
  .argument(`[commit-type]`, `The type of commit to perform. Options: ${Object.keys(commitData).join(', ')}.`)
88
88
  .argument(`[module-tag]`, 'Optional: Sets a specific module tag for the commit.')
89
89
  .argument(`[message]`, 'Optional: Provides an additional custom message for the commit.')
90
- .option(`--log <latest-n>`, 'Shows commit history from the specified number of latest n path commits.')
90
+ .option(`--log [latest-n]`, 'Shows commit history from the specified number of latest n path commits.')
91
91
  .option('--last-msg <latest-n>', 'Displays the last n commit message.')
92
92
  .option('--empty', 'Allows committing with empty files.')
93
93
  .option('--copy', 'Copies the generated commit message to the clipboard.')
@@ -108,7 +108,14 @@ program
108
108
  '--changelog-no-hash',
109
109
  'Excludes commit hashes from the generated changelog entries (used with --changelog-build).',
110
110
  )
111
+ .option('--unpush', 'With --log, automatically sets range to unpushed commits ahead of remote.')
111
112
  .option('-b', 'Shows the current Git branch name.')
113
+ .option('-p [branch]', 'Shows the reflog for the specified branch.')
114
+ .option('--bc <commit-hash>', 'Shows branches that contain the specified commit.')
115
+ .option(
116
+ '--is-remote-repo <url-repo>',
117
+ 'Checks whether a remote Git repository URL is reachable. Prints true or false.',
118
+ )
112
119
  .description('Manages commits to a GitHub repository, supporting various commit types and options.')
113
120
  .action(Underpost.repo.commit);
114
121
 
@@ -308,6 +315,9 @@ program
308
315
  'Retrieves current network traffic data from resource deployments and the host machine network configuration.',
309
316
  )
310
317
  .option('--kubeadm', 'Enables the kubeadm context for deployment operations.')
318
+ .option('--k3s', 'Enables the k3s context for deployment operations.')
319
+ .option('--kind', 'Enables the kind context for deployment operations.')
320
+ .option('--git-clean', 'Runs git clean on volume mount paths before copying.')
311
321
  .option('--etc-hosts', 'Enables the etc-hosts context for deployment operations.')
312
322
  .option('--restore-hosts', 'Restores default `/etc/hosts` entries.')
313
323
  .option('--disable-update-underpost-config', 'Disables updates to Underpost configuration during deployment.')
@@ -597,6 +607,7 @@ program
597
607
  .option('--kubeadm', 'Sets the kubeadm cluster context for the runner execution.')
598
608
  .option('--k3s', 'Sets the k3s cluster context for the runner execution.')
599
609
  .option('--kind', 'Sets the kind cluster context for the runner execution.')
610
+ .option('--git-clean', 'Runs git clean on volume mount paths before copying.')
600
611
  .option('--log-type <log-type>', 'Sets the log type for the runner execution.')
601
612
  .option('--deploy-id <deploy-id>', 'Sets deploy id context for the runner execution.')
602
613
  .option('--user <user>', 'Sets user context for the runner execution.')
@@ -640,6 +651,7 @@ program
640
651
  'Format: semicolon-separated entries of "ip=hostname1,hostname2" ' +
641
652
  '(e.g., "127.0.0.1=foo.local,bar.local;10.1.2.3=foo.remote,bar.remote").',
642
653
  )
654
+ .option('--copy', 'Copies the runner output to the clipboard (supported by: generate-pass, template-deploy-local).')
643
655
  .description('Runs specified scripts using various runners.')
644
656
  .action(Underpost.run.callback);
645
657
 
@@ -764,4 +776,33 @@ program
764
776
  )
765
777
  .action(Underpost.baremetal.callback);
766
778
 
779
+ program
780
+ .command('release')
781
+ .argument('[version]', 'The new version string to set (e.g., "3.1.4"). Defaults to current version.')
782
+ .option('--build', 'Builds a new version: tests template, bumps versions, rebuilds manifests and configs.')
783
+ .option('--deploy', 'Deploys the release: syncs secrets, commits, and pushes to remote repositories.')
784
+ .option(
785
+ '--ci-push <deploy-id>',
786
+ 'Local equivalent of engine-*.ci.yml: builds dd-{deploy-id} and pushes to the engine-{deploy-id} repository. ' +
787
+ 'Accepts the suffix (e.g., "cyberia"), "dd-cyberia", or "engine-cyberia".',
788
+ )
789
+ .option(
790
+ '--message <message>',
791
+ 'Commit message for --ci-push or --pwa-build (defaults to last commit of the engine repository).',
792
+ )
793
+ .option(
794
+ '--pwa-build',
795
+ 'Runs the pwa-microservices-template update flow: always re-clones, syncs engine sources, installs, builds, and pushes.',
796
+ )
797
+ .description('Release orchestrator for building new versions and deploying releases of the Underpost CLI.')
798
+ .action(async (version, options) => {
799
+ if (options.build) return Underpost.release.build(version, options);
800
+ if (options.deploy) return Underpost.release.deploy(version, options);
801
+ if (options.ciPush) return Underpost.release.ci(options.ciPush, options.message, options);
802
+ if (options.pwaBuild) return Underpost.release.pwa(options.message, options);
803
+ console.log(
804
+ 'Please specify --build, --deploy, --ci-push, or --pwa-build. Use "underpost release --help" for details.',
805
+ );
806
+ });
807
+
767
808
  export { program };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Kubectl module providing low-level Kubernetes resource management primitives.
3
+ * Centralises pod querying, file transfer, and in-container execution operations
4
+ * that were previously scattered across db, deploy, and cluster modules.
5
+ * @module src/cli/kubectl.js
6
+ * @namespace UnderpostKubectl
7
+ */
8
+
9
+ import { loggerFactory } from '../server/logger.js';
10
+ import { shellExec } from '../server/process.js';
11
+ import Underpost from '../index.js';
12
+
13
+ const logger = loggerFactory(import.meta);
14
+
15
+ /**
16
+ * Redacts credentials from shell command strings before logging.
17
+ * Masks passwords in `-p<password>`, `--password=<password>`, and `-P <password>` patterns.
18
+ * @param {string} cmd - The raw command string.
19
+ * @returns {string} The command with credentials replaced by `***`.
20
+ * @memberof UnderpostKubectl
21
+ */
22
+ const sanitizeCommand = (cmd) => {
23
+ if (typeof cmd !== 'string') return cmd;
24
+ return cmd
25
+ .replace(/-p['"]?[^\s'"]+/g, '-p***')
26
+ .replace(/--password=['"]?[^\s'"]+/g, '--password=***')
27
+ .replace(/-P\s+['"]?[^\s'"]+/g, '-P ***');
28
+ };
29
+
30
+ /**
31
+ * @class UnderpostKubectl
32
+ * @description Kubernetes cluster resource management primitives.
33
+ * Provides a unified interface for kubectl operations: resource listing, in-pod
34
+ * command execution, file transfer, and pod discovery/filtering.
35
+ * All methods are stateless and safe to call from any other CLI module.
36
+ * @memberof UnderpostKubectl
37
+ */
38
+ class UnderpostKubectl {
39
+ static API = {
40
+ /**
41
+ * Lists Kubernetes resources matching `deployId`, parsed into plain objects.
42
+ * Equivalent to `kubectl get <kindType> -o wide`, filtered by name substring.
43
+ * @param {string} deployId - Substring to match against resource names. Empty string returns all.
44
+ * @param {string} [kindType='pods'] - Resource kind: pods, deployments, svc, nodes, …
45
+ * @param {string} [namespace=''] - Namespace to query; empty string → --all-namespaces.
46
+ * @returns {Array<object>} Parsed rows keyed by column header (NAME, STATUS, NODE, …).
47
+ * @memberof UnderpostKubectl
48
+ */
49
+ get(deployId, kindType = 'pods', namespace = '') {
50
+ const raw = shellExec(
51
+ `sudo kubectl get ${kindType}${namespace ? ` -n ${namespace}` : ` --all-namespaces`} -o wide`,
52
+ { stdout: true, disableLog: true, silent: true },
53
+ );
54
+
55
+ const heads = raw
56
+ .split(`\n`)[0]
57
+ .split(' ')
58
+ .filter((_r) => _r.trim());
59
+
60
+ const pods = raw
61
+ .split(`\n`)
62
+ .filter((r) => (deployId ? r.match(deployId) : r.trim() && !r.match('NAME')))
63
+ .map((r) => r.split(' ').filter((_r) => _r.trim()));
64
+
65
+ const result = [];
66
+ for (const row of pods) {
67
+ const pod = {};
68
+ let index = -1;
69
+ for (const head of heads) {
70
+ index++;
71
+ pod[head] = row[index];
72
+ }
73
+ result.push(pod);
74
+ }
75
+ return result;
76
+ },
77
+
78
+ /**
79
+ * Executes a kubectl command with credential-safe logging and error propagation.
80
+ * @param {string} command - Full kubectl command string.
81
+ * @param {object} [options={}] - Execution options.
82
+ * @param {string} [options.context=''] - Human-readable label for log messages.
83
+ * @returns {string} stdout output from the command.
84
+ * @throws {Error} Re-throws any execution error after logging.
85
+ * @memberof UnderpostKubectl
86
+ */
87
+ run(command, options = {}) {
88
+ const { context = '' } = options;
89
+ try {
90
+ logger.info(`Executing kubectl command`, { command: sanitizeCommand(command), context });
91
+ return shellExec(command, { stdout: true, disableLog: true });
92
+ } catch (error) {
93
+ logger.error(`kubectl command failed`, { command: sanitizeCommand(command), error: error.message, context });
94
+ throw error;
95
+ }
96
+ },
97
+
98
+ /**
99
+ * Runs a shell command inside a pod container via `kubectl exec`.
100
+ * @param {object} params
101
+ * @param {string} params.podName - Target pod name.
102
+ * @param {string} params.namespace - Pod namespace.
103
+ * @param {string} params.command - Shell command to run inside the container.
104
+ * @returns {string} stdout output from the in-pod command.
105
+ * @throws {Error} Re-throws any execution error after logging.
106
+ * @memberof UnderpostKubectl
107
+ */
108
+ exec({ podName, namespace, command }) {
109
+ try {
110
+ const kubectlCmd = `sudo kubectl exec -n ${namespace} -i ${podName} -- sh -c "${command}"`;
111
+ return Underpost.kubectl.run(kubectlCmd, { context: `exec in pod ${podName}` });
112
+ } catch (error) {
113
+ logger.error('Failed to execute command in pod', {
114
+ podName,
115
+ command: sanitizeCommand(command),
116
+ error: error.message,
117
+ });
118
+ throw error;
119
+ }
120
+ },
121
+
122
+ /**
123
+ * Copies a local file into a pod via `kubectl cp`.
124
+ * @param {object} params
125
+ * @param {string} params.sourcePath - Local source path.
126
+ * @param {string} params.podName - Target pod name.
127
+ * @param {string} params.namespace - Pod namespace.
128
+ * @param {string} params.destPath - Destination path inside the container.
129
+ * @returns {boolean} `true` on success, `false` on error.
130
+ * @memberof UnderpostKubectl
131
+ */
132
+ cpTo({ sourcePath, podName, namespace, destPath }) {
133
+ try {
134
+ const command = `sudo kubectl cp ${sourcePath} ${namespace}/${podName}:${destPath}`;
135
+ Underpost.kubectl.run(command, { context: `copy to pod ${podName}` });
136
+ return true;
137
+ } catch (error) {
138
+ logger.error('Failed to copy file to pod', { sourcePath, podName, destPath, error: error.message });
139
+ return false;
140
+ }
141
+ },
142
+
143
+ /**
144
+ * Copies a file from a pod to the local filesystem via `kubectl cp`.
145
+ * @param {object} params
146
+ * @param {string} params.podName - Source pod name.
147
+ * @param {string} params.namespace - Pod namespace.
148
+ * @param {string} params.sourcePath - Source path inside the container.
149
+ * @param {string} params.destPath - Local destination path.
150
+ * @returns {boolean} `true` on success, `false` on error.
151
+ * @memberof UnderpostKubectl
152
+ */
153
+ cpFrom({ podName, namespace, sourcePath, destPath }) {
154
+ try {
155
+ const command = `sudo kubectl cp ${namespace}/${podName}:${sourcePath} ${destPath}`;
156
+ Underpost.kubectl.run(command, { context: `copy from pod ${podName}` });
157
+ return true;
158
+ } catch (error) {
159
+ logger.error('Failed to copy file from pod', { podName, sourcePath, destPath, error: error.message });
160
+ return false;
161
+ }
162
+ },
163
+
164
+ /**
165
+ * Checks whether a file exists inside a pod container.
166
+ * @param {object} params
167
+ * @param {string} params.podName - Pod name.
168
+ * @param {string} params.path - Absolute path inside the container to test.
169
+ * @returns {boolean} `true` if the file exists.
170
+ * @memberof UnderpostKubectl
171
+ */
172
+ existsFile({ podName, path }) {
173
+ const result = shellExec(`kubectl exec ${podName} -- test -f ${path} && echo "true" || echo "false"`, {
174
+ stdout: true,
175
+ disableLog: true,
176
+ silent: true,
177
+ }).trim();
178
+ return result === 'true';
179
+ },
180
+
181
+ /**
182
+ * Returns a filtered list of pods from the cluster.
183
+ * Supports wildcard glob patterns on pod names and optional deployId substring filtering.
184
+ * @param {object} [criteria={}] - Filter criteria.
185
+ * @param {string} [criteria.deployId] - Substring to match against pod names (forwards to `get`).
186
+ * @param {string} [criteria.podNames] - Comma-separated glob patterns (supports `*`).
187
+ * @param {string} [criteria.namespace='default'] - Kubernetes namespace to query.
188
+ * @returns {Array<object>} Filtered pod rows from `get`.
189
+ * @memberof UnderpostKubectl
190
+ */
191
+ getFilteredPods(criteria = {}) {
192
+ const { podNames, namespace = 'default', deployId } = criteria;
193
+ try {
194
+ let pods = Underpost.kubectl.get(deployId || '', 'pods', namespace);
195
+ if (podNames) {
196
+ const patterns = podNames.split(',').map((p) => p.trim());
197
+ pods = pods.filter((pod) =>
198
+ patterns.some((pattern) => new RegExp('^' + pattern.replace(/\*/g, '.*') + '$').test(pod.NAME)),
199
+ );
200
+ }
201
+ logger.info(`Found ${pods.length} pod(s) matching criteria`, { criteria, podNames: pods.map((p) => p.NAME) });
202
+ return pods;
203
+ } catch (error) {
204
+ logger.error('Error filtering pods', { error: error.message, criteria });
205
+ return [];
206
+ }
207
+ },
208
+ };
209
+ }
210
+
211
+ export default UnderpostKubectl;