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
@@ -81,9 +81,10 @@ class UnderpostDockerCompose {
81
81
  * Resolves the canonical directory for a custom docker-compose workflow,
82
82
  * keyed by `--deploy-id` + `--docker-compose-id`:
83
83
  * `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>`.
84
- * This directory ships its own `docker-compose.yml`, `compose.env`, and
85
- * `nginx.conf` (used as-is, never generated). Returns null when
86
- * `--docker-compose-id` is not set (the default, self-generating workflow).
84
+ * This directory ships its own `docker-compose.yml`, `compose.env`, and any
85
+ * project-specific bind-mounted files (all used as-is, never generated).
86
+ * Returns null when `--docker-compose-id` is not set (the default,
87
+ * self-generating workflow).
87
88
  * @param {object} options - CLI options.
88
89
  * @returns {string|null} Repo-root-relative canonical dir, or null.
89
90
  * @memberof UnderpostDockerCompose
@@ -99,8 +100,8 @@ class UnderpostDockerCompose {
99
100
  * so behavior is independent of the caller's working directory.
100
101
  *
101
102
  * Custom workflow (`--docker-compose-id`): the compose file, env-file, and
102
- * bind-mounted config (nginx.conf, mongodb/) all live in the canonical dir, so
103
- * compose runs with `--project-directory` pinned there and no app-override.
103
+ * project-owned bind-mounted config all live in the canonical dir, so compose
104
+ * runs with `--project-directory` pinned there and no app-override.
104
105
  * @param {object} options - CLI options.
105
106
  * @returns {string} The base command string (without a subcommand).
106
107
  * @memberof UnderpostDockerCompose
@@ -191,7 +192,7 @@ services:
191
192
  MONGO_IMAGE=mongo:latest
192
193
  VALKEY_IMAGE=valkey/valkey:latest
193
194
  APP_IMAGE=underpost/underpost-engine
194
- APP_TAG=v3.2.70
195
+ APP_TAG=v3.2.90
195
196
  PROXY_IMAGE=nginx:stable-alpine
196
197
  PROMETHEUS_IMAGE=prom/prometheus:latest
197
198
  GRAFANA_IMAGE=grafana/grafana:latest
@@ -371,17 +372,17 @@ datasources:
371
372
  * @memberof UnderpostDockerCompose
372
373
  */
373
374
  static generate(options = {}) {
374
- // Custom workflow: docker-compose.yml, compose.env, and nginx.conf are
375
- // pre-authored in the canonical dir and used as-is do NOT generate them.
376
- // Only (re)write the required MongoDB entrypoint (replica-set bootstrap),
377
- // the single generated infra artifact, into <canonical>/mongodb so the
378
- // stack stays self-contained under `--project-directory`.
375
+ // A named custom workflow is fully owned by its canonical directory. The
376
+ // generic CLI validates the standard entry files but never imports project
377
+ // modules or generates application-specific nginx, env, or helper content.
379
378
  const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
380
379
  if (composeIdBase) {
381
- const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
382
- fs.mkdirpSync(nodePath.dirname(mongoEntrypointPath));
383
- fs.writeFileSync(mongoEntrypointPath, UnderpostDockerCompose.mongoEntrypointContent(), { mode: 0o755 });
384
- logger.info('mongodb entrypoint written (custom workflow)', { path: mongoEntrypointPath });
380
+ const required = ['docker-compose.yml', 'compose.env'].map((name) =>
381
+ UnderpostDockerCompose.resolve(`${composeIdBase}/${name}`),
382
+ );
383
+ const missing = required.filter((path) => !fs.existsSync(path));
384
+ if (missing.length) throw new Error(`Custom Docker Compose workflow is missing: ${missing.join(', ')}`);
385
+ logger.info('custom Docker Compose workflow uses canonical files as-is', { path: composeIdBase });
385
386
  return;
386
387
  }
387
388
 
@@ -491,16 +492,10 @@ datasources:
491
492
  silentOnError: true,
492
493
  });
493
494
 
494
- // Custom workflow: the compose file, compose.env, and nginx.conf are
495
- // hand-authored source never prune them. Only drop the one generated
496
- // artifact (the mongo entrypoint), regenerated on the next --up/--generate.
495
+ // A named custom workflow owns every file beneath its canonical directory.
496
+ // Reset tears down Docker resources but never prunes project-owned files.
497
497
  const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
498
498
  if (composeIdBase) {
499
- const mongoEntrypointPath = UnderpostDockerCompose.resolve(`${composeIdBase}/mongodb/entrypoint.sh`);
500
- if (fs.existsSync(mongoEntrypointPath)) {
501
- fs.removeSync(mongoEntrypointPath);
502
- logger.info('removed generated artifact', { path: mongoEntrypointPath });
503
- }
504
499
  logger.info('Docker Compose reset complete. Run `--up` to recreate the stack.');
505
500
  return;
506
501
  }
@@ -545,7 +540,7 @@ datasources:
545
540
  * @param {boolean} [options.shell] - Open an interactive shell in `target` (default: app).
546
541
  * @param {string} [options.exec] - General-purpose passthrough docker compose subcommand.
547
542
  * @param {string} [options.deployId] - Deployment to run as the app (default: dd-default). `dd-default` self-bootstraps a fresh engine; any other id runs the standard `underpost start` command.
548
- * @param {string} [options.dockerComposeId] - Custom-workflow selector. When set, use the canonical stack at `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>/` (docker-compose.yml + compose.env + nginx.conf, used as-is), skipping nginx/env/app-override generation. Used by the Cyberia MMO ecosystem (`--deploy-id dd-cyberia --docker-compose-id cyberia`).
543
+ * @param {string} [options.dockerComposeId] - Custom-workflow selector. When set, use the canonical stack at `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>/` (docker-compose.yml + compose.env, used as-is), skipping nginx/env/app-override generation.
549
544
  * @param {string} [options.env] - Deployment environment for non-default deploy ids (default: development).
550
545
  * @param {string} [options.composeFile] - Override compose file path.
551
546
  * @param {string} [options.envFile] - Override env-file path.
package/src/cli/fs.js CHANGED
@@ -315,7 +315,6 @@ class UnderpostFileStorage {
315
315
  public_ids: [path],
316
316
  resource_type: 'raw',
317
317
  });
