underpost 3.2.80 → 3.3.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 (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
package/src/cli/index.js CHANGED
@@ -2,7 +2,8 @@ import dotenv from 'dotenv';
2
2
  import fs from 'fs-extra';
3
3
 
4
4
  import { Command } from 'commander';
5
- import { getNpmRootPath, getUnderpostRootPath, loadConf } from '../server/conf.js';
5
+ import { loadConf } from '../server/conf.js';
6
+ import { getNpmRootPath, getUnderpostRootPath } from '../server/environment.js';
6
7
  import { commitData } from '../client/components/core/CommonJs.js';
7
8
 
8
9
  import Underpost from '../index.js';
@@ -51,6 +52,7 @@ program
51
52
  .option('--merge-zip <build-prefix>', 'Merge split ZIP parts back into a single ZIP file for the given build prefix')
52
53
  .option('--lite-build', 'Skip full build (default is full build)')
53
54
  .option('--icons-build', 'Build icons')
55
+ .option('--ssr', 'Rebuild only SSR views defined in conf.ssr.json, leaving client assets untouched')
54
56
  .description('Builds client assets, single replicas, and/or syncs environment ports.')
55
57
  .action(Underpost.repo.client);
56
58
 
@@ -143,7 +145,7 @@ program
143
145
  '--switch-repo <url>',
144
146
  'Switches the git remote (origin) to <url> and force-pulls the target branch, overwriting the current working tree (discards local commits and tracked changes). Accepts a full URL or "owner/repo".',
145
147
  )
146
- .option('--target-branch <branch>', 'Target branch for --switch-repo (default: master).')
148
+ .option('--target-branch <branch>', 'Target branch for --switch-repo (default: remote default branch).')
147
149
  .description('Manages commits to a GitHub repository, supporting various commit types and options.')
148
150
  .action(Underpost.repo.commit);
149
151
 
@@ -254,6 +256,10 @@ program
254
256
  .option('--ban-egress-clear', 'Clears all banned egress IP addresses.')
255
257
  .option('--ban-both-add', 'Adds IP addresses to both banned ingress and egress lists.')
256
258
  .option('--ban-both-remove', 'Removes IP addresses from both banned ingress and egress lists.')
259
+ .option('--block-all-egress', 'Blocks all outbound traffic from this host (keeps established/related connections).')
260
+ .option('--unblock-all-egress', 'Unblocks all outbound traffic and restores default ACCEPT policy.')
261
+ .option('--block-all-ingress', 'Blocks all new inbound traffic to this host (keeps established/related connections).')
262
+ .option('--unblock-all-ingress', 'Unblocks all inbound traffic and restores default ACCEPT policy.')
257
263
  .option('--mac', 'Prints the MAC address of the main network interface.')
258
264
  .description('Displays the current public machine IP addresses.')
259
265
  .action(Underpost.dns.ipDispatcher);
@@ -264,7 +270,8 @@ program
264
270
  .option('--reset', `Deletes all clusters and prunes all related data and caches.`)
265
271
  .option(
266
272
  '--reset-mongodb',
267
- `Performs a hard cleanup of only MongoDB-related resources (StatefulSet, PVCs/PVs, Secrets, ConfigMaps, caches) without restarting the whole node.`,
273
+ `Performs a hard cleanup of only MongoDB-related resources (StatefulSet, PVCs/PVs, Secrets, ConfigMaps, caches) without restarting the whole node. ` +
274
+ `Combined with --mongodb it instead wipes the retained hostPath volumes as part of that deploy, so the replica set starts from empty data.`,
268
275
  )
269
276
  .option('--mariadb', 'Initializes the cluster with a MariaDB statefulset.')
270
277
  .option('--mysql', 'Initializes the cluster with a MySQL statefulset.')
@@ -275,6 +282,16 @@ program
275
282
  .option('--valkey', 'Initializes the cluster with a Valkey service.')
276
283
  .option('--ipfs', 'Initializes the cluster with an ipfs-cluster statefulset.')
