underpost 3.2.9 → 3.2.11

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 (104) hide show
  1. package/.github/workflows/npmpkg.ci.yml +1 -0
  2. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  3. package/.github/workflows/release.cd.yml +1 -0
  4. package/.vscode/extensions.json +9 -9
  5. package/.vscode/settings.json +20 -4
  6. package/CHANGELOG.md +195 -1
  7. package/CLI-HELP.md +92 -23
  8. package/README.md +38 -9
  9. package/bin/build.js +27 -7
  10. package/bin/build.template.js +187 -0
  11. package/bin/deploy.js +12 -2
  12. package/bin/index.js +2 -1
  13. package/bump.config.js +26 -0
  14. package/conf.js +20 -7
  15. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/dd-test-development/deployment.yaml +4 -2
  19. package/manifests/kind-config-dev.yaml +8 -0
  20. package/manifests/lxd/lxd-admin-profile.yaml +12 -3
  21. package/manifests/mongodb/pv-pvc.yaml +44 -8
  22. package/manifests/mongodb/statefulset.yaml +55 -68
  23. package/manifests/mongodb-4.4/headless-service.yaml +10 -0
  24. package/manifests/mongodb-4.4/kustomization.yaml +3 -1
  25. package/manifests/mongodb-4.4/mongodb-nodeport.yaml +17 -0
  26. package/manifests/mongodb-4.4/pv-pvc.yaml +10 -14
  27. package/manifests/mongodb-4.4/statefulset.yaml +79 -0
  28. package/manifests/mongodb-4.4/storage-class.yaml +9 -0
  29. package/manifests/valkey/statefulset.yaml +1 -1
  30. package/manifests/valkey/valkey-nodeport.yaml +17 -0
  31. package/package.json +27 -12
  32. package/scripts/ipxe-setup.sh +52 -49
  33. package/scripts/k3s-node-setup.sh +81 -46
  34. package/scripts/lxd-vm-setup.sh +193 -8
  35. package/scripts/maas-nat-firewalld.sh +145 -0
  36. package/src/api/core/core.router.js +19 -14
  37. package/src/api/core/core.service.js +5 -5
  38. package/src/api/default/default.router.js +22 -18
  39. package/src/api/default/default.service.js +5 -5
  40. package/src/api/document/document.router.js +28 -23
  41. package/src/api/document/document.service.js +100 -23
  42. package/src/api/file/file.router.js +19 -13
  43. package/src/api/file/file.service.js +9 -7
  44. package/src/api/test/test.router.js +17 -12
  45. package/src/api/types.js +24 -0
  46. package/src/api/user/guest.service.js +5 -4
  47. package/src/api/user/user.router.js +297 -288
  48. package/src/api/user/user.service.js +100 -35
  49. package/src/cli/baremetal.js +132 -101
  50. package/src/cli/cluster.js +700 -232
  51. package/src/cli/db.js +59 -60
  52. package/src/cli/deploy.js +216 -137
  53. package/src/cli/fs.js +13 -3
  54. package/src/cli/index.js +80 -15
  55. package/src/cli/ipfs.js +4 -6
  56. package/src/cli/kubectl.js +4 -1
  57. package/src/cli/lxd.js +1099 -223
  58. package/src/cli/monitor.js +9 -3
  59. package/src/cli/release.js +334 -140
  60. package/src/cli/repository.js +68 -23
  61. package/src/cli/run.js +191 -47
  62. package/src/cli/secrets.js +11 -2
  63. package/src/cli/test.js +9 -3
  64. package/src/client/Default.index.js +9 -3
  65. package/src/client/components/core/Auth.js +5 -0
  66. package/src/client/components/core/ClientEvents.js +76 -0
  67. package/src/client/components/core/EventBus.js +4 -0
  68. package/src/client/components/core/Modal.js +82 -41
  69. package/src/client/components/core/PanelForm.js +56 -52
  70. package/src/client/components/core/Worker.js +162 -363
  71. package/src/client/sw/core.sw.js +174 -112
  72. package/src/db/DataBaseProvider.js +115 -15
  73. package/src/db/mariadb/MariaDB.js +2 -1
  74. package/src/db/mongo/MongoBootstrap.js +657 -0
  75. package/src/db/mongo/MongooseDB.js +129 -21
  76. package/src/index.js +1 -1
  77. package/src/runtime/express/Express.js +2 -2
  78. package/src/runtime/wp/Wp.js +8 -5
  79. package/src/server/auth.js +2 -2
  80. package/src/server/client-build-docs.js +1 -1
  81. package/src/server/client-build.js +94 -129
  82. package/src/server/conf.js +81 -79
  83. package/src/server/process.js +180 -19
  84. package/src/server/proxy.js +9 -2
  85. package/src/server/runtime.js +1 -1
  86. package/src/server/start.js +16 -4
  87. package/src/server/valkey.js +2 -0
  88. package/src/ws/IoInterface.js +16 -16
  89. package/src/ws/core/channels/core.ws.chat.js +11 -11
  90. package/src/ws/core/channels/core.ws.mailer.js +29 -29
  91. package/src/ws/core/channels/core.ws.stream.js +19 -19
  92. package/src/ws/core/core.ws.connection.js +8 -8
  93. package/src/ws/core/core.ws.server.js +6 -5
  94. package/src/ws/default/channels/default.ws.main.js +10 -10
  95. package/src/ws/default/default.ws.connection.js +4 -4
  96. package/src/ws/default/default.ws.server.js +4 -3
  97. package/bin/file.js +0 -202
  98. package/bin/vs.js +0 -74
  99. package/bin/zed.js +0 -84
  100. package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
  101. package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
  102. /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
  103. /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
  104. /package/src/client/ssr/{pages → views}/Test.js +0 -0
