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.
- package/.github/workflows/ghpkg.ci.yml +1 -1
- package/.github/workflows/gitlab.ci.yml +1 -1
- package/.github/workflows/npmpkg.ci.yml +1 -1
- package/.github/workflows/publish.ci.yml +2 -2
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/CHANGELOG.md +178 -1
- package/CLI-HELP.md +58 -2
- package/README.md +3 -2
- package/baremetal/commission-workflows.json +1 -0
- package/bin/build.js +13 -1
- package/docker-compose.yml +224 -0
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +27 -15
- package/scripts/kubeadm-node-setup.sh +317 -0
- package/scripts/rocky-kickstart.sh +877 -185
- package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
- package/scripts/test-monitor.sh +242 -78
- package/src/cli/baremetal.js +717 -16
- package/src/cli/cluster.js +3 -1
- package/src/cli/deploy.js +11 -10
- package/src/cli/docker-compose.js +591 -0
- package/src/cli/fs.js +35 -11
- package/src/cli/index.js +83 -0
- package/src/cli/kickstart.js +142 -20
- package/src/cli/monitor.js +114 -29
- package/src/cli/repository.js +75 -11
- package/src/cli/run.js +275 -6
- package/src/cli/ssh.js +190 -0
- package/src/cli/static.js +2 -2
- package/src/client/components/core/PanelForm.js +44 -44
- package/src/{server → client-builder}/client-build-docs.js +15 -5
- package/src/{server → client-builder}/client-build-live.js +3 -3
- package/src/{server → client-builder}/client-build.js +25 -22
- package/src/{server → client-builder}/client-dev-server.js +3 -3
- package/src/{server → client-builder}/client-icons.js +2 -2
- package/src/{server → client-builder}/ssr.js +5 -5
- package/src/client.build.js +1 -1
- package/src/client.dev.js +1 -1
- package/src/index.js +12 -1
- package/src/mailer/EmailRender.js +1 -1
- package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
- package/src/runtime/express/Express.js +2 -2
- package/src/runtime/nginx/Nginx.js +250 -0
- package/src/server/catalog.js +7 -14
- package/src/server/conf.js +61 -12
- package/src/server/start.js +17 -5
- package/src/server.js +1 -1
- package/test/deploy-monitor.test.js +33 -5
- package/typedoc.json +3 -1
- package/src/server/ipfs-client.js +0 -597
- /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
- /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
|
-
|
|
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 ${
|
|
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
|
-
|
|
167
|
-
|
|
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 ${
|
|
182
|
-
shellExec(`underpost cmt ${
|
|
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
|
)
|
package/src/cli/kickstart.js
CHANGED
|
@@ -43,46 +43,168 @@ class UnderpostKickStart {
|
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* @method kickstartPreVariables
|
|
46
|
-
* @description Generates the variable
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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: ({
|
|
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
|
-
`
|
|
58
|
-
`
|
|
59
|
-
`
|
|
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
|
|
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.
|
|
72
|
-
* @param {string} [options.
|
|
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
|
|
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({
|
|
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');
|
package/src/cli/monitor.js
CHANGED
|
@@ -378,34 +378,77 @@ class UnderpostMonitor {
|
|
|
378
378
|
return deployStatusPort(deployId, env) ?? 3000;
|
|
379
379
|
},
|
|
380
380
|
/**
|
|
381
|
-
* Reads Phase-2 runtime
|
|
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
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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`
|
|
406
|
-
//
|
|
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): `
|
|
444
|
-
*
|
|
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
|
-
* -
|
|
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
|
|
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', {
|
|
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
|
|
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
|
-
|
|
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
|
|
637
|
+
// Terminal success requires both phases. runtime_ready cannot precede
|
|
554
638
|
// Kubernetes readiness.
|
|
555
639
|
if (allPodsK8sReady && allRuntimeReady) {
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|