underpost 3.2.21 → 3.2.28

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 (55) hide show
  1. package/.github/workflows/ghpkg.ci.yml +1 -1
  2. package/.github/workflows/gitlab.ci.yml +1 -1
  3. package/.github/workflows/npmpkg.ci.yml +1 -1
  4. package/.github/workflows/publish.ci.yml +2 -2
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  7. package/CHANGELOG.md +178 -1
  8. package/CLI-HELP.md +58 -2
  9. package/README.md +3 -2
  10. package/baremetal/commission-workflows.json +1 -0
  11. package/bin/build.js +13 -1
  12. package/docker-compose.yml +224 -0
  13. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  15. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  16. package/package.json +27 -15
  17. package/scripts/kubeadm-node-setup.sh +317 -0
  18. package/scripts/rocky-kickstart.sh +877 -185
  19. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  20. package/scripts/test-monitor.sh +242 -78
  21. package/src/cli/baremetal.js +717 -16
  22. package/src/cli/cluster.js +3 -1
  23. package/src/cli/deploy.js +11 -10
  24. package/src/cli/docker-compose.js +591 -0
  25. package/src/cli/fs.js +35 -11
  26. package/src/cli/index.js +83 -0
  27. package/src/cli/kickstart.js +142 -20
  28. package/src/cli/monitor.js +114 -29
  29. package/src/cli/repository.js +75 -11
  30. package/src/cli/run.js +275 -6
  31. package/src/cli/ssh.js +190 -0
  32. package/src/cli/static.js +2 -2
  33. package/src/client/components/core/PanelForm.js +44 -44
  34. package/src/{server → client-builder}/client-build-docs.js +15 -5
  35. package/src/{server → client-builder}/client-build-live.js +3 -3
  36. package/src/{server → client-builder}/client-build.js +25 -22
  37. package/src/{server → client-builder}/client-dev-server.js +3 -3
  38. package/src/{server → client-builder}/client-icons.js +2 -2
  39. package/src/{server → client-builder}/ssr.js +5 -5
  40. package/src/client.build.js +1 -1
  41. package/src/client.dev.js +1 -1
  42. package/src/index.js +12 -1
  43. package/src/mailer/EmailRender.js +1 -1
  44. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  45. package/src/runtime/express/Express.js +2 -2
  46. package/src/runtime/nginx/Nginx.js +250 -0
  47. package/src/server/catalog.js +7 -14
  48. package/src/server/conf.js +61 -12
  49. package/src/server/start.js +17 -5
  50. package/src/server.js +1 -1
  51. package/test/deploy-monitor.test.js +33 -5
  52. package/typedoc.json +3 -1
  53. package/src/server/ipfs-client.js +0 -597
  54. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  55. /package/src/{server → client-builder}/client-formatted.js +0 -0