@@ -924,8 +924,6 @@ class UnderpostRepository {
924
924
  shellExec(`cd ${privateRepoPath} && underpost pull . ${process.env.GITHUB_USERNAME}/${privateRepoName}`, {
925
925
  silent: true,
926
926
  });
927
- shellExec(`underpost run secret`);
928
- shellExec(`underpost run underpost-config`);
929
927
  const packageJsonDeploy = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/package.json`, 'utf8'));
930
928
  const packageJsonEngine = JSON.parse(fs.readFileSync(`./package.json`, 'utf8'));
931
929
  if (packageJsonDeploy.version !== packageJsonEngine.version) {
@@ -1008,11 +1006,14 @@ Prevent build private config repo.`,
1008
1006
  const host = 'default.net';
1009
1007
  const path = '/';
1010
1008
  DefaultConf.server[host][path].valkey = {
1011
- port: 6379,
1012
- host: 'valkey-service.default.svc.cluster.local',
1009
+ port: 'env:VALKEY_PORT:int:6379',
1010
+ host: 'env:VALKEY_HOST:127.0.0.1',
1013
1011
  };
1014
- // mongodb-0.mongodb-service
1015
- DefaultConf.server[host][path].db.host = 'mongodb://mongodb-service:27017';
1012
+ DefaultConf.server[host][path].db.host = 'env:DB_HOST:mongodb://127.0.0.1:27017';
1013
+ DefaultConf.server[host][path].db.replicaSet = 'env:DB_REPLICA_SET:rs0';
1014
+ DefaultConf.server[host][path].db.authSource = 'env:DB_AUTH_SOURCE:admin';
1015
+ DefaultConf.server[host][path].db.user = 'env:DB_USER:';
1016
+ DefaultConf.server[host][path].db.password = 'env:DB_PASSWORD:';
1016
1017
  defaultConf = true;
1017
1018
  break;
1018
1019
  }
@@ -1048,9 +1049,9 @@ Prevent build private config repo.`,
1048
1049
  */
1049
1050
  clean(options = { paths: [''] }) {
1050
1051
  for (const path of options.paths) {
1051
- shellExec(`cd ${path} && git reset`, { silent: true });
1052
- shellExec(`cd ${path} && git checkout .`, { silent: true });
1053
- shellExec(`cd ${path} && git clean -f -d`, { silent: true });
1052
+ shellExec(`cd ${path} && git reset`, { silentOnError: true, silent: true, disableLog: true });
1053
+ shellExec(`cd ${path} && git checkout .`, { silentOnError: true, silent: true, disableLog: true });
1054
+ shellExec(`cd ${path} && git clean -f -d`, { silentOnError: true, silent: true, disableLog: true });
1054
1055
  }
1055
1056
  },
1056
1057
 
@@ -1104,7 +1105,7 @@ Prevent build private config repo.`,
1104
1105
 