277
284
  .option('--contour', 'Initializes the cluster with Project Contour base HTTPProxy and Envoy.')
285
+ .option(
286
+ '--gateway-api',
287
+ 'Initializes the cluster with the Gateway API control plane (CRDs, Envoy Gateway, GatewayClass) used by generated HTTPRoute + QUIC/HTTP3 manifests. ' +
288
+ 'With --dev the data plane binds the listener ports on the host network for direct browser access.',
289
+ )
290
+ .option('--gateway-class <name>', 'GatewayClass name to provision (default "eg").')
291
+ .option(
292
+ '--ingress-node <node-name>',
293
+ 'Dedicated node for underpost-ingress when both routing stacks coexist. Workload placement flags do not move it.',
294
+ )
278
295
  .option(
279
296
  '--node-port',
280
297
  'Exposes enabled ready services (e.g. MongoDB 4.4, Valkey) to the host/public network via their NodePort Service manifest.',
@@ -332,7 +349,6 @@ program
332
349
  .option('--remove', 'Deletes specified deployments and their associated services.')
333
350
  .option('--sync', 'Synchronizes deployment environment variables, ports, and replica counts.')
334
351
  .option('--info-router', 'Displays the current router structure and configuration.')
335
- .option('--expose', 'Exposes services matching the provided deployment ID list.')
336
352
  .option('--cert', 'Resets TLS/SSL certificate secrets for deployments.')
337
353
  .option('--cert-hosts <hosts>', 'Resets TLS/SSL certificate secrets for specified hosts.')
338
354
  .option(
@@ -342,6 +358,10 @@ program
342
358
  'Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.',
343
359
  )
344
360
  .option('--node <node>', 'Sets optional node for deployment operations.')
361
+ .option(
362
+ '--ingress-node <node-name>',
363
+ 'Explicitly relocates the shared host-network ingress; ordinary --node placement never moves it.',
364
+ )
345
365
  .option(
346
366
  '--ssh-key-path <path>',
347
367
  '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.',
@@ -350,6 +370,10 @@ program
350
370
  '--build-manifest',
351
371
  'Builds Kubernetes YAML manifests, including deployments, services, proxies, and secrets.',
352
372
  )
373
+ .option(
374
+ '--sync-static',
375
+ '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.',
376
+ )
353
377
  .option('--replicas <replicas>', 'Sets a custom number of replicas for deployments.')
354
378
  .option('--image <image>', 'Sets a custom image for deployments.')
355
379
  .option('--versions <deployment-versions>', 'A comma-separated list of custom deployment versions.')
@@ -362,35 +386,31 @@ program
362
386
  .option('--retry-count <count>', 'Sets HTTPProxy per-route retry count (e.g., 3).')
363
387
  .option('--retry-per-try-timeout <duration>', 'Sets HTTPProxy retry per-try timeout (e.g., "150ms").')
364
388
  .option('--disable-update-deployment', 'Disables updates to deployments.')
365
- .option('--disable-runtime-probes', 'Omits the internal-status HTTP probes from generated deployment manifests.')
389
+ .option(
390
+ '--disable-runtime-probes',
391
+ 'Deprecated compatibility flag; readiness probes remain mandatory. Use --tcp-probes for legacy workloads.',
392
+ )
366
393
  .option('--tcp-probes', 'Generates legacy TCP socket probes instead of HTTP internal-status probes (migration).')
367
394
  .option('--disable-update-proxy', 'Disables updates to proxies.')
368
395
  .option('--disable-deployment-proxy', 'Disables proxies of deployments.')
369
- .option('--disable-update-volume', 'Disables updates to volume mounts during deployment.')
370
396
  .option(
371
- '--status',
372
- 'Retrieves current network traffic data from resource deployments and the host machine network configuration.',
397
+ '--gateway-api',
398
+ 'Routes through the Gateway API stack (Gateway + HTTPRoute) instead of the Contour HTTPProxy. ' +
399
+ 'Both manifest sets are always generated; this selects which one is applied.',
400
+ )
401
+ .option('--gateway-class <name>', 'GatewayClass name for generated Gateway manifests (default "eg").')
402
+ .option(
403
+ '--disable-http3',
404
+ 'Omits the QUIC/HTTP3 listener config and the Alt-Svc advertisement from Gateway API manifests.',
373
405
  )
406
+ .option('--quic-port <port>', 'UDP port advertised for QUIC/HTTP3 in generated Gateway API manifests (default 443).')
407
+ .option('--disable-update-volume', 'Disables updates to volume mounts during deployment.')
374
408
  .option('--kubeadm', 'Enables the kubeadm context for deployment operations.')
375
409
  .option('--k3s', 'Enables the k3s context for deployment operations.')
376
410
  .option('--kind', 'Enables the kind context for deployment operations.')
377
411
  .option('--git-clean', 'Runs git clean on volume mount paths before copying.')
378
412
  .option('--disable-update-underpost-config', 'Disables updates to Underpost configuration during deployment.')
379
413
  .option('--namespace <namespace>', 'Kubernetes namespace for deployment operations (defaults to "default").')
380
- .option('--kind-type <kind-type>', 'Specifies the Kind cluster type for deployment operations.')
381
- .option('--port <port>', 'Sets up port forwarding from local to remote ports.')
382
- .option(
383
- '--expose-port <port>',
384
- 'Sets the local:remote port to expose when --expose is active (overrides auto-detected service port).',
385
- )
386
- .option(
387
- '--expose-local-port <port>',
388
- '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.',
389
- )
390
- .option(
391
- '--local-proxy',
392
- '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.',
393
- )
394
414
  .option('--cmd <cmd>', 'Custom initialization command for deployment (comma-separated commands).')
395
415
  .option(
396
416
  '--skip-full-build',
@@ -404,30 +424,71 @@ program
404
424
  '--image-pull-policy <policy>',
405
425
  'Override container imagePullPolicy in the generated deployment manifest (Always, IfNotPresent, Never). Defaults to Never for localhost/ images and IfNotPresent otherwise.',
406
426
  )
407
- .option(
408
- '--tls',
409
- 'Enables TLS for the local proxy started by --expose --local-proxy. ' +
410
- 'The proxy will serve HTTPS on port 443 using self-signed certificates resolved from the local SSL store. ' +
411
- 'Use together with --expose and --local-proxy.',
412
- )
413
427
  .description('Manages application deployments, defaulting to deploying development pods.')
414
428
  .action(Underpost.deploy.callback);
415
429
 
416
430
  program
417
431
  .command('secret')
418
- .argument('<platform>', `The secret management platform. Options: ${Object.keys(Underpost.secret).join(', ')}.`)
432
+ .argument(
433
+ '[platform]',
434
+ `The secret management platform. Options: ${Object.keys(Underpost.secret).join(', ')}. Defaults to "sops".`,
435
+ 'sops',
436
+ )
419
437
  .option('--init', 'Initializes the secrets platform environment.')
420
438
  .option('--create-from-file <path-env-file>', 'Creates secrets from a specified environment file.')
421
439
  .option('--create-from-env', 'Creates secrets from container environment variables (envFrom: secretRef).')
422
440
  .option('--global-clean', 'Removes all filesystem traces of secrets (engine-private, .env, conf cache).')
423
441
  .option('--list', 'Lists all available secrets for the platform.')
442
+ .option(
443
+ '--encrypt <plaintext-path>',
444
+ 'Encrypts a plaintext Secret manifest into the Git-tracked SOPS store and shreds the source (sops platform).',
445
+ )
446
+ .option(
447
+ '--apply',
448
+ 'Decrypts stored SOPS manifests and streams them into kubectl apply, without writing plaintext to disk (sops platform).',
449
+ )
450
+ .option('--namespace <namespace>', 'Kubernetes namespace for secret operations (defaults to "default").')
451
+ .option(
452
+ '--install-tools',
453
+ 'Installs the sops and age host binaries only, without running a full cluster host initialization.',
454
+ )
455
+ .option(
456
+ '--rotate',
457
+ 'Re-keys every stored SOPS manifest onto --recipient. Secret values are unchanged, so no workload restart is needed.',
458
+ )
459
+ .option('--recipient <age-public-key>', 'Incoming Age public recipient for --rotate.')
460
+ .option(
461
+ '--prune-recipients',
462
+ 'With --rotate, makes --recipient the only recipient, revoking every previous key (use after a key compromise). ' +
463
+ 'Requires --force, and revokes CI/CD keys too unless they are named in --keep-recipients.',
464
+ )
465
+ .option(
466
+ '--keep-recipients <age-public-keys>',
467
+ 'Comma-separated recipients to retain while --prune-recipients revokes the rest (e.g. the CI/CD key).',
468
+ )
469
+ .option(
470
+ '--purge <secret-name>',
471
+ 'Emergency removal: deletes the live Kubernetes Secret and takes its encrypted manifest out of the store.',
472
+ )
473
+ .option(
474
+ '--force',
475
+ 'Confirms the irreversible variant: deletes the manifest instead of archiving it (--purge), ' +
476
+ 'revokes recipients (--rotate --prune-recipients), or replaces an existing manifest (--encrypt).',
477
+ )
478
+ .option('--dry-run', 'Reports what --apply, --rotate, or --purge would do without changing anything.')
424
479
  .description(`Manages secrets for various platforms.`)
425
- .action((...args) => {
426
- if (args[1].globalClean) return Underpost.secret.globalSecretClean();
427
- if (args[1].createFromFile) return Underpost.secret[args[0]].createFromEnvFile(args[1].createFromFile);
428
- if (args[1].createFromEnv) return Underpost.secret[args[0]].createFromContainerEnv();
429
- if (args[1].list) return Underpost.secret[args[0]].list();
430
- if (args[1].init) return Underpost.secret[args[0]].init();
480
+ .action((platform, options) => {
481
+ // Host tooling install is platform-independent, so it resolves before any platform lookup.
482
+ if (options.installTools) return Underpost.secret.sops.installTooling();
483
+ if (options.globalClean) return Underpost.secret.globalSecretClean();
484
+ if (options.rotate) return Underpost.secret[platform].rotate(options.recipient, options);
485
+ if (options.purge) return Underpost.secret[platform].purge(options.purge, options);
486
+ if (options.encrypt) return Underpost.secret[platform].encrypt(options.encrypt, options.namespace, options);
487
+ if (options.apply) return Underpost.secret[platform].apply(options.namespace, options);
488
+ if (options.createFromFile) return Underpost.secret[platform].createFromEnvFile(options.createFromFile);
489
+ if (options.createFromEnv) return Underpost.secret[platform].createFromContainerEnv();
490
+ if (options.list) return Underpost.secret[platform].list();
491
+ if (options.init) return Underpost.secret[platform].init();
431
492
  });
432
493
 
433
494
  program
@@ -537,19 +598,23 @@ program
537
598
 
538
599
  program
539
600
  .command('cron')
540
- .argument('[deploy-list]', 'A comma-separated list of deployment IDs (e.g., "default-a,default-b").')
601
+ .argument(
602
+ '[deploy-list]',
603
+ 'A comma-separated list of deployment IDs (e.g., "default-a,default-b"). In manifest modes its first entry is the manifest owner deploy-id.',
604
+ )
541
605
  .argument(
542
606
  '[job-list]',
543
- `A comma-separated list of job IDs. Options: ${Underpost.cron.getJobsIDs()}. Defaults to all available jobs.`,
607
+ `A comma-separated list of job IDs. Options: ${Underpost.cron.getJobsIDs()}. Defaults to all available jobs, and restricts which jobs are generated in manifest modes.`,
544
608
  )
545
609
  .option('--generate-k8s-cronjobs', 'Generates Kubernetes CronJob YAML manifests from cron configuration.')
546
- .option('--apply', 'Applies generated K8s CronJob manifests to the cluster via kubectl.')
610
+ .option('--apply', 'Generates and applies K8s CronJob manifests to the cluster via kubectl (never runs jobs).')
547
611
  .option(
548
- '--setup-start [deploy-id]',
549
- 'Updates deploy-id package.json start script and generates+applies its K8s CronJob manifests.',
612
+ '--setup-start',
613
+ 'Updates deploy-list package.json start script and generates+applies its K8s CronJob manifests.',
550
614
  )
551
615
  .option('--namespace <namespace>', 'Kubernetes namespace for the CronJob resources (default: "default").')
552
616
  .option('--image <image>', 'Custom container image for the CronJob pods.')
617
+ .option('--node-name <node-name>', 'Pins the CronJob pods to this node via a kubernetes.io/hostname nodeSelector.')
553
618
  .option('--git', 'Pass --git flag to cron job execution.')
554
619
  .option('--cmd <cmd>', 'Optional pre-script commands to run before cron execution.')
555
620
  .option('--dev', 'Use local ./ base path instead of global underpost installation.')
@@ -559,7 +624,7 @@ program
559
624
  .option('--dry-run', 'Preview cron jobs without executing them.')
560
625
  .option(
561
626
  '--create-job-now',
562
- 'After applying manifests, immediately create a Job from each CronJob (requires --apply).',
627
+ 'Creates a Job from each CronJob on the cluster now (implies manifest mode; combine with --apply to publish first).',
563
628
  )
564
629
  .description('Manages cron jobs: execute jobs directly or generate and apply K8s CronJob manifests.')
565
630
  .action(Underpost.cron.callback);
@@ -641,6 +706,108 @@ program
641
706
  .description('Manages SSH credentials and sessions for remote access to cluster nodes or services.')
642
707
  .action(Underpost.ssh.callback);
643
708
 
709
+ // `underpost wireguard` and `underpost haproxy` are two entrypoints onto one
710
+ // subsystem: the L3 transport and the gateway in front of it are configured from
711
+ // the same deploy state, so they share a single option set and a single action
712
+ // rather than drifting into two half-overlapping command surfaces.
713
+ const edgeCommandFactory = (name, description) =>
714
+ program
715
+ .command(name)
716
+ .option(
717
+ '--deploy-id <deploy-id>',
718
+ 'Deploy IDs whose conf.server.json/conf.instances.json define the routes. ' +
719
+ 'Accepts one id or a comma-separated list; defaults to "dd", every deploy in dd.router, ' +
720
+ 'because the edge holds one pair of map files for the whole cluster.',
721
+ )
722
+ .option('--interface <name>', 'WireGuard interface name (default: "wg0").')
723
+ .option('--wireguard-install', 'Installs the wireguard-tools, haproxy and iptables host packages.')
724
+ .option('--wireguard-setup', 'Generates keys, builds the interface config, and applies local network rules.')
725
+ .option('--server', 'Configures this node as the hub endpoint accepting inbound tunnel traffic.')
726
+ .option('--client', 'Configures this node as a spoke endpoint maintaining an outbound tunnel.')
727
+ .option('--port <port>', 'WireGuard UDP listening port (default: 51820).')
728
+ .option(
729
+ '--cidr <cidr>',
730
+ 'Hub interface address with prefix when used with --server (e.g. "10.0.0.1/24"); ' +
731
+ 'the overlay subnet a spoke routes back through the hub when used with --client (default: "10.0.0.0/24").',
732
+ )
733
+ .option('--peer-ip <ip>', 'Tunnel address of the target spoke. Required with --client and --peer-add.')
734
+ .option('--endpoint <host:port>', 'Hub host and port a spoke dials. Required with --client.')
735
+ .option('--public-key <key>', 'Hub public key with --client; spoke public key with --peer-add.')
736
+ .option('--peer-add <peer-id>', 'Registers a spoke and applies it to the running hub without a restart.')
737
+ .option('--peer-remove <peer-id>', 'Removes a spoke from the registry and from the running hub.')
738
+ .option('--allowed-ips <cidrs>', 'Comma-separated CIDRs routed to the spoke (e.g. "10.0.0.2/32,192.168.10.0/24").')
739
+ .option('--hosts <hosts>', 'Comma-separated hostnames bound to the spoke, overriding instance resolution.')
740
+ .option('--instances <instances>', 'Comma-separated conf.instances.json ids bound to the spoke.')
741
+ .option('--default', 'Marks the spoke as the fallback for hostnames that match no other binding.')
742
+ .option('--haproxy-setup', 'Installs HAProxy, publishes the current routes, and enables the daemon.')
743
+ .option('--haproxy-sync', 'Recompiles the SNI/Host maps from deploy config and hot-reloads HAProxy.')
744
+ .option(
745
+ '--status',
746
+ 'Prints the whole edge context without changing anything: role, interface, tunnel address, ' +
747
+ 'public key, daemon states, peers with their bindings and link health, and the resolved routing.',
748
+ )
749
+ .option(
750
+ '--build-conf',
751
+ 'Writes only engine-private/deploy/conf.wireguard.json and touches no host state. ' +
752
+ 'Combine with --wireguard-setup / --peer-add / --peer-remove to author the topology off-box; ' +
753
+ 'alone it normalizes and validates the existing registry.',
754
+ )
755
+ .option(
756
+ '--forward-proxy-server',
757
+ 'Ensures the hub HTTP/CONNECT forward proxy runs as the underpost-forward-proxy systemd service, ' +
758
+ 'bound to the tunnel address only (default port 1080), and returns. Authenticates every request with ' +
759
+ 'FORWARD_PROXY_API_KEY, so spokes can reach the internet through the VPS public address. Idempotent: ' +
760
+ 're-running converges on the one service and restarts it only when the unit actually changed.',
761
+ )
762
+ .option(
763
+ '--forward-proxy-server-host <host>',
764
+ 'Address the forward proxy binds, overriding the hub tunnel address from the registry.',
765
+ )
766
+ .option('--forward-proxy-server-port <port>', 'Port the forward proxy binds (default: 1080).')
767
+ .option(
768
+ '--ssh-forward-port <port>',
769
+ 'Publishes the default spoke SSH port on this public TCP port of the hub, so CI with no fixed ' +
770
+ 'address can reach the cluster node (e.g. 2222). "0" closes it. Stored in the registry.',
771
+ )
772
+ .option('--wireguard-start', 'Enables and starts wg-quick@<interface> and the QUIC forward.')
773
+ .option('--wireguard-stop', 'Tears down the interface and removes its transient packet rules.')
774
+ .option('--wireguard-reset', 'Removes generated configs and packet rules, keeping the key pair and registry.')
775
+ .option('--wireguard-reinstall', 'Full purge, package reinstall and re-key; every spoke must re-register.')
776
+ .option('--dry-run', 'Prints the files and commands the run would apply, without touching the host.')
777
+ .description(description)
778
+ .action(Underpost.wireguard.callback);
779
+
780
+ edgeCommandFactory(
781
+ 'wireguard',
782
+ 'Manages the WireGuard L3 hub-and-spoke transport and the HAProxy edge gateway in front of it.',
783
+ );
784
+ edgeCommandFactory(
785
+ 'haproxy',
786
+ 'Manages the HAProxy edge gateway over the WireGuard transport (same subsystem as `underpost wireguard`).',
787
+ );
788
+
789
+ program
790
+ .command('vultr')
791
+ .argument('[deploy-list]', 'A comma-separated list of deployment IDs, logged for attribution.')
792
+ .option('--instance-id <instance-id>', 'Vultr instance id to meter (default: VULTR_INSTANCE_ID).')
793
+ .option('--api-key <api-key>', 'Vultr API key (default: VULTR_API_KEY). Prefer the environment over this flag.')
794
+ .option(
795
+ '--threshold <ratio>',
796
+ 'Fraction of the plan quota that triggers the egress block; "0.80" and "80" are both accepted (default: 0.80).',
797
+ )
798
+ .option('--metric <metric>', '"total" (incoming + outgoing, default) or "outgoing" for egress alone.')
799
+ .option('--month <yyyy-mm>', 'Billing month to sum (default: the current UTC month).')
800
+ .option('--all-dates', 'Sum every daily bucket the API returns instead of scoping to one month.')
801
+ .option('--host <ip>', 'Edge VPS to block (default: VULTR_VPS_IP, then DEFAULT_SSH_HOST).')
802
+ .option('--user <user>', 'SSH user on the edge VPS (default: VULTR_SSH_USER, then DEFAULT_SSH_USER, then "root").')
803
+ .option('--key-path <path>', 'SSH private key (default: VULTR_SSH_KEY_PATH, then DEFAULT_SSH_KEY_PATH).')
804
+ .option('--port <port>', 'SSH port on the edge VPS (default: VULTR_SSH_PORT, then DEFAULT_SSH_PORT, then 22).')
805
+ .option('--force', 'Re-apply the egress block even if it was already applied for this cycle.')
806
+ .option('--auto-unblock', 'Restore egress automatically once consumption falls back under the threshold.')
807
+ .option('--dry-run', 'Reports the consumption and the action it would take, without touching the edge VPS.')
808
+ .description('Meters the edge VPS bandwidth against its Vultr plan quota and blocks egress before overage accrues.')
809
+ .action(Underpost.vultr.callback);
810
+
644
811
  program
645
812
  .command('run')
646
813
  .argument('<runner-id>', `The runner ID to run. Options: ${Underpost.run.RUNNERS}.`)
@@ -652,11 +819,24 @@ program
652
819
  .option('--replicas <replicas>', 'Sets a custom number of replicas for deployment.')
653
820
  .option('--pod-name <pod-name>', 'Optional: Specifies the pod name for execution.')
654
821
  .option('--node-name <node-name>', 'Optional: Specifies the node name for execution.')
822
+ .option(
823
+ '--ingress-node <node-name>',
824
+ 'Dedicated node for the host-network underpost-ingress listener. Workload --node-name never relocates it.',
825
+ )
655
826
  .option(
656
827
  '--ssh-key-path <path>',
657
828
  'Optional: Private key path for node SSH operations, forwarded to volume shipping over SSH. Defaults to engine-private/deploy/id_rsa.',
658
829
  )
659
830
  .option('--port <port>', 'Optional: Specifies the port for execution.')
831
+ .option(
832
+ '--expose-container-ports <ports>',
833
+ 'Comma-separated Service/container ports; multiple matched resources consume values by resource index.',
834
+ )
835
+ .option(
836
+ '--expose-host-ports <ports>',
837
+ 'Comma-separated host ports paired with container ports by resource/port index.',
838
+ )
839
+ .option('--local-proxy', 'Starts the development path proxy after the expose runner creates its port-forwards.')
660
840
  .option('--etc-hosts', 'Enables etc-hosts context for the runner execution.')
661
841
  .option('--volume-host-path <volume-host-path>', 'Optional: Specifies the volume host path for test execution.')
662
842
  .option('--volume-mount-path <volume-mount-path>', 'Optional: Specifies the volume mount path for test execution.')
@@ -692,19 +872,25 @@ program
692
872
  '--resource-template-id <resource-template-id >',
693
873
  'Specifies a resource template ID for the runner execution.',
694
874
  )
695
- .option('--expose', 'Enables service exposure for the runner execution.')
875
+ .option(
876
+ '--expose',
877
+ 'Enables exposure-only behavior in compatible runners; the expose runner itself does not require this flag.',
878
+ )
696
879
  .option('--conf-server-path <conf-server-path>', 'Sets a custom configuration server path.')
697
880
  .option('--underpost-root <underpost-root>', 'Sets a custom Underpost root path.')
698
881
  .option('--cmd-cron-jobs <cmd-cron-jobs>', 'Pre-script commands to run before cron job execution.')
699
882
  .option(
700
883
  '--deploy-id-cron-jobs <deploy-id-cron-jobs>',
701
- 'Specifies deployment IDs to synchronize cron jobs with during execution.',
884
+ 'Cron deploy-id to set up during sync; defaults to dd.cron, "none" skips cron setup entirely.',
702
885
  )
703
886
  .option('--timezone <timezone>', 'Sets the timezone for the runner execution.')
704
887
  .option('--kubeadm', 'Sets the kubeadm cluster context for the runner execution.')
705
888
  .option('--k3s', 'Sets the k3s cluster context for the runner execution.')
706
889
  .option('--kind', 'Sets the kind cluster context for the runner execution.')
707
- .option('--traffic <traffic>', 'Blue/green traffic colour to bake into generated manifests (default: blue).')
890
+ .option(
891
+ '--traffic <traffic>',
892
+ 'Blue/green traffic colour to bake into generated manifests (default: blue). `stop` accepts a comma list, e.g. blue,green.',
893
+ )
708
894
  .option('--git-clean', 'Runs git clean on volume mount paths before copying.')
709
895
  .option('--deploy-id <deploy-id>', 'Sets deploy id context for the runner execution.')
710
896
  .option('--user <user>', 'Sets user context for the runner execution.')
@@ -718,6 +904,21 @@ program
718
904
  .option('--timeout-idle <duration>', 'Sets HTTPProxy per-route idle timeout (e.g., "10s", "infinity").')
719
905
  .option('--retry-count <count>', 'Sets HTTPProxy per-route retry count (e.g., 3).')
720
906
  .option('--retry-per-try-timeout <duration>', 'Sets HTTPProxy retry per-try timeout (e.g., "150ms").')
907
+ .option(
908
+ '--gateway-api',
909
+ 'Routes through the Gateway API stack (Gateway + HTTPRoute) instead of the Contour HTTPProxy. ' +
910
+ 'Both manifest sets are always generated; this selects which one is applied.',
911
+ )
912
+ .option(
913
+ '--disable-gateway-api',
914
+ 'Falls back to the Contour HTTPProxy stack in runners where the Gateway API is the default (cluster).',
915
+ )
916
+ .option('--gateway-class <name>', 'GatewayClass name for generated Gateway manifests (default "eg").')
917
+ .option(
918
+ '--disable-http3',
919
+ 'Omits the QUIC/HTTP3 listener config and the Alt-Svc advertisement from Gateway API manifests.',
920
+ )
921
+ .option('--quic-port <port>', 'UDP port advertised for QUIC/HTTP3 in generated Gateway API manifests (default 443).')
721
922
  .option('--disable-private-conf-update', 'Disables updates to private configuration during execution.')
722
923
  .option('--logs', 'Streams logs during the runner execution.')
723
924
  .option('--monitor-status <status>', 'Sets the status to monitor for pod/resource (default: "Running").')
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);
package/src/cli/lxd.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  */
7
7
 
8
- import { getNpmRootPath } from '../server/conf.js';
8
+ import { getNpmRootPath } from '../server/environment.js';
9
9
  import { pbcopy, shellExec } from '../server/process.js';
10
10
  import walk from 'ignore-walk';
11
11
  import fs from 'fs-extra';
@@ -8,10 +8,10 @@ import {
8
8
  loadReplicas,
9
9
  pathPortAssignmentFactory,
10
10
  loadConfServerJson,
11
- loadCronDeployEnv,
12
11
  etcHostFactory,
13
12
  deployRangePortFactory,
14
13
  } from '../server/conf.js';
14
+ import { loadCronDeployEnv } from '../server/cron.js';
15
15
  import { loggerFactory } from '../server/logger.js';
16
16
  import { timer } from '../client/components/core/CommonJs.js';
17
17
  import {
@@ -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';