package/src/cli/fs.js CHANGED
@@ -95,6 +95,17 @@ class UnderpostFileStorage {
95
95
  ) {
96
96
  const { storage, storageConf } = Underpost.fs.getStorageConf(options);
97
97
 
98
+ // ── Single-file handling: when path is a file (not a directory), use parent dir
99
+ // as the git working directory and process just that one file. ──────────────
100
+ let isSingleFile = false;
101
+ let parentDir = path;
102
+ let singleFileName = '';
103
+ if (fs.existsSync(path) && !fs.statSync(path).isDirectory()) {
104
+ isSingleFile = true;
105
+ parentDir = dir.dirname(path);
106
+ singleFileName = path.split('/').pop();
107
+ }
108
+
98
109
  // In recursive remove mode, delete every tracked storage key under the requested path,
99
110
  // even when local files/directories are already missing.
100
111
  if (options.rm === true) {
@@ -137,10 +148,15 @@ class UnderpostFileStorage {
137
148
  return;
138
149
  }
139
150
 
140
- const deleteFiles = options.pull === true ? [] : Underpost.repo.getDeleteFiles(path);
151
+ // For single files, run getDeleteFiles against the parent directory to avoid
152
+ // trying to `cd` into a file.
153
+ const gitContextPath = isSingleFile ? parentDir : path;
154
+ const deleteFiles = options.pull === true ? [] : Underpost.repo.getDeleteFiles(gitContextPath);
155
+
156
+ // When processing a single file, only consider it for deletion
141
157
  for (const relativePath of deleteFiles) {
142
- const _path = path + '/' + relativePath;
143
- if (_path in storage) {
158
+ const _path = isSingleFile ? (relativePath === singleFileName ? path : null) : path + '/' + relativePath;
159
+ if (_path && _path in storage) {
144
160
  await Underpost.fs.delete(_path);
145
161
  delete storage[_path];
146
162
  }
@@ -157,17 +173,25 @@ class UnderpostFileStorage {
157
173
  // Only run git init/commit when the caller explicitly requests git tracking (--git flag).
158
174
  // For bundle pulls into ./build the git step is unwanted and would error on a non-repo path.
159
175
  if (options.git === true) {
160
- Underpost.repo.initLocalRepo({ path });
161
- shellExec(`cd ${path} && git add . && git commit -m "Base pull state"`, {
176
+ Underpost.repo.initLocalRepo({ path: gitContextPath });
177
+ shellExec(`cd ${gitContextPath} && git add . && git commit -m "Base pull state"`, {
162
178
  silentOnError: true,
163
179
  });
164
180
  }
165
181
  } else {
166
- const files =
167
- options.git === true ? Underpost.repo.getChangedFiles(path) : await fs.readdir(path, { recursive: true });
182
+ let files;
183
+ if (isSingleFile) {
184
+ // Single file: treat the file itself as the sole item to process
185
+ files = [singleFileName];
186
+ } else {
187
+ files =
188
+ options.git === true
189
+ ? Underpost.repo.getChangedFiles(gitContextPath)
190
+ : await fs.readdir(path, { recursive: true });
191
+ }
168
192
  for (const relativePath of files) {
169
- const _path = path + '/' + relativePath;
170
- if (fs.statSync(_path).isDirectory()) {
193
+ const _path = isSingleFile ? path : path + '/' + relativePath;
194
+ if (fs.existsSync(_path) && fs.statSync(_path).isDirectory()) {
171
195
  if (options.pull === true && !fs.existsSync(_path)) fs.mkdirSync(_path, { recursive: true });
172
196
  continue;
173
197
  } else if (!(_path in storage) || options.force === true) {
@@ -178,8 +202,8 @@ class UnderpostFileStorage {
178
202
  }
179
203
  Underpost.fs.writeStorageConf(storage, storageConf);
180
204
  if (options.git === true) {
181
- shellExec(`cd ${path} && git add .`);
182
- shellExec(`underpost cmt ${path} feat`, {
205
+ shellExec(`cd ${gitContextPath} && git add .`);
206
+ shellExec(`underpost cmt ${gitContextPath} feat`, {
183
207
  silentOnError: true,
184
208
  silent: true,
185
209
  disableLog: true,
package/src/cli/index.js CHANGED
@@ -70,6 +70,10 @@ program
70
70
  '--pull-bundle',
71
71
  'Downloads the pre-built client bundle from Cloudinary via pull-bundle before starting. Use together with --skip-full-build to skip the local build entirely.',
72
72
  )
73
+ .option(
74
+ '--private-test-repo',
75
+ 'During --build, clone the private test source repo (engine-test-<id>) instead of the production engine-<id> repo.',
76
+ )
73
77
  .action(Underpost.start.callback)
74
78
  .description('Initiates application servers, build pipelines, or other defined services based on the deployment ID.');
75
79
 
@@ -727,9 +731,44 @@ program
727
731
  'Explicitly download the pre-built client bundle from Cloudinary inside the container (supported by: sync, template-deploy). Use together with --skip-full-build.',
728
732
  )
729
733
  .option('--remove', 'Remove/teardown resources')
734
+ .option(
735
+ '--test',
736
+ 'Enables test/generic-purpose mode for the runner (e.g. use self-signed TLS instead of cert-manager).',
737
+ )
730
738
  .description('Runs specified scripts using various runners.')
731
739
  .action(Underpost.run.callback);
732
740
 
741
+ program
742
+ .command('docker-compose')
743
+ .argument('[target]', 'Optional service name for --logs, --shell, --restart, or --build.')
744
+ .option('--install', 'Install Docker Engine and the Compose v2 plugin on RHEL/Rocky hosts.')
745
+ .option(
746
+ '--reset',
747
+ 'Comprehensive teardown (equivalent to cluster --reset): removes all stack containers, the network, named volumes (destroys data), orphans, and generated artifacts.',
748
+ )
749
+ .option('--force', 'Force reinstall (--install), remove volumes (--down), or also drop the env-file (--reset).')
750
+ .option(
751
+ '--deploy-id <deploy-id>',
752
+ "Deployment to run as the app container (default: dd-default). 'dd-default' self-bootstraps a fresh engine; any other id runs the standard 'underpost start' command (mirrors src/cli/deploy.js).",
753
+ )
754
+ .option('--env <env>', 'Deployment environment for non-default deploy ids (default: development).')
755
+ .option('--generate', 'Render dynamic supporting files (nginx router config, env-file, app-command override).')
756
+ .option('--up', 'Start the full stack detached (regenerates config first).')
757
+ .option('--down', 'Stop and remove containers (and orphans).')
758
+ .option('--volumes', 'With --down, also remove named volumes (destroys persisted data).')
759
+ .option('--restart', 'Restart services (optionally a single [target]).')
760
+ .option('--build', 'With --up rebuild images; alone, rebuilds images with --no-cache.')
761
+ .option('--pull', 'Pull upstream images for all services.')
762
+ .option('--logs', 'Follow logs for all services (optionally a single [target]).')
763
+ .option('--status', 'Show a formatted status table of services.')
764
+ .option('--shell', 'Open an interactive shell in [target] (default: app).')
765
+ .option('--exec <subcommand>', 'General-purpose passthrough docker compose subcommand.')
766
+ .option('--compose-file <path>', 'Path to the compose file (default: docker-compose.yml).')
767
+ .option('--env-file <path>', 'Path to the compose env-file (default: docker/compose.env).')
768
+ .option('--nginx-conf <path>', 'Path to the generated nginx config (default: docker/nginx/default.conf).')
769
+ .description('General-purpose Docker Compose development pipeline (mirrors the Kubernetes dev stack).')
770
+ .action(Underpost.dockerCompose.callback);
771
+
733
772
  program
734
773
  .command('lxd')
735
774
  .argument(
@@ -848,6 +887,42 @@ program
848
887
  .option('--remove-machines <system-ids>', 'Removes baremetal machines by comma-separated system IDs, or use "all"')
849
888
  .option('--clear-discovered', 'Clears all discovered baremetal machines from the database.')
850
889
  .option('--commission', 'Init workflow for commissioning a physical machine.')
890
+ .option(
891
+ '--install-disk [device]',
892
+ 'Explicit target install disk for Rocky deployment (e.g. /dev/nvme0n1). Omit or leave empty to auto-detect the internal disk.',
893
+ )
894
+ .option(
895
+ '--no-auto-install',
896
+ 'Disables the ephemeral runtime AUTO_INSTALL fallback (controller must trigger install).',
897
+ )
898
+ .option('--no-remote-install', 'Skips the controller-side remote install orchestration over SSH.')
899
+ .option(
900
+ '--worker',
901
+ 'Post-install infra role: join the deployed node as a Kubernetes worker (requires --control <ip>). Without this flag the node is set up as a control-plane.',
902
+ )
903
+ .option('--control <ip>', 'Control-plane IP the worker node joins (used with --worker for kubeadm infra setup).')
904
+ .option(
905
+ '--ssh-key-dir <dir>',
906
+ 'Directory holding the SSH key pair used for commissioning/orchestration (expects <dir>/id_rsa and <dir>/id_rsa.pub). Overrides the workflow "sshKeyDir"; defaults to engine-private/deploy. Supports a leading ~.',
907
+ )
908
+ .option(
909
+ '--deploy-id <deploy-id>',
910
+ 'Deployment ID whose user key pair is used for SSH (key from engine-private/conf/<deploy-id>/users/<user>/id_rsa). Same user↔deployId↔key convention as the ssh command.',
911
+ )
912
+ .option(
913
+ '--user <user>',
914
+ 'SSH user paired with --deploy-id for key resolution and the login user on an existing control-plane (defaults to root). Mirrors the ssh command --user.',
915
+ )
916
+ .option(
917
+ '--engine-repo <url>',
918
+ 'Custom engine repo cloned + normalized to /home/dd/engine on the node (default: <GITHUB_USERNAME>/engine).',
919
+ )
920
+ .option('--engine-branch <branch>', 'Branch of the engine repo to clone on the node.')
921
+ .option(
922
+ '--engine-private-repo <url>',
923
+ 'Custom private repo cloned + normalized to /home/dd/engine/engine-private on the node (default: <GITHUB_USERNAME>/engine-<id>-private).',
924
+ )
925
+ .option('--engine-private-branch <branch>', 'Branch of the engine-private repo to clone on the node.')
851
926
  .option(
852
927
  '--bootstrap-http-server-run',
853
928
  'Runs a temporary bootstrap HTTP server for generic purposes such as serving iPXE scripts or ISO images during commissioning.',
@@ -888,6 +963,14 @@ program
888
963
  )
889
964
  .option('--dev', 'Sets the development context environment for baremetal operations.')
890
965
  .option('--ls', 'Lists available boot resources and machines.')
966
+ .option(
967
+ '--resume-infra-setup',
968
+ 'Skip commissioning, OS install, and all bootstrapping; resume only the SSH-based infra setup (kubeadm join/init) on a node that already has the OS installed and is reachable via SSH.',
969
+ )
970
+ .option(
971
+ '--resume-join',
972
+ 'Skip everything except the kubeadm join command. Assumes engine, Node.js, CRI-O, kubelet, and kubeadm are already installed. Only retrieves a fresh join token from the control-plane and runs kubeadm join.',
973
+ )
891
974
  .description(
892
975
  'Manages baremetal server operations, including installation, database setup, commissioning, and user management.',
893
976
  )
@@ -43,46 +43,168 @@ class UnderpostKickStart {
43
43
 
44
44
  /**
45
45
  * @method kickstartPreVariables
46
- * @description Generates the variable assignments block for the %pre script.
47
- * @param {object} options
48
- * @param {string} [options.rootPassword]
49
- * @param {string} [options.authorizedKeys]
50
- * @param {string} [options.adminUsername]
46
+ * @description Generates the shell variable-assignment block injected at the top of the
47
+ * kickstart `%pre` script. These variables drive both the ephemeral commissioning runtime
48
+ * and the unattended disk installer in `scripts/rocky-kickstart.sh`. Passwords are emitted
49
+ * base64-encoded (`*_B64`) and decoded by an in-script `ks_b64d` helper; all other values
50
+ * are single-quote escaped so arbitrary content survives the shell/heredoc layers.
51
+ * @param {object} options - Variable values to bake into the `%pre` block.
52
+ * @param {string} [options.rootPassword=''] - root console password (base64-encoded).
53
+ * @param {string} [options.authorizedKeys=''] - SSH public key(s) installed as authorized_keys.
54
+ * @param {string} [options.adminUsername=''] - Primary (MAAS) admin user (defaults to MAAS_ADMIN_USERNAME).
55
+ * @param {string} [options.adminPassword=''] - Primary admin console password (defaults to rootPassword).
56
+ * @param {string} [options.deployUsername=''] - Optional second (deploy) admin user (e.g. admin).
57
+ * @param {string} [options.deployPassword=''] - Deploy user console password (defaults to rootPassword).
58
+ * @param {string} [options.netIp=''] - Static IPv4 for the deployed OS; empty = DHCP.
59
+ * @param {number} [options.netPrefix=24] - IPv4 prefix length for the static address.
60
+ * @param {string} [options.netGateway=''] - Default gateway for the deployed OS.
61
+ * @param {string} [options.netDns=''] - DNS server for the deployed OS.
62
+ * @param {string} [options.timezone=''] - IANA timezone configured in the deployed OS (e.g. America/Santiago).
63
+ * @param {string} [options.keyboardLayout=''] - Console/X11 keyboard layout for the deployed OS (e.g. es).
64
+ * @param {string} [options.chronyConfPath='/etc/chrony.conf'] - chrony config path written in the deployed OS.
65
+ * @param {string} [options.bootstrapUrl=''] - Base URL of the bootstrap HTTP server for this host (status POSTs).
66
+ * @param {string} [options.workflowId=''] - Workflow identifier reported in status metadata.
67
+ * @param {string} [options.systemId=''] - MAAS system id reported in status metadata (if known).
68
+ * @param {string} [options.targetHostname=''] - Hostname reported in status metadata and set on the deployed OS.
69
+ * @param {number} [options.sshPort=22] - SSH port the ephemeral runtime listens on.
70
+ * @param {string} [options.installDiskHint=''] - Optional explicit target disk (e.g. /dev/nvme0n1); empty = auto-detect.
71
+ * @param {boolean} [options.autoInstall=true] - When true, the ephemeral runtime self-installs after a fallback timeout if no remote trigger arrives.
51
72
  * @memberof UnderpostKickStart
52
- * @returns {string}
73
+ * @returns {string} Newline-joined bash variable assignments.
53
74
  */
54
- kickstartPreVariables: ({ rootPassword = '', authorizedKeys = '', adminUsername = '' }) => {
75
+ kickstartPreVariables: ({
76
+ rootPassword = '',
77
+ authorizedKeys = '',
78
+ adminUsername = '',
79
+ adminPassword = '',
80
+ deployUsername = '',
81
+ deployPassword = '',
82
+ netIp = '',
83
+ netPrefix = 24,
84
+ netGateway = '',
85
+ netDns = '',
86
+ timezone = '',
87
+ keyboardLayout = '',
88
+ chronyConfPath = '/etc/chrony.conf',
89
+ bootstrapUrl = '',
90
+ workflowId = '',
91
+ systemId = '',
92
+ targetHostname = '',
93
+ sshPort = 22,
94
+ installDiskHint = '',
95
+ autoInstall = true,
96
+ }) => {
55
97
  const sanitizedKeys = (authorizedKeys || '').trim();
98
+ // Passwords are passed base64-encoded so arbitrary characters (quotes,
99
+ // spaces, $, etc.) survive every shell/heredoc layer intact. base64 output
100
+ // never contains single quotes. Decoding tries `base64` then falls back to
101
+ // python3 so a minimal Anaconda environment can never yield empty passwords.
102
+ const b64 = (v) => Buffer.from(String(v || ''), 'utf8').toString('base64');
103
+ const sq = (v) => `'${String(v || '').replace(/'/g, "'\\''")}'`;
56
104
  return [
57
- `ROOT_PASS='${rootPassword || ''}'`,
58
- `AUTHORIZED_KEYS='${sanitizedKeys}'`,
59
- `ADMIN_USER='${adminUsername || process.env.MAAS_ADMIN_USERNAME || 'maas'}'`,
105
+ `ks_b64d() { printf %s "$1" | base64 -d 2>/dev/null || printf %s "$1" | python3 -c 'import sys,base64;sys.stdout.buffer.write(base64.b64decode(sys.stdin.read().strip()))' 2>/dev/null; }`,
106
+ `ROOT_PASS_B64='${b64(rootPassword)}'`,
107
+ `ADMIN_PASS_B64='${b64(adminPassword || rootPassword)}'`,
108
+ `DEPLOY_PASS_B64='${b64(deployPassword)}'`,
109
+ `ROOT_PASS="$(ks_b64d "$ROOT_PASS_B64")"`,
110
+ `ADMIN_PASS="$(ks_b64d "$ADMIN_PASS_B64")"`,
111
+ `DEPLOY_PASS="$(ks_b64d "$DEPLOY_PASS_B64")"`,
112
+ `AUTHORIZED_KEYS=${sq(sanitizedKeys)}`,
113
+ `ADMIN_USER=${sq(adminUsername || process.env.MAAS_ADMIN_USERNAME || 'maas')}`,
114
+ `DEPLOY_USER=${sq(deployUsername)}`,
115
+ `NET_IP=${sq(netIp)}`,
116
+ `NET_PREFIX='${parseInt(netPrefix, 10) || 24}'`,
117
+ `NET_GATEWAY=${sq(netGateway)}`,
118
+ `NET_DNS=${sq(netDns)}`,
119
+ `TIMEZONE=${sq(timezone)}`,
120
+ `KEYBOARD_LAYOUT=${sq(keyboardLayout)}`,
121
+ `CHRONY_CONF_PATH=${sq(chronyConfPath || '/etc/chrony.conf')}`,
122
+ `BOOTSTRAP_URL=${sq(bootstrapUrl)}`,
123
+ `WORKFLOW_ID=${sq(workflowId)}`,
124
+ `SYSTEM_ID=${sq(systemId)}`,
125
+ `TARGET_HOSTNAME=${sq(targetHostname)}`,
126
+ `SSH_PORT='${sshPort || 22}'`,
127
+ `INSTALL_DISK_HINT=${sq(installDiskHint)}`,
128
+ `AUTO_INSTALL='${autoInstall ? '1' : '0'}'`,
60
129
  ].join('\n');
61
130
  },
62
131
 
63
132
  /**
64
133
  * @method kickstartFactory
65
134
  * @description Generates a complete kickstart configuration by combining the header,
66
- * variable assignments, and the rocky-kickstart.sh script body.
67
- * @param {object} options
68
- * @param {string} [options.lang='en_US.UTF-8']
69
- * @param {string} [options.keyboard='us']
70
- * @param {string} [options.timezone='America/New_York']
71
- * @param {string} [options.rootPassword]
72
- * @param {string} [options.authorizedKeys]
135
+ * the `%pre` variable-assignment block, and the `scripts/rocky-kickstart.sh` body.
136
+ * @param {object} options - Kickstart generation options.
137
+ * @param {string} [options.lang='en_US.UTF-8'] - System language for the ephemeral runtime.
138
+ * @param {string} [options.keyboard='us'] - Keyboard layout for the ephemeral runtime AND the deployed OS.
139
+ * @param {string} [options.timezone='America/New_York'] - Timezone for the ephemeral runtime AND the deployed OS.
140
+ * @param {string} [options.chronyConfPath='/etc/chrony.conf'] - chrony config path written in the deployed OS.
141
+ * @param {string} [options.rootPassword=process.env.MAAS_ADMIN_PASS] - root console password.
142
+ * @param {string} [options.adminUsername=''] - Primary (MAAS) admin user created in the deployed OS.
143
+ * @param {string} [options.adminPassword=''] - Primary admin console password (defaults to rootPassword).
144
+ * @param {string} [options.deployUsername=''] - Optional second (deploy) admin user (e.g. admin).
145
+ * @param {string} [options.deployPassword=''] - Deploy user console password (defaults to rootPassword).
146
+ * @param {string} [options.netIp=''] - Static IPv4 for the deployed OS; empty = DHCP.
147
+ * @param {number} [options.netPrefix=24] - IPv4 prefix length for the static address.
148
+ * @param {string} [options.netGateway=''] - Default gateway for the deployed OS.
149
+ * @param {string} [options.netDns=''] - DNS server for the deployed OS.
150
+ * @param {string} [options.authorizedKeys=''] - SSH public key(s) installed as authorized_keys.
151
+ * @param {string} [options.bootstrapUrl=''] - Base URL of the bootstrap HTTP server (status POSTs).
152
+ * @param {string} [options.workflowId=''] - Workflow identifier reported in status metadata.
153
+ * @param {string} [options.systemId=''] - MAAS system id reported in status metadata (if known).
154
+ * @param {string} [options.targetHostname=''] - Hostname reported in status metadata and set on the deployed OS.
155
+ * @param {number} [options.sshPort=22] - SSH port the ephemeral runtime listens on.
156
+ * @param {string} [options.installDiskHint=''] - Optional explicit target disk; empty = auto-detect.
157
+ * @param {boolean} [options.autoInstall=true] - When true, the runtime self-installs after a fallback timeout.
73
158
  * @memberof UnderpostKickStart
74
- * @returns {string}
159
+ * @returns {string} The full kickstart (ks.cfg) source.
75
160
  */
76
161
  kickstartFactory: ({
77
162
  lang = 'en_US.UTF-8',
78
163
  keyboard = 'us',
79
164
  timezone = 'America/New_York',
165
+ chronyConfPath = '/etc/chrony.conf',
80
166
  rootPassword = process.env.MAAS_ADMIN_PASS,
167
+ adminUsername = '',
168
+ adminPassword = '',
169
+ deployUsername = '',
170
+ deployPassword = '',
171
+ netIp = '',
172
+ netPrefix = 24,
173
+ netGateway = '',
174
+ netDns = '',
81
175
  authorizedKeys = '',
176
+ bootstrapUrl = '',
177
+ workflowId = '',
178
+ systemId = '',
179
+ targetHostname = '',
180
+ sshPort = 22,
181
+ installDiskHint = '',
182
+ autoInstall = true,
82
183
  }) => {
83
- const adminUsername = process.env.MAAS_ADMIN_USERNAME || 'maas';
184
+ const resolvedAdminUsername = adminUsername || process.env.MAAS_ADMIN_USERNAME || 'maas';
84
185
  const header = UnderpostKickStart.API.kickstartHeader({ lang, keyboard, timezone, rootPassword });
85
- const variables = UnderpostKickStart.API.kickstartPreVariables({ rootPassword, authorizedKeys, adminUsername });
186
+ const variables = UnderpostKickStart.API.kickstartPreVariables({
187
+ rootPassword,
188
+ authorizedKeys,
189
+ adminUsername: resolvedAdminUsername,
190
+ adminPassword,
191
+ deployUsername,
192
+ deployPassword,
193
+ netIp,
194
+ netPrefix,
195
+ netGateway,
196
+ netDns,
197
+ timezone,
198
+ keyboardLayout: keyboard,
199
+ chronyConfPath,
200
+ bootstrapUrl,
201
+ workflowId,
202
+ systemId,
203
+ targetHostname,
204
+ sshPort,
205
+ installDiskHint,
206
+ autoInstall,
207
+ });
86
208
 
87
209
  const scriptPath = path.resolve(__dirname, '../../scripts/rocky-kickstart.sh');
88
210
  const scriptBody = fs.readFileSync(scriptPath, 'utf8');
@@ -378,34 +378,77 @@ class UnderpostMonitor {
378
378
  return deployStatusPort(deployId, env) ?? 3000;
379
379
  },
380
380
  /**
381
- * Reads Phase-2 runtime readiness from a single pod over HTTP, tunneling
382
- * through `kubectl port-forward` to the in-pod internal status endpoint.
381
+ * Reads Phase-2 runtime status from a single pod using the selected transport.
383
382
  *
384
- * Transport failures (port-forward down, connection refused, HTTP error)
385
- * are reported as `{ ok: false }` and must never be interpreted as success
386
- * by callers they are retried, not promoted. A reachable endpoint returns
387
- * `{ ok: true, status }` with the normalized runtime contract value.
383
+ * - `exec` (default): `kubectl exec underpost config get container-status`
384
+ * reads the env-file value. Synchronous, no background process required
385
+ * for custom instances (cyberia-server/client) and the safe choice for
386
+ * CI/SSH. See `Deploy custom instance to K8S.md`.
387
+ * - `http`: port-forward to the in-pod `/_internal/status` endpoint served
388
+ * by the `underpost start` launcher (dd-* runtime deploys). Opt-in.
389
+ *
390
+ * Transport failures are reported as `{ ok: false }` and must never be read
391
+ * as success — they are retried, not promoted.
388
392
  *
389
393
  * @param {string} podName
390
394
  * @param {string} namespace
391
395
  * @param {number} internalPort
396
+ * @param {('http'|'exec')} [transport='exec']
392
397
  * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
393
398
  * @memberof UnderpostMonitor
394
399
  */
395
- async readRuntimeStatus(podName, namespace, internalPort) {
396
- // The local side of the tunnel MUST be an ephemeral free port: pinning it
397
- // to internalPort collides with any host-local service on that number
398
- // (e.g. a dev runtime on the same machine as the cluster), which makes
399
- // port-forward fail to bind and every read return a false transport error.
400
+ async readRuntimeStatus(podName, namespace, internalPort, transport = 'exec') {
401
+ return transport === 'exec'
402
+ ? Underpost.monitor.readRuntimeStatusViaExec(podName, namespace)
403
+ : Underpost.monitor.readRuntimeStatusViaHttp(podName, namespace, internalPort);
404
+ },
405
+ /**
406
+ * Phase-2 read over `kubectl exec` (env-file transport). Works for any pod
407
+ * whose image bakes the underpost CLI — notably custom instances that stamp
408
+ * `container-status` from `lifecycle.postStart`/`preStop` hooks.
409
+ * @param {string} podName
410
+ * @param {string} namespace
411
+ * @returns {{ok: boolean, status?: (string|null), transportError?: string}}
412
+ * @memberof UnderpostMonitor
413
+ */
414
+ readRuntimeStatusViaExec(podName, namespace) {
415
+ try {
416
+ const raw = shellExec(
417
+ `sudo kubectl exec ${podName} -n ${namespace} -- sh -c 'underpost config get container-status --plain'`,
418
+ { silent: true, disableLog: true, stdout: true, silentOnError: true },
419
+ );
420
+ const status = normalizeContainerStatus(raw ? raw.toString().trim() : '');
421
+ return status === undefined ? { ok: false, transportError: 'empty_status' } : { ok: true, status };
422
+ } catch (error) {
423
+ return { ok: false, transportError: error?.code || error?.message || 'exec_failed' };
424
+ }
425
+ },
426
+ /**
427
+ * Phase-2 read over `kubectl port-forward` + HTTP `/_internal/status`.
428
+ *
429
+ * The local side of the tunnel MUST be an ephemeral free port: pinning it to
430
+ * internalPort collides with any host-local service on that number (e.g. a
431
+ * dev runtime on the same machine as the cluster), making port-forward fail
432
+ * to bind and every read return a false transport error.
433
+ *
434
+ * @param {string} podName
435
+ * @param {string} namespace
436
+ * @param {number} internalPort
437
+ * @returns {Promise<{ok: boolean, status?: (string|null), transportError?: string}>}
438
+ * @memberof UnderpostMonitor
439
+ */
440
+ async readRuntimeStatusViaHttp(podName, namespace, internalPort) {
400
441
  const override = parseInt(process.env.UNDERPOST_PF_LOCAL_PORT);
401
442
  const localPort = Number.isNaN(override) ? await Underpost.monitor.findFreePort() : override;
402
443
  const url = `http://127.0.0.1:${localPort}${INTERNAL_STATUS_PATH}`;
403
444
  let portForward;
404
445
  try {
405
- // `exec` collapses the shell so the tracked child PID is the
406
- // sudo/kubectl process, letting the SIGTERM teardown reach the tunnel.
446
+ // `exec` makes the tracked child the sudo/kubectl process (so kill
447
+ // reaches it); stdio is redirected to /dev/null so the tunnel never
448
+ // inherits — and therefore never holds open — a CI/SSH session's pipes,
449
+ // which would hang the job after a successful deploy.
407
450
  portForward = shellExec(
408
- `exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace}`,
451
+ `exec sudo kubectl port-forward pod/${podName} ${localPort}:${internalPort} -n ${namespace} </dev/null >/dev/null 2>&1`,
409
452
  { async: true, silent: true, disableLog: true, silentOnError: true },
410
453
  );
411
454
  } catch (_) {
@@ -440,12 +483,26 @@ class UnderpostMonitor {
440
483
  * two-phase state machine.
441
484
  *
442
485
  * Phase 1 (Kubernetes): pod `Ready` condition via `checkDeploymentReadyStatus`.
443
- * Phase 2 (Runtime): `running-deployment` from the in-pod internal
444
- * status endpoint, read over HTTP (`readRuntimeStatus`).
486
+ * Phase 2 (Runtime): `container-status`, read via the selected transport.
487
+ *
488
+ * Two deployment shapes are supported via `options`:
489
+ * - `runtime` gate (default, dd-* deploys): the `underpost start` launcher
490
+ * stamps `running-deployment`. Success requires K8S Ready AND every pod
491
+ * reporting `running-deployment`.
492
+ * - `kubernetes` gate (custom instances, e.g. cyberia): the runtime is a
493
+ * bare binary; K8S `readinessProbe` (TCP) IS the running signal and
494
+ * `container-status` is stamped to `initializing`/`stopping` by lifecycle
495
+ * hooks. Success requires K8S Ready; the status read is used only for
496
+ * fast `error` detection and display.
497
+ *
498
+ * Phase-2 transport defaults to `exec` (`kubectl exec`, no background
499
+ * process). The `http` transport (`kubectl port-forward` → `/_internal/status`)
500
+ * is opt-in via `options.statusTransport='http'` or
501
+ * `UNDERPOST_STATUS_TRANSPORT=http`; it must not be used in CI/SSH sessions
502
+ * where a stray tunnel can hang the job.
445
503
  *
446
- * Contract:
447
- * - Success requires BOTH phases; runtime readiness is never declared
448
- * before Kubernetes readiness.
504
+ * Contract (both shapes):
505
+ * - Runtime readiness is never declared before Kubernetes readiness.
449
506
  * - An explicit runtime `error` (or a fatal pod status) transitions
450
507
  * immediately to `failed` (throw → CD exit 1).
451
508
  * - Transport failures never count as success and never advance state.
@@ -457,16 +514,27 @@ class UnderpostMonitor {
457
514
  * @param {string} targetTraffic - Target traffic status for the deployment.
458
515
  * @param {Array<string>} ignorePods - List of pod names to ignore.
459
516
  * @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
517
+ * @param {object} [options] - Monitoring shape.
518
+ * @param {('runtime'|'kubernetes')} [options.readyGate='runtime'] - Running-signal owner.
519
+ * @param {('http'|'exec')} [options.statusTransport='http'] - Phase-2 read transport.
460
520
  * @returns {object} - Object containing the ready status of the deployment.
461
521
  * @memberof UnderpostMonitor
462
522
  */
463
- async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default') {
523
+ async monitorReadyRunner(deployId, env, targetTraffic, ignorePods = [], namespace = 'default', options = {}) {
464
524
  const delayMs = parseInt(process.env.UNDERPOST_MONITOR_DELAY_MS) || 1000;
465
525
  const maxIterations = parseInt(process.env.UNDERPOST_MONITOR_MAX_ITERATIONS) || 3000;
466
526
  const deploymentId = `${deployId}-${env}-${targetTraffic}`;
467
527
  const tag = `[${deploymentId}]`;
468
528
  const expectedStatus = RUNTIME_STATUS.RUNNING;
469
- const internalPort = await Underpost.monitor.deployInternalPort(deployId, env);
529
+ const readyGate = options.readyGate === 'kubernetes' ? 'kubernetes' : 'runtime';
530
+ // Default to `exec`: a single synchronous `kubectl exec` read leaves no
531
+ // background process behind. The `http` transport spawns `kubectl
532
+ // port-forward` children that, if orphaned, inherit a CI/SSH session's
533
+ // stdio and hang the job after a successful deploy — opt in explicitly.
534
+ const statusTransport =
535
+ (options.statusTransport || process.env.UNDERPOST_STATUS_TRANSPORT) === 'http' ? 'http' : 'exec';
536
+ const internalPort =
537
+ statusTransport === 'http' ? await Underpost.monitor.deployInternalPort(deployId, env) : null;
470
538
  const podErrorStates = ['error', 'crashloopbackoff', 'oomkilled', 'imagepullbackoff', 'errimagepull'];
471
539
 
472
540
  const emit = (state, status) =>
@@ -478,7 +546,15 @@ class UnderpostMonitor {
478
546
  timestamp: new Date().toISOString(),
479
547
  });
480
548
 
481
- logger.info('Deployment init', { deployId, env, targetTraffic, namespace, internalPort });
549
+ logger.info('Deployment init', {
550
+ deployId,
551
+ env,
552
+ targetTraffic,
553
+ namespace,
554
+ internalPort,
555
+ readyGate,
556
+ statusTransport,
557
+ });
482
558
  emit('pending');
483
559
 
484
560
  const runtimeStatusCache = new Map();
@@ -513,12 +589,12 @@ class UnderpostMonitor {
513
589
  const allPodsK8sReady = result.notReadyPods.length === 0;
514
590
  if (allPodsK8sReady) emit('pod_ready');
515
591
 
516
- // Phase 2: runtime readiness over HTTP. Transport failures neither
517
- // advance state nor count as success; explicit `error` is terminal.
592
+ // Phase 2: runtime status via the selected transport. Transport failures
593
+ // neither advance state nor count as success; explicit `error` is terminal.
518
594
  let allRuntimeRead = true;
519
595
  for (const pod of allPods) {
520
596
  if (!pod?.NAME) continue;
521
- const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort);
597
+ const read = await Underpost.monitor.readRuntimeStatus(pod.NAME, namespace, internalPort, statusTransport);
522
598
  if (!read.ok) {
523
599
  allRuntimeRead = false;
524
600
  emit('runtime_booting', `transport:${read.transportError}`);
@@ -526,6 +602,9 @@ class UnderpostMonitor {
526
602
  }
527
603
  const status = read.status;
528
604
  if (status === RUNTIME_STATUS.ERROR) throw new Error(`Pod ${pod.NAME} reported runtime status=error`);
605
+ // Regression (advanced → empty/build) means a pod restarted. Under the
606
+ // kubernetes gate the runtime never advances past `initializing`, so
607
+ // only treat a drop to empty/build as a regression there.
529
608
  if (advancedPods.has(pod.NAME) && (!status || status === RUNTIME_STATUS.BUILD))
530
609
  throw new Error(`Pod ${pod.NAME} runtime status regressed (${status ?? 'empty'}) — pod likely restarted`);
531
610
  if (status && status !== RUNTIME_STATUS.BUILD) advancedPods.add(pod.NAME);
@@ -533,8 +612,13 @@ class UnderpostMonitor {
533
612
  emit('runtime_booting', status);
534
613
  }
535
614
 
615
+ // Under the kubernetes gate the readinessProbe is the running signal, so
616
+ // K8S Ready alone confirms Phase 2; the status read above is kept only
617
+ // for `error` fast-fail and display.
536
618
  const allRuntimeReady =
537
- allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
619
+ readyGate === 'kubernetes'
620
+ ? true
621
+ : allRuntimeRead && allPods.every((pod) => runtimeStatusCache.get(pod.NAME) === expectedStatus);
538
622
 
539
623
  for (const pod of allPods) {
540
624
  const status = runtimeStatusCache.get(pod.NAME) || 'waiting for status';
@@ -550,11 +634,12 @@ class UnderpostMonitor {
550
634
  );
551
635
  }
552
636
 
553
- // Terminal success requires BOTH phases. runtime_ready cannot precede
637
+ // Terminal success requires both phases. runtime_ready cannot precede
554
638
  // Kubernetes readiness.
555
639
  if (allPodsK8sReady && allRuntimeReady) {
556
- emit('runtime_ready', expectedStatus);
557
- logger.info(`${tag} | Deployment ready (K8S Ready + runtime ${expectedStatus})`);
640
+ const readySignal = readyGate === 'kubernetes' ? 'K8S readinessProbe' : `runtime ${expectedStatus}`;
641
+ emit('runtime_ready', readyGate === 'kubernetes' ? 'k8s-ready' : expectedStatus);
642
+ logger.info(`${tag} | Deployment ready (K8S Ready + ${readySignal})`);
558
643
  return result;
559
644
  }
560
645