318
- logger.info('download result', downloadResult);
319
318
  await Downloader.downloadFile(downloadResult, zipPath);
320
319
 
321
320
  if (options.omitUnzip === true) {
package/src/cli/image.js CHANGED
@@ -12,6 +12,7 @@ import { loggerFactory } from '../server/logger.js';
12
12
  import Underpost from '../index.js';
13
13
  import { getNpmRootPath } from '../server/conf.js';
14
14
  import { shellExec } from '../server/process.js';
15
+ import { crictlCommandFactory } from '../server/cri.js';
15
16
 
16
17
  const logger = loggerFactory(import.meta);
17
18
 
@@ -117,20 +118,38 @@ class UnderpostImage {
117
118
  };
118
119
  // addBuildSecret('github_token', process.env.GITHUB_TOKEN);
119
120
  addBuildSecret('github_username', process.env.GITHUB_USERNAME);
120
- // Cloudinary creds power build-time asset pulls (`node bin fs --pull`).
121
- // addBuildSecret('cloudinary_cloud_name', process.env.CLOUDINARY_CLOUD_NAME);
122
- // addBuildSecret('cloudinary_api_key', process.env.CLOUDINARY_API_KEY);
123
- // addBuildSecret('cloudinary_api_secret', process.env.CLOUDINARY_API_SECRET);
121
+ // Build secrets are created dynamically: each `RUN --mount=type=secret,id=<x>`
122
+ // id maps to a host env var; only the ones actually set are passed, so a
123
+ // Dockerfile that omits (or comments out) a mount just never sees it. Extend
124
+ // this map — plus a merge of any caller-supplied `options.buildSecrets` — to
125
+ // add more without touching the call sites. Secrets never persist in image
126
+ // history (unlike build-args); they live in 0600 temp files removed post-build.
127
+ const BUILD_SECRET_ENV = {
128
+ // // Cloudinary creds power build-time asset pulls (`node bin fs --pull`).
129
+ // cloudinary_cloud_name: 'CLOUDINARY_CLOUD_NAME',
130
+ // cloudinary_api_key: 'CLOUDINARY_API_KEY',
131
+ // cloudinary_api_secret: 'CLOUDINARY_API_SECRET',
132
+ };
133
+ for (const [id, envVar] of Object.entries(BUILD_SECRET_ENV)) addBuildSecret(id, process.env[envVar]);
134
+ for (const [id, value] of Object.entries(options.buildSecrets || {})) addBuildSecret(id, value);
124
135
  const secretArgs = secretFlags.length ? ` ${secretFlags.join(' ')}` : '';
125
- if (secretFlags.length)
126
- logger.info('Passing host GitHub credentials as build secrets', { ids: secretFlags.length });
136
+ if (secretFlags.length) logger.info('Passing host credentials as build secrets', { ids: secretFlags.length });
137
+
138
+ // Non-secret build args (e.g. INSTANCE_CODES for engine-cyberia): injected
139
+ // dynamically from `options.buildArgs`, overriding the Dockerfile's ARG
140
+ // defaults. Unlike secrets these DO persist in image metadata, so only
141
+ // non-sensitive values belong here.
142
+ const buildArgFlags = Object.entries(options.buildArgs || {})
143
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
144
+ .map(([k, v]) => `--build-arg ${k}=${JSON.stringify(String(v))}`);
145
+ const buildArgStr = buildArgFlags.length ? ` ${buildArgFlags.join(' ')}` : '';
127
146
 
128
147
  if (path)
129
148
  try {
130
149
  shellExec(
131
150
  `cd ${path} && sudo podman build -f ./${
132
151
  dockerfileName && typeof dockerfileName === 'string' ? dockerfileName : 'Dockerfile'
133
- } -t ${imageName} --pull=never --cap-add=CAP_AUDIT_WRITE${cache}${secretArgs} --network host`,
152
+ } -t ${imageName} --pull=never --cap-add=CAP_AUDIT_WRITE${cache}${secretArgs}${buildArgStr} --network host`,
134
153
  );
135
154
  } finally {
136
155
  for (const file of secretTmpFiles) {
@@ -207,6 +226,7 @@ class UnderpostImage {
207
226
  * @param {object} options - Options for the image retrieval.
208
227
  * @param {boolean} options.spec - Whether to retrieve images from the pod specifications.
209
228
  * @param {string} options.namespace - Kubernetes namespace to filter pods.
229
+ * @param {boolean} [options.k3s] - Resolve the CRI endpoint as K3s' embedded containerd.
210
230
  * @returns {Array<object>} - Array of objects containing pod names and their corresponding images.
211
231
  * @memberof UnderpostImage
212
232
  */
@@ -227,10 +247,17 @@ class UnderpostImage {
227
247
  }))
228
248
  .filter((o) => o.image);
229
249
  }
230
- const raw = shellExec(node === 'kind-worker' ? `docker exec -i ${node} crictl images` : `crictl images`, {
231
- stdout: true,
232
- silent: true,
233
- });
250
+ // Outside kind, target the live CRI endpoint explicitly: /etc/crictl.yaml
251
+ // may still point at a CRI-O socket the host no longer runs.
252
+ const raw = shellExec(
253
+ node === 'kind-worker'
254
+ ? `docker exec -i ${node} crictl images`
255
+ : crictlCommandFactory('images', options || {}),
256
+ {
257
+ stdout: true,
258
+ silent: true,
259
+ },
260
+ );
234
261
 
235
262
  const heads = raw
236
263
  .split(`\n`)[0]
@@ -294,7 +321,7 @@ class UnderpostImage {
294
321
  shellExec(`docker exec -i kind-control-plane crictl rmi ${imageName}`);
295
322
  shellExec(`docker exec -i kind-worker crictl rmi ${imageName}`);
296
323
  } else if (kubeadm === true) {
297
- shellExec(`crictl rmi ${imageName}`);
324
+ shellExec(crictlCommandFactory(`rmi ${imageName}`));
298
325
  } else if (k3s === true) {
299
326
  shellExec(`sudo k3s ctr images rm ${imageName}`);
300
327
  }
@@ -363,7 +390,7 @@ class UnderpostImage {
363
390
  shellExec(`docker pull ${image}`);
364
391
  shellExec(`sudo kind load docker-image ${image}`);
365
392
  } else {
366
- shellExec(`sudo crictl pull ${image}`);
393
+ shellExec(crictlCommandFactory(`pull ${image}`, { k3s: targetK3s }));
367
394
  }
368
395
  },
369
396
  };
package/src/cli/index.js CHANGED
@@ -108,6 +108,10 @@ program
108
108
  .option('--edit', 'Edit last commit.')
109
109
  .option('--deploy-id <deploy-id>', 'Sets the deployment configuration ID for the commit context.')
110
110
  .option('--cached', 'Commit staged changes only or context.')
111
+ .option(
112
+ '--init-repo [origin]',
113
+ 'Initialize a git repository at the specified path. Optionally set the git remote origin URL.',
114
+ )
111
115
  .option('--hashes <hashes>', 'Comma-separated list of specific file hashes of commits.')
112
116
  .option('--extension <extension>', 'specific file extensions of commits.')
113
117
  .option('--changelog', 'Print the plain changelog of the last N commits (see --from-n-commit, default 1).')
@@ -260,7 +264,8 @@ program
260
264
  .option('--reset', `Deletes all clusters and prunes all related data and caches.`)
261
265
  .option(
262
266
  '--reset-mongodb',
263
- `Performs a hard cleanup of only MongoDB-related resources (StatefulSet, PVCs/PVs, Secrets, ConfigMaps, caches) without restarting the whole node.`,
267
+ `Performs a hard cleanup of only MongoDB-related resources (StatefulSet, PVCs/PVs, Secrets, ConfigMaps, caches) without restarting the whole node. ` +
268
+ `Combined with --mongodb it instead wipes the retained hostPath volumes as part of that deploy, so the replica set starts from empty data.`,
264
269
  )
265
270
  .option('--mariadb', 'Initializes the cluster with a MariaDB statefulset.')
266
271
  .option('--mysql', 'Initializes the cluster with a MySQL statefulset.')
@@ -271,6 +276,16 @@ program
271
276
  .option('--valkey', 'Initializes the cluster with a Valkey service.')
272
277
  .option('--ipfs', 'Initializes the cluster with an ipfs-cluster statefulset.')
273
278
  .option('--contour', 'Initializes the cluster with Project Contour base HTTPProxy and Envoy.')
279
+ .option(
280
+ '--gateway-api',
281
+ 'Initializes the cluster with the Gateway API control plane (CRDs, Envoy Gateway, GatewayClass) used by generated HTTPRoute + QUIC/HTTP3 manifests. ' +
282
+ 'With --dev the data plane binds the listener ports on the host network for direct browser access.',
283
+ )
284
+ .option('--gateway-class <name>', 'GatewayClass name to provision (default "eg").')
285
+ .option(
286
+ '--ingress-node <node-name>',
287
+ 'Dedicated node for underpost-ingress when both routing stacks coexist. Workload placement flags do not move it.',
288
+ )
274
289
  .option(
275
290
  '--node-port',
276
291
  'Exposes enabled ready services (e.g. MongoDB 4.4, Valkey) to the host/public network via their NodePort Service manifest.',
@@ -328,7 +343,6 @@ program
328
343
  .option('--remove', 'Deletes specified deployments and their associated services.')
329
344
  .option('--sync', 'Synchronizes deployment environment variables, ports, and replica counts.')
330
345
  .option('--info-router', 'Displays the current router structure and configuration.')
331
- .option('--expose', 'Exposes services matching the provided deployment ID list.')
332
346
  .option('--cert', 'Resets TLS/SSL certificate secrets for deployments.')
333
347
  .option('--cert-hosts <hosts>', 'Resets TLS/SSL certificate secrets for specified hosts.')
334
348
  .option(
@@ -338,6 +352,10 @@ program
338
352
  'Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.',
339
353
  )
340
354
  .option('--node <node>', 'Sets optional node for deployment operations.')
355
+ .option(
356
+ '--ingress-node <node-name>',
357
+ 'Explicitly relocates the shared host-network ingress; ordinary --node placement never moves it.',
358
+ )
341
359
  .option(
342
360
  '--ssh-key-path <path>',
343
361
  'Private key path for node SSH operations. Currently used when shipping a hostPath volume to a remote target node over SSH. Defaults to engine-private/deploy/id_rsa.',
@@ -346,6 +364,10 @@ program
346
364
  '--build-manifest',
347
365
  'Builds Kubernetes YAML manifests, including deployments, services, proxies, and secrets.',
348
366
  )
367
+ .option(
368
+ '--sync-static',
369
+ 'Places the SSR status pages and intercepted contexts in the gateway static utility tree, so the edge serves them instead of the application pods. Prefers the running workload and falls back to this checkout, so it can seed the tree before the deployment exists and refresh it once the deployment is Ready.',
370
+ )
349
371
  .option('--replicas <replicas>', 'Sets a custom number of replicas for deployments.')
350
372
  .option('--image <image>', 'Sets a custom image for deployments.')
351
373
  .option('--versions <deployment-versions>', 'A comma-separated list of custom deployment versions.')
@@ -358,35 +380,31 @@ program
358
380
  .option('--retry-count <count>', 'Sets HTTPProxy per-route retry count (e.g., 3).')
359
381
  .option('--retry-per-try-timeout <duration>', 'Sets HTTPProxy retry per-try timeout (e.g., "150ms").')
360
382
  .option('--disable-update-deployment', 'Disables updates to deployments.')
361
- .option('--disable-runtime-probes', 'Omits the internal-status HTTP probes from generated deployment manifests.')
383
+ .option(
384
+ '--disable-runtime-probes',
385
+ 'Deprecated compatibility flag; readiness probes remain mandatory. Use --tcp-probes for legacy workloads.',
386
+ )
362
387
  .option('--tcp-probes', 'Generates legacy TCP socket probes instead of HTTP internal-status probes (migration).')
363
388
  .option('--disable-update-proxy', 'Disables updates to proxies.')
364
389
  .option('--disable-deployment-proxy', 'Disables proxies of deployments.')
365
- .option('--disable-update-volume', 'Disables updates to volume mounts during deployment.')
366
390
  .option(
367
- '--status',
368
- 'Retrieves current network traffic data from resource deployments and the host machine network configuration.',
391
+ '--gateway-api',
392
+ 'Routes through the Gateway API stack (Gateway + HTTPRoute) instead of the Contour HTTPProxy. ' +
393
+ 'Both manifest sets are always generated; this selects which one is applied.',
394
+ )
395
+ .option('--gateway-class <name>', 'GatewayClass name for generated Gateway manifests (default "eg").')
396
+ .option(
397
+ '--disable-http3',
398
+ 'Omits the QUIC/HTTP3 listener config and the Alt-Svc advertisement from Gateway API manifests.',
369
399
  )
400
+ .option('--quic-port <port>', 'UDP port advertised for QUIC/HTTP3 in generated Gateway API manifests (default 443).')
401
+ .option('--disable-update-volume', 'Disables updates to volume mounts during deployment.')
370
402
  .option('--kubeadm', 'Enables the kubeadm context for deployment operations.')
371
403
  .option('--k3s', 'Enables the k3s context for deployment operations.')
372
404
  .option('--kind', 'Enables the kind context for deployment operations.')
373
405
  .option('--git-clean', 'Runs git clean on volume mount paths before copying.')
374
406
  .option('--disable-update-underpost-config', 'Disables updates to Underpost configuration during deployment.')
375
407
  .option('--namespace <namespace>', 'Kubernetes namespace for deployment operations (defaults to "default").')
376
- .option('--kind-type <kind-type>', 'Specifies the Kind cluster type for deployment operations.')
377
- .option('--port <port>', 'Sets up port forwarding from local to remote ports.')
378
- .option(
379
- '--expose-port <port>',
380
- 'Sets the local:remote port to expose when --expose is active (overrides auto-detected service port).',
381
- )
382
- .option(
383
- '--expose-local-port <port>',
384
- 'Sets a different local port for --expose (e.g. 80) while keeping the remote service port. Useful for /etc/hosts local access without specifying a port in the browser.',
385
- )
386
- .option(
387
- '--local-proxy',
388
- 'Forward all service TCP ports locally and start the Node.js path-routing proxy. Enables full path-based routing (e.g. /wp alongside /) without needing --expose-local-port. Requires --expose.',
389
- )
390
408
  .option('--cmd <cmd>', 'Custom initialization command for deployment (comma-separated commands).')
391
409
  .option(
392
410
  '--skip-full-build',
@@ -400,30 +418,71 @@ program
400
418
  '--image-pull-policy <policy>',
401
419
  'Override container imagePullPolicy in the generated deployment manifest (Always, IfNotPresent, Never). Defaults to Never for localhost/ images and IfNotPresent otherwise.',
402
420
  )
403
- .option(
404
- '--tls',
405
- 'Enables TLS for the local proxy started by --expose --local-proxy. ' +
406
- 'The proxy will serve HTTPS on port 443 using self-signed certificates resolved from the local SSL store. ' +
407
- 'Use together with --expose and --local-proxy.',
408
- )
409
421
  .description('Manages application deployments, defaulting to deploying development pods.')
410
422
  .action(Underpost.deploy.callback);
411
423
 
412
424
  program
413
425
  .command('secret')
414
- .argument('<platform>', `The secret management platform. Options: ${Object.keys(Underpost.secret).join(', ')}.`)
426
+ .argument(
427
+ '[platform]',
428
+ `The secret management platform. Options: ${Object.keys(Underpost.secret).join(', ')}. Defaults to "sops".`,
429
+ 'sops',
430
+ )
415
431
  .option('--init', 'Initializes the secrets platform environment.')
416
432
  .option('--create-from-file <path-env-file>', 'Creates secrets from a specified environment file.')
417
433
  .option('--create-from-env', 'Creates secrets from container environment variables (envFrom: secretRef).')
418
434
  .option('--global-clean', 'Removes all filesystem traces of secrets (engine-private, .env, conf cache).')
419
435
  .option('--list', 'Lists all available secrets for the platform.')
436
+ .option(
437
+ '--encrypt <plaintext-path>',
438
+ 'Encrypts a plaintext Secret manifest into the Git-tracked SOPS store and shreds the source (sops platform).',
439
+ )
440
+ .option(
441
+ '--apply',
442
+ 'Decrypts stored SOPS manifests and streams them into kubectl apply, without writing plaintext to disk (sops platform).',
443
+ )
444
+ .option('--namespace <namespace>', 'Kubernetes namespace for secret operations (defaults to "default").')
445
+ .option(
446
+ '--install-tools',
447
+ 'Installs the sops and age host binaries only, without running a full cluster host initialization.',
448
+ )
449
+ .option(
450
+ '--rotate',
451
+ 'Re-keys every stored SOPS manifest onto --recipient. Secret values are unchanged, so no workload restart is needed.',
452
+ )
453
+ .option('--recipient <age-public-key>', 'Incoming Age public recipient for --rotate.')
454
+ .option(
455
+ '--prune-recipients',
456
+ 'With --rotate, makes --recipient the only recipient, revoking every previous key (use after a key compromise). ' +
457
+ 'Requires --force, and revokes CI/CD keys too unless they are named in --keep-recipients.',
458
+ )
459
+ .option(
460
+ '--keep-recipients <age-public-keys>',
461
+ 'Comma-separated recipients to retain while --prune-recipients revokes the rest (e.g. the CI/CD key).',
462
+ )
463
+ .option(
464
+ '--purge <secret-name>',
465
+ 'Emergency removal: deletes the live Kubernetes Secret and takes its encrypted manifest out of the store.',
466
+ )
467
+ .option(
468
+ '--force',
469
+ 'Confirms the irreversible variant: deletes the manifest instead of archiving it (--purge), ' +
470
+ 'revokes recipients (--rotate --prune-recipients), or replaces an existing manifest (--encrypt).',
471
+ )
472
+ .option('--dry-run', 'Reports what --apply, --rotate, or --purge would do without changing anything.')
420
473
  .description(`Manages secrets for various platforms.`)
421
- .action((...args) => {
422
- if (args[1].globalClean) return Underpost.secret.globalSecretClean();
423
- if (args[1].createFromFile) return Underpost.secret[args[0]].createFromEnvFile(args[1].createFromFile);
424
- if (args[1].createFromEnv) return Underpost.secret[args[0]].createFromContainerEnv();
425
- if (args[1].list) return Underpost.secret[args[0]].list();
426
- if (args[1].init) return Underpost.secret[args[0]].init();
474
+ .action((platform, options) => {
475
+ // Host tooling install is platform-independent, so it resolves before any platform lookup.
476
+ if (options.installTools) return Underpost.secret.sops.installTooling();
477
+ if (options.globalClean) return Underpost.secret.globalSecretClean();
478
+ if (options.rotate) return Underpost.secret[platform].rotate(options.recipient, options);
479
+ if (options.purge) return Underpost.secret[platform].purge(options.purge, options);
480
+ if (options.encrypt) return Underpost.secret[platform].encrypt(options.encrypt, options.namespace, options);
481
+ if (options.apply) return Underpost.secret[platform].apply(options.namespace, options);
482
+ if (options.createFromFile) return Underpost.secret[platform].createFromEnvFile(options.createFromFile);
483
+ if (options.createFromEnv) return Underpost.secret[platform].createFromContainerEnv();
484
+ if (options.list) return Underpost.secret[platform].list();
485
+ if (options.init) return Underpost.secret[platform].init();
427
486
  });
428
487
 
429
488
  program
@@ -648,11 +707,24 @@ program
648
707
  .option('--replicas <replicas>', 'Sets a custom number of replicas for deployment.')
649
708
  .option('--pod-name <pod-name>', 'Optional: Specifies the pod name for execution.')
650
709
  .option('--node-name <node-name>', 'Optional: Specifies the node name for execution.')
710
+ .option(
711
+ '--ingress-node <node-name>',
712
+ 'Dedicated node for the host-network underpost-ingress listener. Workload --node-name never relocates it.',
713
+ )
651
714
  .option(
652
715
  '--ssh-key-path <path>',
653
716
  'Optional: Private key path for node SSH operations, forwarded to volume shipping over SSH. Defaults to engine-private/deploy/id_rsa.',
654
717
  )
655
718
  .option('--port <port>', 'Optional: Specifies the port for execution.')
719
+ .option(
720
+ '--expose-container-ports <ports>',
721
+ 'Comma-separated Service/container ports; multiple matched resources consume values by resource index.',
722
+ )
723
+ .option(
724
+ '--expose-host-ports <ports>',
725
+ 'Comma-separated host ports paired with container ports by resource/port index.',
726
+ )
727
+ .option('--local-proxy', 'Starts the development path proxy after the expose runner creates its port-forwards.')
656
728
  .option('--etc-hosts', 'Enables etc-hosts context for the runner execution.')
657
729
  .option('--volume-host-path <volume-host-path>', 'Optional: Specifies the volume host path for test execution.')
658
730
  .option('--volume-mount-path <volume-mount-path>', 'Optional: Specifies the volume mount path for test execution.')
@@ -688,7 +760,10 @@ program
688
760
  '--resource-template-id <resource-template-id >',
689
761
  'Specifies a resource template ID for the runner execution.',
690
762
  )
691
- .option('--expose', 'Enables service exposure for the runner execution.')
763
+ .option(
764
+ '--expose',
765
+ 'Enables exposure-only behavior in compatible runners; the expose runner itself does not require this flag.',
766
+ )
692
767
  .option('--conf-server-path <conf-server-path>', 'Sets a custom configuration server path.')
693
768
  .option('--underpost-root <underpost-root>', 'Sets a custom Underpost root path.')
694
769
  .option('--cmd-cron-jobs <cmd-cron-jobs>', 'Pre-script commands to run before cron job execution.')
@@ -700,7 +775,10 @@ program
700
775
  .option('--kubeadm', 'Sets the kubeadm cluster context for the runner execution.')
701
776
  .option('--k3s', 'Sets the k3s cluster context for the runner execution.')
702
777
  .option('--kind', 'Sets the kind cluster context for the runner execution.')
703
- .option('--traffic <traffic>', 'Blue/green traffic colour to bake into generated manifests (default: blue).')
778
+ .option(
779
+ '--traffic <traffic>',
780
+ 'Blue/green traffic colour to bake into generated manifests (default: blue). `stop` accepts a comma list, e.g. blue,green.',
781
+ )
704
782
  .option('--git-clean', 'Runs git clean on volume mount paths before copying.')
705
783
  .option('--deploy-id <deploy-id>', 'Sets deploy id context for the runner execution.')
706
784
  .option('--user <user>', 'Sets user context for the runner execution.')
@@ -714,6 +792,21 @@ program
714
792
  .option('--timeout-idle <duration>', 'Sets HTTPProxy per-route idle timeout (e.g., "10s", "infinity").')
715
793
  .option('--retry-count <count>', 'Sets HTTPProxy per-route retry count (e.g., 3).')
716
794
  .option('--retry-per-try-timeout <duration>', 'Sets HTTPProxy retry per-try timeout (e.g., "150ms").')
795
+ .option(
796
+ '--gateway-api',
797
+ 'Routes through the Gateway API stack (Gateway + HTTPRoute) instead of the Contour HTTPProxy. ' +
798
+ 'Both manifest sets are always generated; this selects which one is applied.',
799
+ )
800
+ .option(
801
+ '--disable-gateway-api',
802
+ 'Falls back to the Contour HTTPProxy stack in runners where the Gateway API is the default (cluster).',
803
+ )
804
+ .option('--gateway-class <name>', 'GatewayClass name for generated Gateway manifests (default "eg").')
805
+ .option(
806
+ '--disable-http3',
807
+ 'Omits the QUIC/HTTP3 listener config and the Alt-Svc advertisement from Gateway API manifests.',
808
+ )
809
+ .option('--quic-port <port>', 'UDP port advertised for QUIC/HTTP3 in generated Gateway API manifests (default 443).')
717
810
  .option('--disable-private-conf-update', 'Disables updates to private configuration during execution.')
718
811
  .option('--logs', 'Streams logs during the runner execution.')
719
812
  .option('--monitor-status <status>', 'Sets the status to monitor for pod/resource (default: "Running").')
@@ -758,6 +851,7 @@ program
758
851
  '--test',
759
852
  'Enables test/generic-purpose mode for the runner (e.g. use self-signed TLS instead of cert-manager).',
760
853
  )
854
+ .option('--branch <branch>', 'Sets the branch for git operations (default: current branch).')
761
855
  .description('Runs specified scripts using various runners.')
762
856
  .action(Underpost.run.callback);
763
857
 
package/src/cli/ipfs.js CHANGED
@@ -6,11 +6,14 @@
6
6
 
7
7
  import { loggerFactory } from '../server/logger.js';
8
8
  import { shellExec } from '../server/process.js';
9
+ import { resolveReplicaCount } from '../server/conf.js';
9
10
  import fs from 'fs-extra';
10
11
  import Underpost from '../index.js';
11
12
 
12
13
  const logger = loggerFactory(import.meta);
13
14
 
15
+ const IPFS_DEFAULT_REPLICA_COUNT = 3;
16
+
14
17
  /**
15
18
  * @class UnderpostIPFS
16
19
  * @description Manages deployment of an ipfs-cluster StatefulSet on Kubernetes.
@@ -25,8 +28,12 @@ class UnderpostIPFS {
25
28
  * @description Resolves the IPFS cluster credentials from engine-private/ if they
26
29
  * already exist, otherwise generates new ones (hex cluster secret + peer identity
27
30
  * via ipfs-cluster-service init) and persists them with mode 0o600.
31
+ * Idempotent and self-healing: an existing pair is reused untouched, and a missing or
32
+ * unreadable one is regenerated. Both files are always rewritten together — the peer id and
33
+ * the private key are two halves of one identity, so reusing one with a freshly minted other
34
+ * would advertise a peer id that does not match the key.
28
35
  * @param {string} privateDir - Absolute path to the engine-private directory.
29
- * @returns {{ CLUSTER_SECRET: string, IDENTITY_JSON: { id: string, private_key: string } }}
36
+ * @returns {{ CLUSTER_SECRET: string, IDENTITY_JSON: { id: string, private_key: string }, generated: boolean }}
30
37
  * @memberof UnderpostIPFS
31
38
  */
32
39
  resolveCredentials(privateDir) {
@@ -34,11 +41,17 @@ class UnderpostIPFS {
34
41
  const identityPath = `${privateDir}/ipfs-cluster-identity.json`;
35
42
 
36
43
  if (fs.existsSync(secretPath) && fs.existsSync(identityPath)) {
37
- logger.info('Reusing existing IPFS cluster credentials from engine-private/');
38
- return {
39
- CLUSTER_SECRET: fs.readFileSync(secretPath, 'utf8').trim(),
40
- IDENTITY_JSON: JSON.parse(fs.readFileSync(identityPath, 'utf8')),
41
- };
44
+ try {
45
+ const CLUSTER_SECRET = fs.readFileSync(secretPath, 'utf8').trim();
46
+ const IDENTITY_JSON = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
47
+ if (CLUSTER_SECRET && IDENTITY_JSON?.id && IDENTITY_JSON?.private_key) {
48
+ logger.info('Reusing existing IPFS cluster credentials from engine-private/');
49
+ return { CLUSTER_SECRET, IDENTITY_JSON, generated: false };
50
+ }
51
+ logger.warn('Existing IPFS cluster credentials are incomplete; regenerating');
52
+ } catch (error) {
53
+ logger.warn(`Existing IPFS cluster credentials are unreadable (${error.message}); regenerating`);
54
+ }
42
55
  }
43
56
 
44
57
  logger.info('Generating new IPFS cluster credentials and persisting to engine-private/');
@@ -61,7 +74,52 @@ class UnderpostIPFS {
61
74
 
62
75
  logger.info(`IPFS cluster credentials saved (peer ID: ${IDENTITY_JSON.id})`);
63
76
 
64
- return { CLUSTER_SECRET, IDENTITY_JSON };
77
+ return { CLUSTER_SECRET, IDENTITY_JSON, generated: true };
78
+ },
79
+
80
+ /**
81
+ * @method storeCredentials
82
+ * @description Encrypts the current IPFS credentials into the SOPS store, replacing whatever
83
+ * is there. Called after a regeneration so the encrypted Secret cannot drift from the peer id
84
+ * the `env-config` ConfigMap advertises — the id lives only in the local identity file, so a
85
+ * stale manifest would pair someone else's private key with the new id and the cluster would
86
+ * never form.
87
+ * @param {{ CLUSTER_SECRET: string, IDENTITY_JSON: { private_key: string } }} credentials
88
+ * @param {object} options
89
+ * @param {string} options.namespace - Kubernetes namespace.
90
+ * @memberof UnderpostIPFS
91
+ */
92
+ storeCredentials({ CLUSTER_SECRET, IDENTITY_JSON }, options) {
93
+ const stageDir = '/dev/shm/underpost-secrets';
94
+ const stagePath = `${stageDir}/ipfs-cluster-secret.yaml`;
95
+ fs.ensureDirSync(stageDir);
96
+ fs.chmodSync(stageDir, 0o700);
97
+ try {
98
+ fs.outputFileSync(
99
+ stagePath,
100
+ [
101
+ 'apiVersion: v1',
102
+ 'kind: Secret',
103
+ 'metadata:',
104
+ ' name: ipfs-cluster-secret',
105
+ ` namespace: ${options.namespace}`,
106
+ ' labels:',
107
+ ' app.kubernetes.io/managed-by: underpost',
108
+ 'type: Opaque',
109
+ 'stringData:',
110
+ ` cluster-secret: '${CLUSTER_SECRET.replace(/'/g, "''")}'`,
111
+ ` bootstrap-peer-priv-key: '${IDENTITY_JSON.private_key.replace(/'/g, "''")}'`,
112
+ '',
113
+ ].join('\n'),
114
+ 'utf8',
115
+ );
116
+ fs.chmodSync(stagePath, 0o600);
117
+ // encrypt() stages, validates, moves into place, and shreds the plaintext source.
118
+ Underpost.secret.sops.encrypt(stagePath, options.namespace, { force: true });
119
+ logger.info('Re-encrypted regenerated IPFS credentials into the SOPS store');
120
+ } finally {
121
+ fs.removeSync(stageDir);
122
+ }
65
123
  },