1105
1106
  try {
1106
1107
  // Fetch directory contents recursively
1107
- const copiedFiles = await this._fetchAndCopyGitHubDirectory({
1108
+ const copiedFiles = await this.fetchAndCopyGitHubDirectory({
1108
1109
  apiUrl,
1109
1110
  targetPath,
1110
1111
  basePath: directoryPath,
@@ -1140,7 +1141,7 @@ Prevent build private config repo.`,
1140
1141
  * @returns {Promise<array>} Array of copied file paths.
1141
1142
  * @memberof UnderpostRepository
1142
1143
  */
1143
- async _fetchAndCopyGitHubDirectory(options) {
1144
+ async fetchAndCopyGitHubDirectory(options) {
1144
1145
  const { apiUrl, targetPath, basePath, branch } = options;
1145
1146
  const copiedFiles = [];
1146
1147
 
@@ -1175,14 +1176,12 @@ Prevent build private config repo.`,
1175
1176
 
1176
1177
  logger.info(`Found ${contents.length} items in directory: ${basePath}`);
1177
1178
 
1178
- // Process each item in the directory
1179
1179
  for (const item of contents) {
1180
1180
  const itemTargetPath = `${targetPath}/${item.name}`;
1181
1181
 
1182
1182
  if (item.type === 'file') {
1183
1183
  logger.info(`Downloading file: ${item.path}`);
1184
1184
 
1185
- // Download file content
1186
1185
  const fileResponse = await fetch(item.download_url);
1187
1186
  if (!fileResponse.ok) {
1188
1187
  logger.error(`Failed to download: ${item.download_url}`);
@@ -1192,16 +1191,14 @@ Prevent build private config repo.`,
1192
1191
  const fileContent = await fileResponse.text();
1193
1192
  fs.writeFileSync(itemTargetPath, fileContent);
1194
1193
 
1195
- logger.info(`✓ Saved: ${itemTargetPath}`);
1194
+ logger.info(`Saved: ${itemTargetPath}`);
1196
1195
  copiedFiles.push(itemTargetPath);
1197
1196
  } else if (item.type === 'dir') {
1198
- logger.info(`📁 Processing directory: ${item.path}`);
1197
+ logger.info(`Processing directory: ${item.path}`);
1199
1198
 
1200
- // Create subdirectory
1201
1199
  fs.mkdirSync(itemTargetPath, { recursive: true });
1202
1200
 
1203
- // Recursively process subdirectory
1204
- const subFiles = await this._fetchAndCopyGitHubDirectory({
1201
+ const subFiles = await this.fetchAndCopyGitHubDirectory({
1205
1202
  apiUrl: item.url,
1206
1203
  targetPath: itemTargetPath,
1207
1204
  basePath: item.path,
@@ -1209,7 +1206,7 @@ Prevent build private config repo.`,
1209
1206
  });
1210
1207
 
1211
1208
  copiedFiles.push(...subFiles);
1212
- logger.info(`✓ Completed directory: ${item.path} (${subFiles.length} files)`);
1209
+ logger.info(`Completed directory: ${item.path} (${subFiles.length} files)`);
1213
1210
  } else {
1214
1211
  logger.warn(`Skipping unknown item type '${item.type}': ${item.path}`);
1215
1212
  }
@@ -1295,7 +1292,7 @@ Prevent build private config repo.`,
1295
1292
  },
1296
1293
  /**
1297
1294
  * Checks whether a remote Git repository URL is reachable.
1298
- * Uses `git ls-remote` with `|| true` so the process always exits 0.
1295
+ * Uses `silentOnError` so a non-reachable remote returns false instead of throwing.
1299
1296
  * Injects `GITHUB_TOKEN` into GitHub HTTPS URLs when available.
1300
1297
  * @param {string} url - Full HTTPS clone URL to test (e.g. "https://github.com/org/repo.git").
1301
1298
  * @returns {boolean} `true` when the remote responded with at least one ref hash.
@@ -1305,10 +1302,11 @@ Prevent build private config repo.`,
1305
1302
  if (!url) return false;
1306
1303
  const authUrl = Underpost.repo.resolveAuthUrl(url);
1307
1304
  // GIT_TERMINAL_PROMPT=0 prevents git from hanging on credential prompts inside containers.
1308
- const raw = shellExec(`GIT_TERMINAL_PROMPT=0 git ls-remote "${authUrl}" HEAD 2>&1 || true`, {
1305
+ const raw = shellExec(`GIT_TERMINAL_PROMPT=0 git ls-remote "${authUrl}" HEAD 2>&1`, {
1309
1306
  stdout: true,
1310
1307
  silent: true,
1311
1308
  disableLog: true,
1309
+ silentOnError: true,
1312
1310
  });
1313
1311
  logger.info('isRemoteRepo', { url, raw: (raw || '').slice(0, 120) });
1314
1312
  return typeof raw === 'string' && /^[0-9a-f]{40}\t/m.test(raw);
@@ -1380,11 +1378,12 @@ Prevent build private config repo.`,
1380
1378
  }
1381
1379
  shellExec(`cd "${repoPath}" && git config user.name '${gitUsername}'`);
1382
1380
  shellExec(`cd "${repoPath}" && git config user.email '${gitEmail}'`);
1383
-
1381
+ shellExec(`cd "${repoPath}" && git config core.filemode false`);
1384
1382
  if (origin) {
1385
- const currentRemote = shellExec(`cd "${repoPath}" && git remote get-url origin 2>/dev/null || true`, {
1383
+ const currentRemote = shellExec(`cd "${repoPath}" && git remote get-url origin`, {
1386
1384
  stdout: true,
1387
1385
  silent: true,
1386
+ silentOnError: true,
1388
1387
  }).trim();
1389
1388
  if (!currentRemote) {
1390
1389
  shellExec(`cd "${repoPath}" && git remote add origin "${origin}"`);
@@ -1608,6 +1607,52 @@ Prevent build private config repo.`,
1608
1607
  logger.info('engine-private in /home/dd removed');
1609
1608
  }
1610
1609
  },
1610
+
1611
+ /**
1612
+ * Resolves the GitHub repository for a given instance runtime by scanning
1613
+ * every `conf.instances.json` listed in `./engine-private/deploy/dd.router`.
1614
+ *
1615
+ * Resolution order:
1616
+ * 1. If `runtime` is falsy, returns `${GITHUB_USERNAME}/engine`.
1617
+ * 2. Iterates each deploy ID found in `dd.router` and looks for an instance
1618
+ * whose `runtime` field matches the supplied value.
1619
+ * 3. When a match is found, returns `instance.metadata.repository`.
1620
+ * 4. Falls back to `${GITHUB_USERNAME}/engine` when no match is found.
1621
+ *
1622
+ * @param {string} [runtime=''] - The runtime identifier to look up (e.g. `'cyberia-server'`, `'cyberia-client'`).
1623
+ * @returns {string} The resolved `owner/repo` string.
1624
+ * @memberof UnderpostRepository
1625
+ */
1626
+ resolveInstanceRepo(runtime = '') {
1627
+ const fallback = `${process.env.GITHUB_USERNAME}/engine`;
1628
+ if (!runtime) return fallback;
1629
+ const ddRouter = './engine-private/deploy/dd.router';
1630
+ const deployIds = fs.existsSync(ddRouter)
1631
+ ? fs
1632
+ .readFileSync(ddRouter, 'utf8')
1633
+ .split(',')
1634
+ .map((s) => s.trim())
1635
+ .filter(Boolean)
1636
+ : [];
1637
+ for (const deployId of deployIds) {
1638
+ const confPath = `./engine-private/conf/${deployId}/conf.instances.json`;
1639
+ if (!fs.existsSync(confPath)) continue;
1640
+ try {
1641
+ const instances = JSON.parse(fs.readFileSync(confPath, 'utf8'));
1642
+ const match = instances.find((i) => i && i.runtime === runtime);
1643
+ if (match && match.metadata && match.metadata.repository) {
1644
+ logger.info(`[resolveInstanceRepo] resolved from ${confPath}`, {
1645
+ runtime,
1646
+ repo: match.metadata.repository,
1647
+ });
1648
+ return match.metadata.repository;
1649
+ }
1650
+ } catch (err) {
1651
+ logger.warn(`[resolveInstanceRepo] failed to parse ${confPath}: ${err.message}`);
1652
+ }
1653
+ }
1654
+ return fallback;
1655
+ },
1611
1656
  };
1612
1657
  }
1613
1658
 
package/src/cli/run.js CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  awaitDeployMonitor,
11
11
  buildKindPorts,
12
12
  Config,
13
+ cronDeployIdResolve,
14
+ etcHostFactory,
13
15
  getNpmRootPath,
14
16
  isDeployRunnerContext,
15
17
  loadConfServerJson,
@@ -24,6 +26,7 @@ import { range, setPad, timer } from '../client/components/core/CommonJs.js';
24
26
  import os from 'os';
25
27
  import Underpost from '../index.js';
26
28
  import dotenv from 'dotenv';
29
+ import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
27
30
 
28
31
  const waitForPort = (port, host = '127.0.0.1', { maxAttempts = 30, interval = 2000 } = {}) =>
29
32
  new Promise((resolve, reject) => {
@@ -97,6 +100,7 @@ const logger = loggerFactory(import.meta);
97
100
  * @property {string} deployId - The deployment ID.
98
101
  * @property {string} instanceId - The instance ID.
99
102
  * @property {string} user - The user to run as.
103
+ * @property {string} group - The group to use.
100
104
  * @property {string} pid - The process ID.
101
105
  * @property {boolean} disablePrivateConfUpdate - Whether to disable private configuration updates.
102
106
  * @property {string} monitorStatus - The monitor status option.
@@ -114,6 +118,7 @@ const logger = loggerFactory(import.meta);
114
118
  * @property {boolean} copy - Whether to copy the command to the clipboard instead of executing it.
115
119
  * @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
116
120
  * @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
121
+ * @property {boolean} remove - Whether to remove/teardown resources instead of creating them (e.g. delete-expose for k3s proxy devices in dev-cluster).
117
122
  * @memberof UnderpostRun
118
123
  */
119
124
  const DEFAULT_OPTION = {
@@ -165,6 +170,7 @@ const DEFAULT_OPTION = {
165
170
  deployId: '',
166
171
  instanceId: '',
167
172
  user: '',
173
+ group: '',
168
174
  pid: '',
169
175
  disablePrivateConfUpdate: false,
170
176
  monitorStatus: '',
@@ -180,6 +186,7 @@ const DEFAULT_OPTION = {
180
186
  copy: false,
181
187
  skipFullBuild: false,
182
188
  pullBundle: false,
189
+ remove: false,
183
190
  };
184
191
 
185
192
  /**
@@ -209,28 +216,39 @@ class UnderpostRun {
209
216
  'dev-cluster': (path, options = DEFAULT_OPTION) => {
210
217
  const baseCommand = options.dev ? 'node bin' : 'underpost';
211
218
  const mongoHosts = ['mongodb-0.mongodb-service'];
219
+ let primaryMongoHost = 'mongodb-0.mongodb-service';
212
220
  if (!options.expose) {
213
221
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''} --reset`);
214
222
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''}`);
215
223
 
216
224
  shellExec(
217
- `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb --mongo-db-host ${mongoHosts.join(
225
+ `${baseCommand} cluster${options.dev ? ' --dev' : ''} --mongodb4 --service-host ${mongoHosts.join(
218
226
  ',',
219
227
  )} --pull-image`,
220
228
  );
221
229
  shellExec(`${baseCommand} cluster${options.dev ? ' --dev' : ''} --valkey --pull-image`);
222
230
  }
223
-
224
- {
225
- // Detect MongoDB primary pod using method
226
- let primaryMongoHost = 'mongodb-0.mongodb-service';
231
+ if (options.k3s) {
232
+ if (options.remove) {
233
+ shellExec(`${baseCommand} lxd --delete-expose k3s-control:27017`);
234
+ shellExec(`${baseCommand} lxd --delete-expose k3s-control:6379`);
235
+ } else {
236
+ shellExec(`${baseCommand} lxd --expose k3s-control:27017 --node-port 32017`);
237
+ shellExec(`${baseCommand} lxd --expose k3s-control:6379 --node-port 32079`);
238
+ }
239
+ shellExec(`lxc config device show k3s-control`);
240
+ } else {
227
241
  try {
228
- const primaryPodName = Underpost.db.getMongoPrimaryPodName({
229
- namespace: options.namespace,
230
- podName: 'mongodb-0',
231
- });
232
- // shellExec(`${baseCommand} deploy --expose --disable-update-underpost-config mongo`, { async: true });
233
- shellExec(`kubectl port-forward -n ${options.namespace} pod/${primaryPodName} 27017:27017`, { async: true });
242
+ const primaryPodName =
243
+ MongoBootstrap.getPrimaryPodName({
244
+ namespace: options.namespace,
245
+ podName: 'mongodb-0',
246
+ disableAuth: options.dev,
247
+ }) || 'mongodb-0';
248
+ shellExec(
249
+ `${baseCommand} deploy --expose --namespace ${options.namespace} --disable-update-underpost-config mongo`,
250
+ { async: true },
251
+ );
234
252
  shellExec(
235
253
  `${baseCommand} deploy --expose --namespace ${options.namespace} --disable-update-underpost-config valkey`,
236
254
  { async: true },
@@ -241,10 +259,9 @@ class UnderpostRun {
241
259
  default: primaryMongoHost,
242
260
  });
243
261
  }
244
-
245
- const hostListenResult = Underpost.deploy.etcHostFactory([primaryMongoHost]);
246
- logger.info(hostListenResult.renderHosts);
247
262
  }
263
+ const hostListenResult = etcHostFactory([primaryMongoHost]);
264
+ logger.info(hostListenResult.renderHosts);
248
265
  },
249
266
 
250
267
  /**
@@ -505,14 +522,16 @@ class UnderpostRun {
505
522
  },
506
523
  /**
507
524
  * @method docker-image
508
- * @description Dispatches the Docker image CI workflow (`docker-image.ci.yml`) for the `engine` repository via `workflow_dispatch`.
509
- * @param {string} path - The input value, identifier, or path for the operation.
525
+ * @description Dispatches the Docker image CI workflow (`docker-image[.<runtime>].ci.yml`) via `workflow_dispatch`.
526
+ * Repository resolution is delegated to `Underpost.repo.resolveInstanceRepo(path)`.
527
+ * @param {string} path - Optional runtime / workflow suffix (e.g. `cyberia-server`, `cyberia-client`).
510
528
  * @param {Object} options - The default underpost runner options for customizing workflow
511
529
  * @memberof UnderpostRun
512
530
  */
513
531
  'docker-image': (path, options = DEFAULT_OPTION) => {
532
+ const repo = Underpost.repo.resolveInstanceRepo(path);
514
533
  Underpost.repo.dispatchWorkflow({
515
- repo: `${process.env.GITHUB_USERNAME}/engine`,
534
+ repo,
516
535
  workflowFile: `docker-image${path ? `.${path}` : ''}.ci.yml`,
517
536
  ref: 'master',
518
537
  inputs: {},
@@ -527,6 +546,7 @@ class UnderpostRun {
527
546
  */
528
547
  clean: (path = '', options = DEFAULT_OPTION) => {
529
548
  Underpost.repo.clean({ paths: path ? path.split(',') : ['/home/dd/engine', '/home/dd/engine/engine-private'] });
549
+ if (options.dev) shellExec(`node bin run shared-dir ${path ? path : '/home/dd/engine'}`);
530
550
  },
531
551
  /**
532
552
  * @method pull
@@ -536,16 +556,14 @@ class UnderpostRun {
536
556
  * @memberof UnderpostRun
537
557
  */
538
558
  pull: (path, options = DEFAULT_OPTION) => {
559
+ // shellExec is fail-fast by default — any non-zero exit throws and
560
+ // propagates up to the workflow step. No per-call flag required.
539
561
  if (!fs.existsSync(`/home/dd`) || !fs.existsSync(`/home/dd/engine`)) {
540
562
  fs.mkdirSync(`/home/dd`, { recursive: true });
541
- shellExec(`cd /home/dd && underpost clone ${process.env.GITHUB_USERNAME}/engine`, {
542
- silent: true,
543
- });
563
+ shellExec(`cd /home/dd && underpost clone ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
544
564
  } else {
545
565
  shellExec(`underpost run clean`);
546
- shellExec(`cd /home/dd/engine && underpost pull . ${process.env.GITHUB_USERNAME}/engine`, {
547
- silent: true,
548
- });
566
+ shellExec(`cd /home/dd/engine && underpost pull . ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
549
567
  }
550
568
  if (!fs.existsSync(`/home/dd/engine/engine-private`))
551
569
  shellExec(`cd /home/dd/engine && underpost clone ${process.env.GITHUB_USERNAME}/engine-private`, {
@@ -554,9 +572,7 @@ class UnderpostRun {
554
572
  else
555
573
  shellExec(
556
574
  `cd /home/dd/engine/engine-private && underpost pull . ${process.env.GITHUB_USERNAME}/engine-private`,
557
- {
558
- silent: true,
559
- },
575
+ { silent: true },
560
576
  );
561
577
  },
562
578
  /**
@@ -643,6 +659,11 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
643
659
  /**
644
660
  * @method sync
645
661
  * @description Cleans up, and then runs a deployment synchronization command (`underpost deploy --kubeadm --build-manifest --sync...`) using parameters parsed from `path` (deployId, replicas, versions, image, node).
662
+ *
663
+ * Forwards `--image-pull-policy <policy>` to the underlying `deploy --build-manifest` invocation when `options.imagePullPolicy` is set,
664
+ * which then plumbs through `buildManifest` and `deploymentYamlPartsFactory` to override the container `imagePullPolicy` in the generated
665
+ * `deployment.yaml`. Useful when you want to force `Always` so the kubelet re-pulls a mutable tag on every rollout. Example:
666
+ * `node bin run sync dd-core --kubeadm --image-pull-policy Always`
646
667
  * @param {string} path - The input value, identifier, or path for the operation (used as a comma-separated string containing deploy parameters).
647
668
  * @param {Object} options - The default underpost runner options for customizing workflow
648
669
  * @memberof UnderpostRun
@@ -700,13 +721,14 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
700
721
 
701
722
  const skipFullBuildFlag = options.skipFullBuild ? ' --skip-full-build' : '';
702
723
  const pullBundleFlag = options.pullBundle ? ' --pull-bundle' : '';
724
+ const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
703
725
 
704
726
  shellExec(
705
727
  `${baseCommand} deploy${clusterFlag} --build-manifest --sync --info-router --replicas ${replicas} --node ${node}${
706
728
  image ? ` --image ${image}` : ''
707
729
  }${versions ? ` --versions ${versions}` : ''}${
708
730
  options.namespace ? ` --namespace ${options.namespace}` : ''
709
- }${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag} ${deployId} ${env}`,
731
+ }${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag} ${deployId} ${env}`,
710
732
  );
711
733
 
712
734
  if (isDeployRunnerContext(path, options)) {
@@ -717,7 +739,7 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
717
739
  shellExec(
718
740
  `${baseCommand} deploy${clusterFlag}${cmdString} --replicas ${replicas} --disable-update-proxy ${deployId} ${env} --versions ${versions}${
719
741
  options.namespace ? ` --namespace ${options.namespace}` : ''
720
- }${timeoutFlags}${gitCleanFlag}`,
742
+ }${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}`,
721
743
  );
722
744
  if (!targetTraffic)
723
745
  targetTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
@@ -1023,6 +1045,9 @@ EOF
1023
1045
  cmd: _cmd,
1024
1046
  volumes: _volumes,
1025
1047
  metadata: _metadata,
1048
+ lifecycle: _lifecycle,
1049
+ readinessProbe: _readinessProbe,
1050
+ livenessProbe: _livenessProbe,
1026
1051
  } = instance;
1027
1052
  if (id !== _id) continue;
1028
1053
  const _deployId = `${deployId}-${_id}`;
@@ -1087,6 +1112,20 @@ EOF
1087
1112
  ),
1088
1113
  );
1089
1114
 
1115
+ // Resolve env-scoped lifecycle/probe blocks: each can be either
1116
+ // { ...envObj } // shared shape
1117
+ // { development: {...}, production: {...} } // env-specific
1118
+ const pickEnv = (v) => (v && (v.development || v.production) ? v[env] : v);
1119
+
1120
+ // Convention: an instance config may place `imagePullPolicy` inside
1121
+ // the env-scoped lifecycle block (alongside postStart/preStop).
1122
+ // Extract it onto the container spec (where K8S expects it) and
1123
+ // strip it from the lifecycle hash so the rendered YAML stays valid.
1124
+ // CLI override (`--image-pull-policy`) wins over the conf value.
1125
+ const { lifecycle: lifecycleForManifest, imagePullPolicy: lifecycleImagePullPolicy } =
1126
+ Underpost.deploy.extractInstanceImagePullPolicy(pickEnv(_lifecycle));
1127
+ const instanceImagePullPolicy = options.imagePullPolicy || lifecycleImagePullPolicy;
1128
+
1090
1129
  let deploymentYaml = `---
1091
1130
  ${Underpost.deploy
1092
1131
  .deploymentYamlPartsFactory({
@@ -1099,6 +1138,11 @@ ${Underpost.deploy
1099
1138
  namespace: options.namespace,
1100
1139
  volumes: _volumes,
1101
1140
  cmd: resolvedCmd,
1141
+ lifecycle: lifecycleForManifest,
1142
+ readinessProbe: pickEnv(_readinessProbe),
1143
+ livenessProbe: pickEnv(_livenessProbe),
1144
+ containerPort: _toPort,
1145
+ imagePullPolicy: instanceImagePullPolicy,
1102
1146
  })
1103
1147
  .replace('{{ports}}', buildKindPorts(_fromPort, _toPort))}
1104
1148
  `;
@@ -1130,7 +1174,7 @@ EOF
1130
1174
  );
1131
1175
  }
1132
1176
  if (options.etcHosts) {
1133
- const hostListenResult = Underpost.deploy.etcHostFactory(etcHosts);
1177
+ const hostListenResult = etcHostFactory(etcHosts);
1134
1178
  logger.info(hostListenResult.renderHosts);
1135
1179
  }
1136
1180
  },
@@ -1187,14 +1231,34 @@ EOF
1187
1231
  volumes: _volumes,
1188
1232
  metadata: _metadata,
1189
1233
  runtime: _runtime,
1234
+ lifecycle: _lifecycle,
1235
+ readinessProbe: _readinessProbe,
1236
+ livenessProbe: _livenessProbe,
1190
1237
  } = instance;
1191
1238
 
1192
- // Resolve Dockerfile source: use runtime-specific path when instance defines a runtime.
1193
- const dockerfileSourcePath = _runtime ? `src/runtime/${_runtime}/Dockerfile` : `${rootPath}/Dockerfile`;
1194
- if (fs.existsSync(dockerfileSourcePath)) {
1239
+ // Resolve Dockerfile source. Dev/prod variant rules:
1240
+ // - When the instance defines a `runtime`, look under
1241
+ // `src/runtime/<runtime>/`. In `--dev` mode prefer `Dockerfile.dev`
1242
+ // when it exists, falling back to `Dockerfile`.
1243
+ // - When `runtime` is not set, look in the project root with the
1244
+ // same `.dev` → no-suffix precedence.
1245
+ // Dockerfile.dev is a full Dockerfile (not an overlay) — each runtime
1246
+ // owns the contract between its dev image and its prod image (debug
1247
+ // build flags, extra tooling, default ports, etc.).
1248
+ const dockerfileBase = _runtime ? `src/runtime/${_runtime}` : rootPath;
1249
+ const dockerfileCandidates = options.dev
1250
+ ? [`${dockerfileBase}/Dockerfile.dev`, `${dockerfileBase}/Dockerfile`]
1251
+ : [`${dockerfileBase}/Dockerfile`];
1252
+ const dockerfileSourcePath = dockerfileCandidates.find((p) => fs.existsSync(p));
1253
+ if (dockerfileSourcePath) {
1254
+ if (options.dev && !dockerfileSourcePath.endsWith('.dev')) {
1255
+ logger.warn(
1256
+ `[instance-build-manifest] --dev requested but no Dockerfile.dev present; falling back to ${dockerfileSourcePath}`,
1257
+ );
1258
+ }
1195
1259
  fs.copyFileSync(dockerfileSourcePath, dockerfileManifestPath);
1196
1260
  } else {
1197
- logger.warn(`[instance-build-manifest] Dockerfile not found at ${dockerfileSourcePath}`);
1261
+ logger.warn(`[instance-build-manifest] Dockerfile not found; tried: ${dockerfileCandidates.join(', ')}`);
1198
1262
  }
1199
1263
 
1200
1264
  const _deployId = `${deployId}-${_id}`;
@@ -1239,6 +1303,17 @@ EOF
1239
1303
  ),
1240
1304
  );
1241
1305
 
1306
+ // Env-aware lifecycle / probe selection. Each block may either be
1307
+ // a single object (shared across envs) or `{ development, production }`.
1308
+ const pickEnv = (v) => (v && (v.development || v.production) ? v[env] : v);
1309
+
1310
+ // Convention: an instance config may place `imagePullPolicy` inside
1311
+ // the env-scoped lifecycle block (alongside postStart/preStop).
1312
+ // Extract it onto the container spec and strip it from the lifecycle hash.
1313
+ const { lifecycle: lifecycleForManifest, imagePullPolicy: lifecycleImagePullPolicy } =
1314
+ Underpost.deploy.extractInstanceImagePullPolicy(pickEnv(_lifecycle));
1315
+ const instanceImagePullPolicy = options.imagePullPolicy || lifecycleImagePullPolicy;
1316
+
1242
1317
  const deploymentYaml =
1243
1318
  `---\n` +
1244
1319
  Underpost.deploy
@@ -1252,6 +1327,11 @@ EOF
1252
1327
  namespace: options.namespace,
1253
1328
  volumes: _volumes,
1254
1329
  cmd: resolvedCmd,
1330
+ lifecycle: lifecycleForManifest,
1331
+ readinessProbe: pickEnv(_readinessProbe),
1332
+ livenessProbe: pickEnv(_livenessProbe),
1333
+ containerPort: _toPort,
1334
+ imagePullPolicy: instanceImagePullPolicy,
1255
1335
  })
1256
1336
  .replace('{{ports}}', buildKindPorts(_fromPort, _toPort));
1257
1337
 
@@ -1330,9 +1410,9 @@ EOF`);
1330
1410
  // crictl is in the kubernetes repo but excluded by default — install it explicitly
1331
1411
  shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes`);
1332
1412
  // Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
1333
- shellExec(
1334
- `sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf 2>/dev/null || true`,
1335
- );
1413
+ shellExec(`sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf`, {
1414
+ silentOnError: true,
1415
+ });
1336
1416
  shellExec(`sudo systemctl enable --now crio`);
1337
1417
  logger.info('CRI-O installed and started.');
1338
1418
  // Write crictl config so all crictl calls default to the CRI-O socket
@@ -1466,7 +1546,8 @@ EOF`);
1466
1546
  `git config user.name '${username}' && ` +
1467
1547
  `git config user.email '${email}' && ` +
1468
1548
  `git config credential.interactive always &&` +
1469
- `git config pull.rebase false`,
1549
+ `git config pull.rebase false && ` +
1550
+ `git config core.filemode false`,
1470
1551
  {
1471
1552
  disableLog: true,
1472
1553
  silent: true,
@@ -1843,7 +1924,7 @@ EOF`);
1843
1924
  );
1844
1925
  } else logger.error(`Service pod ${podToMonitor} failed to start in time.`);
1845
1926
  if (options.etcHosts === true) {
1846
- const hostListenResult = Underpost.deploy.etcHostFactory([host]);
1927
+ const hostListenResult = etcHostFactory([host]);
1847
1928
  logger.info(hostListenResult.renderHosts);
1848
1929
  }
1849
1930
  },
@@ -1861,7 +1942,7 @@ EOF`);
1861
1942
  const confServer = loadConfServerJson(`./engine-private/conf/${options.deployId}/conf.server.json`);
1862
1943
  hosts.push(...Object.keys(confServer));
1863
1944
  }
1864
- const hostListenResult = Underpost.deploy.etcHostFactory(hosts);
1945
+ const hostListenResult = etcHostFactory(hosts);
1865
1946
  logger.info(hostListenResult.renderHosts);
1866
1947
  },
1867
1948
 
@@ -2180,15 +2261,26 @@ EOF`);
2180
2261
  * @memberof UnderpostRun
2181
2262
  */
2182
2263
  kill: (path = '', options = DEFAULT_OPTION) => {
2183
- if (options.pid) return shellExec(`sudo kill -9 ${options.pid}`);
2264
+ if (options.pid)
2265
+ return shellExec(`sudo kill -9 ${options.pid}`, {
2266
+ silentOnError: true,
2267
+ });
2184
2268
  for (const _path of path.split(',')) {
2185
2269
  if (_path.split('+')[1]) {
2186
2270
  let [port, sumPortOffSet] = _path.split('+');
2187
2271
  port = parseInt(port);
2188
2272
  sumPortOffSet = parseInt(sumPortOffSet);
2189
2273
  for (const sumPort of range(0, sumPortOffSet))
2190
- shellExec(`sudo kill -9 $(lsof -t -i:${parseInt(port) + parseInt(sumPort)})`);
2191
- } else shellExec(`sudo kill -9 $(lsof -t -i:${_path})`);
2274
+ shellExec(
2275
+ `PIDS=$(lsof -t -i:${parseInt(port) + parseInt(sumPort)}); [ -n "$PIDS" ] && sudo kill -9 $PIDS || true`,
2276
+ {
2277
+ silentOnError: true,
2278
+ },
2279
+ );
2280
+ } else
2281
+ shellExec(`PIDS=$(lsof -t -i:${_path}); [ -n "$PIDS" ] && sudo kill -9 $PIDS || true`, {
2282
+ silentOnError: true,
2283
+ });
2192
2284
  }
2193
2285
  },
2194
2286
  /**
@@ -2235,9 +2327,10 @@ EOF`);
2235
2327
  * @memberof UnderpostRun
2236
2328
  */
2237
2329
  secret: (path, options = DEFAULT_OPTION) => {
2238
- const secretPath = path ? path : `/home/dd/engine/engine-private/conf/dd-cron/.env.production`;
2239
- const command = `${options.dev ? 'node bin' : 'underpost'} secret underpost --create-from-file ${secretPath}`;
2240
- shellExec(command);
2330
+ const cronDeployId = cronDeployIdResolve() || 'dd-cron';
2331
+ Underpost.secret.underpost.createFromEnvFile(
2332
+ `/home/dd/engine/engine-private/conf/${cronDeployId}/.env.${options.dev ? 'development' : 'production'}`,
2333
+ );
2241
2334
  },
2242
2335
  /**
2243
2336
  * @method underpost-config
@@ -2545,6 +2638,57 @@ EOF`;
2545
2638
  }
2546
2639
  }
2547
2640
  },
2641
+
2642
+ /**
2643
+ * @method monitor-ui
2644
+ * @description Installs and enables the Cockpit KVM Dashboard (cockpit, cockpit-machines, libvirt)
2645
+ * and opens the cockpit firewall service. With `--remove`, closes the firewall service instead.
2646
+ * @param {string} path - Unused.
2647
+ * @param {Object} options - The default underpost runner options for customizing workflow.
2648
+ * `options.remove` — when true, removes the cockpit firewall rule instead of adding it.
2649
+ * @memberof UnderpostRun
2650
+ */
2651
+ 'monitor-ui': (path, options = DEFAULT_OPTION) => {
2652
+ if (options.remove) {
2653
+ shellExec(`sudo firewall-cmd --zone=public --remove-service=cockpit --permanent`);
2654
+ shellExec(`sudo firewall-cmd --reload`);
2655
+ return;
2656
+ }
2657
+ shellExec(`sudo dnf install -y cockpit cockpit-machines libvirt`);
2658
+ shellExec(`sudo systemctl enable --now cockpit.socket libvirtd`);
2659
+ shellExec(`sudo firewall-cmd --permanent --add-service=cockpit`);
2660
+ shellExec(`sudo firewall-cmd --reload`);
2661
+ },
2662
+
2663
+ /**
2664
+ * @method shared-dir
2665
+ * @description Run once for initial shared-directory setup. Creates the group, adds the user,
2666
+ * creates the directory, sets ownership, applies the SGID bit, and configures default ACLs so
2667
+ * all future files inside the directory automatically inherit group write permissions.
2668
+ * Use `reload-shared-dir` for subsequent permission repairs without recreating the group.
2669
+ * @param {string} path - Target directory to set up (defaults to `/home/dd/engine`).
2670
+ * Customise via the `path` argument or leave empty to use the default.
2671
+ * @param {Object} options - The default underpost runner options for customizing workflow.
2672
+ * Key fields: `options.user` (default `'admin'`), `options.group` (default `'engine-dev'`).
2673
+ * @memberof UnderpostRun
2674
+ */
2675
+ 'shared-dir': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
2676
+ const dir = path || '/home/dd/engine';
2677
+ const user = options.user || 'admin';
2678
+ const group = options.group || 'engine-dev';
2679
+
2680
+ logger.info(`[setup-shared-dir] dir=${dir} user=${user} group=${group}`);
2681
+
2682
+ shellExec(`sudo groupadd ${group} 2>/dev/null || true`);
2683
+ shellExec(`sudo usermod -aG ${group} ${user}`);
2684
+ shellExec(`sudo mkdir -p ${dir}`);
2685
+ shellExec(`sudo chown -R ${user}:${group} ${dir}`);
2686
+ shellExec(`sudo chmod -R 2775 ${dir}`);
2687
+ shellExec(`sudo setfacl -d -m g:${group}:rwx ${dir}`);
2688
+ shellExec(`sudo setfacl -m g:${group}:rwx ${dir}`);
2689
+
2690
+ logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
2691
+ },
2548
2692
  };
2549
2693
 
2550
2694
  static API = {
@@ -2601,14 +2745,14 @@ EOF`;
2601
2745
  if (options.replicas === '' || options.replicas === null || options.replicas === undefined)
2602
2746
  options.replicas = 1;
2603
2747
  options.npmRoot = npmRoot;
2604
- logger.info('callback', { path, options });
2748
+ logger.info(`Executing runner`, { runner, namespace: options.namespace });
2605
2749
  if (!Underpost.run.RUNNERS.includes(runner)) throw new Error(`Runner not found: ${runner}`);
2606
2750
  const result = await Underpost.run.CALL(runner, path, options);
2607
2751
  return result;
2608
2752
  } catch (error) {
2609
2753
  console.log(error);
2610
2754
  logger.error(error);
2611
- return null;
2755
+ process.exit(1);
2612
2756
  }
2613
2757
  },
2614
2758
  };