66
124
 
67
125
  /**
@@ -100,12 +158,18 @@ class UnderpostIPFS {
100
158
  applySecrets({ CLUSTER_SECRET, IDENTITY_JSON }, options) {
101
159
  logger.info('Applying IPFS cluster Kubernetes Secret and env ConfigMap');
102
160
 
103
- shellExec(
104
- `kubectl create secret generic ipfs-cluster-secret \
161
+ // Encrypted store first, origin generate/read logic only when no manifest is stored.
162
+ // `--from-literal` places the cluster secret and the peer private key in the command
163
+ // string, so the seed path is kept out of the command log; the encrypted path never
164
+ // exposes them at all.
165
+ if (!Underpost.secret.sops.applyIfPresent('ipfs-cluster-secret', options.namespace))
166
+ shellExec(
167
+ `kubectl create secret generic ipfs-cluster-secret \
105
168
  --from-literal=cluster-secret=${CLUSTER_SECRET} \
106
169
  --from-literal=bootstrap-peer-priv-key=${IDENTITY_JSON.private_key} \
107
170
  --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
108
- );
171
+ { disableLog: true },
172
+ );
109
173
 
110
174
  shellExec(
111
175
  `kubectl create configmap env-config \
@@ -165,7 +229,14 @@ sudo sysctl -w net.core.wmem_max=7500000`,
165
229
 
166
230
  const credentials = Underpost.ipfs.resolveCredentials(`${underpostRoot}/engine-private`);
167
231
 
168
- const ipfsReplicas = options.replicas ? parseInt(options.replicas) : 3;
232
+ // `env-config` advertises `bootstrap-peer-id` from the local identity file — the peer id is
233
+ // not carried in the Secret. So a regeneration invalidates any stored manifest: it would
234
+ // pair the previous private key with the new id and the cluster would never form. Re-encrypt
235
+ // the fresh pair so the store and the ConfigMap stay one identity.
236
+ if (credentials.generated && Underpost.secret.sops.has('ipfs-cluster-secret', options.namespace))
237
+ Underpost.ipfs.storeCredentials(credentials, options);
238
+
239
+ const ipfsReplicas = resolveReplicaCount(options.replicas, IPFS_DEFAULT_REPLICA_COUNT);
169
240
 
170
241
  Underpost.ipfs.teardown(options, ipfsReplicas);
171
242
  Underpost.ipfs.applySecrets(credentials, options);
@@ -123,7 +123,7 @@ class UnderpostMonitor {
123
123
 
124
124
  let errorPayloads = [];
125
125
  if (options.sync === true) {
126
- const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
126
+ const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace, env });
127
127
  if (currentTraffic) Underpost.env.set(`${deployId}-${env}-traffic`, currentTraffic);
128
128
  }
129
129
  let traffic = Underpost.env.get(`${deployId}-${env}-traffic`) ?? 'blue';
@@ -135,6 +135,10 @@ const buildVersionBumpTargets = () => [
135
135
  file: 'src/runtime/engine-cyberia/docker-compose.yml',
136
136
  patterns: /(_TAG:-v)\d+\.\d+\.\d+/g,
137
137
  },
138
+ {
139
+ file: 'engine-private/conf/dd-cyberia/docker-compose/cyberia/docker-compose.yml',
140
+ patterns: /(_TAG:-v)\d+\.\d+\.\d+/g,
141
+ },
138
142
 
139
143
  // ── Cyberia CLI dev image tar/name defaults (bin/cyberia.js). ──
140
144
  {