underpost 3.2.22 → 3.2.30
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 +181 -1
- package/CLI-HELP.md +61 -2
- package/README.md +3 -2
- package/baremetal/commission-workflows.json +1 -0
- package/bin/build.js +1 -0
- 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 +3 -5
- package/src/cli/baremetal.js +717 -16
- package/src/cli/cluster.js +3 -1
- package/src/cli/deploy.js +118 -9
- package/src/cli/docker-compose.js +591 -0
- package/src/cli/env.js +11 -2
- package/src/cli/fs.js +35 -11
- package/src/cli/index.js +89 -0
- package/src/cli/kickstart.js +142 -20
- package/src/cli/repository.js +153 -11
- package/src/cli/run.js +285 -8
- package/src/cli/secrets.js +7 -2
- package/src/cli/ssh.js +234 -0
- package/src/cli/static.js +2 -2
- 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 -3
- package/src/client.dev.js +1 -1
- package/src/db/mongo/MongoBootstrap.js +12 -12
- 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 +3 -3
- package/src/server/runtime-status.js +18 -1
- package/src/server/start.js +12 -2
- package/src/server.js +6 -2
- package/test/deploy-monitor.test.js +26 -10
- package/typedoc.json +3 -1
- package/src/server/ipfs-client.js +0 -599
- /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
|
@@ -132,6 +132,12 @@ program
|
|
|
132
132
|
'--has-changes',
|
|
133
133
|
'Prints "1" if there are staged or unstaged git changes in the repository, empty string otherwise.',
|
|
134
134
|
)
|
|
135
|
+
.option('--remote-url', 'Prints the current git remote URL (origin) in plain text.')
|
|
136
|
+
.option(
|
|
137
|
+
'--switch-repo <url>',
|
|
138
|
+
'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".',
|
|
139
|
+
)
|
|
140
|
+
.option('--target-branch <branch>', 'Target branch for --switch-repo (default: master).')
|
|
135
141
|
.description('Manages commits to a GitHub repository, supporting various commit types and options.')
|
|
136
142
|
.action(Underpost.repo.commit);
|
|
137
143
|
|
|
@@ -330,6 +336,10 @@ program
|
|
|
330
336
|
'Enables TLS in the Contour HTTPProxy virtualhost without requiring a production ClusterIssuer.',
|
|
331
337
|
)
|
|
332
338
|
.option('--node <node>', 'Sets optional node for deployment operations.')
|
|
339
|
+
.option(
|
|
340
|
+
'--ssh-key-path <path>',
|
|
341
|
+
'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.',
|
|
342
|
+
)
|
|
333
343
|
.option(
|
|
334
344
|
'--build-manifest',
|
|
335
345
|
'Builds Kubernetes YAML manifests, including deployments, services, proxies, and secrets.',
|
|
@@ -630,6 +640,10 @@ program
|
|
|
630
640
|
.option('--replicas <replicas>', 'Sets a custom number of replicas for deployment.')
|
|
631
641
|
.option('--pod-name <pod-name>', 'Optional: Specifies the pod name for execution.')
|
|
632
642
|
.option('--node-name <node-name>', 'Optional: Specifies the node name for execution.')
|
|
643
|
+
.option(
|
|
644
|
+
'--ssh-key-path <path>',
|
|
645
|
+
'Optional: Private key path for node SSH operations, forwarded to volume shipping over SSH. Defaults to engine-private/deploy/id_rsa.',
|
|
646
|
+
)
|
|
633
647
|
.option('--port <port>', 'Optional: Specifies the port for execution.')
|
|
634
648
|
.option('--etc-hosts', 'Enables etc-hosts context for the runner execution.')
|
|
635
649
|
.option('--volume-host-path <volume-host-path>', 'Optional: Specifies the volume host path for test execution.')
|
|
@@ -738,6 +752,37 @@ program
|
|
|
738
752
|
.description('Runs specified scripts using various runners.')
|
|
739
753
|
.action(Underpost.run.callback);
|
|
740
754
|
|
|
755
|
+
program
|
|
756
|
+
.command('docker-compose')
|
|
757
|
+
.argument('[target]', 'Optional service name for --logs, --shell, --restart, or --build.')
|
|
758
|
+
.option('--install', 'Install Docker Engine and the Compose v2 plugin on RHEL/Rocky hosts.')
|
|
759
|
+
.option(
|
|
760
|
+
'--reset',
|
|
761
|
+
'Comprehensive teardown (equivalent to cluster --reset): removes all stack containers, the network, named volumes (destroys data), orphans, and generated artifacts.',
|
|
762
|
+
)
|
|
763
|
+
.option('--force', 'Force reinstall (--install), remove volumes (--down), or also drop the env-file (--reset).')
|
|
764
|
+
.option(
|
|
765
|
+
'--deploy-id <deploy-id>',
|
|
766
|
+
"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).",
|
|
767
|
+
)
|
|
768
|
+
.option('--env <env>', 'Deployment environment for non-default deploy ids (default: development).')
|
|
769
|
+
.option('--generate', 'Render dynamic supporting files (nginx router config, env-file, app-command override).')
|
|
770
|
+
.option('--up', 'Start the full stack detached (regenerates config first).')
|
|
771
|
+
.option('--down', 'Stop and remove containers (and orphans).')
|
|
772
|
+
.option('--volumes', 'With --down, also remove named volumes (destroys persisted data).')
|
|
773
|
+
.option('--restart', 'Restart services (optionally a single [target]).')
|
|
774
|
+
.option('--build', 'With --up rebuild images; alone, rebuilds images with --no-cache.')
|
|
775
|
+
.option('--pull', 'Pull upstream images for all services.')
|
|
776
|
+
.option('--logs', 'Follow logs for all services (optionally a single [target]).')
|
|
777
|
+
.option('--status', 'Show a formatted status table of services.')
|
|
778
|
+
.option('--shell', 'Open an interactive shell in [target] (default: app).')
|
|
779
|
+
.option('--exec <subcommand>', 'General-purpose passthrough docker compose subcommand.')
|
|
780
|
+
.option('--compose-file <path>', 'Path to the compose file (default: docker-compose.yml).')
|
|
781
|
+
.option('--env-file <path>', 'Path to the compose env-file (default: docker/compose.env).')
|
|
782
|
+
.option('--nginx-conf <path>', 'Path to the generated nginx config (default: docker/nginx/default.conf).')
|
|
783
|
+
.description('General-purpose Docker Compose development pipeline (mirrors the Kubernetes dev stack).')
|
|
784
|
+
.action(Underpost.dockerCompose.callback);
|
|
785
|
+
|
|
741
786
|
program
|
|
742
787
|
.command('lxd')
|
|
743
788
|
.argument(
|
|
@@ -856,6 +901,42 @@ program
|
|
|
856
901
|
.option('--remove-machines <system-ids>', 'Removes baremetal machines by comma-separated system IDs, or use "all"')
|
|
857
902
|
.option('--clear-discovered', 'Clears all discovered baremetal machines from the database.')
|
|
858
903
|
.option('--commission', 'Init workflow for commissioning a physical machine.')
|
|
904
|
+
.option(
|
|
905
|
+
'--install-disk [device]',
|
|
906
|
+
'Explicit target install disk for Rocky deployment (e.g. /dev/nvme0n1). Omit or leave empty to auto-detect the internal disk.',
|
|
907
|
+
)
|
|
908
|
+
.option(
|
|
909
|
+
'--no-auto-install',
|
|
910
|
+
'Disables the ephemeral runtime AUTO_INSTALL fallback (controller must trigger install).',
|
|
911
|
+
)
|
|
912
|
+
.option('--no-remote-install', 'Skips the controller-side remote install orchestration over SSH.')
|
|
913
|
+
.option(
|
|
914
|
+
'--worker',
|
|
915
|
+
'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.',
|
|
916
|
+
)
|
|
917
|
+
.option('--control <ip>', 'Control-plane IP the worker node joins (used with --worker for kubeadm infra setup).')
|
|
918
|
+
.option(
|
|
919
|
+
'--ssh-key-dir <dir>',
|
|
920
|
+
'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 ~.',
|
|
921
|
+
)
|
|
922
|
+
.option(
|
|
923
|
+
'--deploy-id <deploy-id>',
|
|
924
|
+
'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.',
|
|
925
|
+
)
|
|
926
|
+
.option(
|
|
927
|
+
'--user <user>',
|
|
928
|
+
'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.',
|
|
929
|
+
)
|
|
930
|
+
.option(
|
|
931
|
+
'--engine-repo <url>',
|
|
932
|
+
'Custom engine repo cloned + normalized to /home/dd/engine on the node (default: <GITHUB_USERNAME>/engine).',
|
|
933
|
+
)
|
|
934
|
+
.option('--engine-branch <branch>', 'Branch of the engine repo to clone on the node.')
|
|
935
|
+
.option(
|
|
936
|
+
'--engine-private-repo <url>',
|
|
937
|
+
'Custom private repo cloned + normalized to /home/dd/engine/engine-private on the node (default: <GITHUB_USERNAME>/engine-<id>-private).',
|
|
938
|
+
)
|
|
939
|
+
.option('--engine-private-branch <branch>', 'Branch of the engine-private repo to clone on the node.')
|
|
859
940
|
.option(
|
|
860
941
|
'--bootstrap-http-server-run',
|
|
861
942
|
'Runs a temporary bootstrap HTTP server for generic purposes such as serving iPXE scripts or ISO images during commissioning.',
|
|
@@ -896,6 +977,14 @@ program
|
|
|
896
977
|
)
|
|
897
978
|
.option('--dev', 'Sets the development context environment for baremetal operations.')
|
|
898
979
|
.option('--ls', 'Lists available boot resources and machines.')
|
|
980
|
+
.option(
|
|
981
|
+
'--resume-infra-setup',
|
|
982
|
+
'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.',
|
|
983
|
+
)
|
|
984
|
+
.option(
|
|
985
|
+
'--resume-join',
|
|
986
|
+
'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.',
|
|
987
|
+
)
|
|
899
988
|
.description(
|
|
900
989
|
'Manages baremetal server operations, including installation, database setup, commissioning, and user management.',
|
|
901
990
|
)
|
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/repository.js
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
buildReplicaId,
|
|
23
23
|
writeEnv,
|
|
24
24
|
} from '../server/conf.js';
|
|
25
|
-
import { buildClient, unzipClientBuild, mergeClientBuildZip } from '../
|
|
25
|
+
import { buildClient, unzipClientBuild, mergeClientBuildZip } from '../client-builder/client-build.js';
|
|
26
26
|
import { DefaultConf } from '../../conf.js';
|
|
27
27
|
import Underpost from '../index.js';
|
|
28
28
|
|
|
@@ -106,6 +106,9 @@ class UnderpostRepository {
|
|
|
106
106
|
* @param {boolean} [options.changelogBuild=false] - If true, scrapes all git history and builds a full CHANGELOG.md. Commits containing 'New release v:' are used as version section titles. Only commits starting with '[<tag>]' are included as entries.
|
|
107
107
|
* @param {string} [options.changelogMinVersion=''] - If set, overrides the default minimum version limit (2.85.0) for --changelog-build.
|
|
108
108
|
* @param {boolean} [options.changelogNoHash=false] - If true, omits commit hashes from the changelog entries.
|
|
109
|
+
* @param {boolean} [options.remoteUrl=false] - If true, prints the current git remote URL (origin) in plain text and returns.
|
|
110
|
+
* @param {string} [options.switchRepo=''] - If set, switches the remote `origin` to this URL and force-pulls the target branch, overwriting the current working tree.
|
|
111
|
+
* @param {string} [options.targetBranch=''] - Target branch for `switchRepo` (defaults to `master`).
|
|
109
112
|
* @memberof UnderpostRepository
|
|
110
113
|
*/
|
|
111
114
|
commit(
|
|
@@ -135,6 +138,9 @@ class UnderpostRepository {
|
|
|
135
138
|
bc: '',
|
|
136
139
|
isRemoteRepo: '',
|
|
137
140
|
hasChanges: false,
|
|
141
|
+
remoteUrl: false,
|
|
142
|
+
switchRepo: '',
|
|
143
|
+
targetBranch: '',
|
|
138
144
|
},
|
|
139
145
|
) {
|
|
140
146
|
if (!repoPath) repoPath = '.';
|
|
@@ -155,6 +161,22 @@ class UnderpostRepository {
|
|
|
155
161
|
return;
|
|
156
162
|
}
|
|
157
163
|
|
|
164
|
+
if (options.remoteUrl) {
|
|
165
|
+
const url = Underpost.repo.getRemoteUrl({ path: repoPath });
|
|
166
|
+
if (options.copy) pbcopy(url);
|
|
167
|
+
else console.log(url);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (options.switchRepo) {
|
|
172
|
+
Underpost.repo.switchRemote({
|
|
173
|
+
path: repoPath,
|
|
174
|
+
url: options.switchRepo,
|
|
175
|
+
branch: options.targetBranch || 'master',
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
158
180
|
if (options.bc) {
|
|
159
181
|
console.log(
|
|
160
182
|
shellExec(`cd ${repoPath} && git for-each-ref --contains ${options.bc} --format='%(refname:short)'`, {
|
|
@@ -1332,6 +1354,65 @@ Prevent build private config repo.`,
|
|
|
1332
1354
|
return typeof raw === 'string' && /^[0-9a-f]{40}\t/m.test(raw);
|
|
1333
1355
|
},
|
|
1334
1356
|
|
|
1357
|
+
/**
|
|
1358
|
+
* Returns the current URL of a git remote in plain text.
|
|
1359
|
+
* @param {object} [opts]
|
|
1360
|
+
* @param {string} [opts.path='.'] - Path to the git repository.
|
|
1361
|
+
* @param {string} [opts.remote='origin'] - Remote name to query.
|
|
1362
|
+
* @returns {string} The remote URL, or '' when the remote is not configured.
|
|
1363
|
+
* @memberof UnderpostRepository
|
|
1364
|
+
*/
|
|
1365
|
+
getRemoteUrl({ path: repoPath = '.', remote = 'origin' } = {}) {
|
|
1366
|
+
return shellExec(`cd "${repoPath}" && git remote get-url ${remote}`, {
|
|
1367
|
+
stdout: true,
|
|
1368
|
+
silent: true,
|
|
1369
|
+
disableLog: true,
|
|
1370
|
+
silentOnError: true,
|
|
1371
|
+
}).trim();
|
|
1372
|
+
},
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Switches a local repository onto a different remote and force-syncs its
|
|
1376
|
+
* working tree to a target branch, discarding local commits and tracked
|
|
1377
|
+
* changes — effectively "switch repo to <url>#<branch>".
|
|
1378
|
+
*
|
|
1379
|
+
* Sequence (idempotent, re-runnable):
|
|
1380
|
+
* 1. Normalize the URL (`owner/repo` → full GitHub HTTPS) and set/add the
|
|
1381
|
+
* remote, storing the token-free URL so no secret leaks into `.git/config`.
|
|
1382
|
+
* 2. Force-fetch the target branch (auth injected inline for private repos).
|
|
1383
|
+
* 3. Reset the working tree to the fetched tip and check out the target
|
|
1384
|
+
* branch, overwriting any current tracked content.
|
|
1385
|
+
*
|
|
1386
|
+
* Untracked files are intentionally left in place (no `git clean`).
|
|
1387
|
+
*
|
|
1388
|
+
* @param {object} opts
|
|
1389
|
+
* @param {string} opts.url - New remote URL (full URL or "owner/repo" short form).
|
|
1390
|
+
* @param {string} [opts.path='.'] - Path to the git repository.
|
|
1391
|
+
* @param {string} [opts.branch='master'] - Target branch to overwrite the current tree with.
|
|
1392
|
+
* @param {string} [opts.remote='origin'] - Remote name to set and fetch from.
|
|
1393
|
+
* @returns {void}
|
|
1394
|
+
* @memberof UnderpostRepository
|
|
1395
|
+
*/
|
|
1396
|
+
switchRemote({ url, path: repoPath = '.', branch = 'master', remote = 'origin' }) {
|
|
1397
|
+
if (!url) throw new Error('switchRemote requires a target remote url');
|
|
1398
|
+
if (!fs.existsSync(`${repoPath}/.git`)) throw new Error(`switchRemote: not a git repository: ${repoPath}`);
|
|
1399
|
+
// Token-free URL for the stored remote; auth-injected URL only for the fetch.
|
|
1400
|
+
let normalized = url;
|
|
1401
|
+
if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) {
|
|
1402
|
+
normalized = `https://github.com/${url}`;
|
|
1403
|
+
}
|
|
1404
|
+
const authUrl = Underpost.repo.resolveAuthUrl(url);
|
|
1405
|
+
const current = Underpost.repo.getRemoteUrl({ path: repoPath, remote });
|
|
1406
|
+
if (!current) shellExec(`cd "${repoPath}" && git remote add ${remote} "${normalized}"`);
|
|
1407
|
+
else shellExec(`cd "${repoPath}" && git remote set-url ${remote} "${normalized}"`);
|
|
1408
|
+
logger.info('switchRemote', { path: repoPath, remote, branch, url: normalized });
|
|
1409
|
+
shellExec(`cd "${repoPath}" && GIT_TERMINAL_PROMPT=0 git fetch --force "${authUrl}" ${branch}`);
|
|
1410
|
+
// reset --hard first clears the worktree so the checkout cannot be blocked
|
|
1411
|
+
// by conflicting local changes; -B points the target branch at the fetched tip.
|
|
1412
|
+
shellExec(`cd "${repoPath}" && git reset --hard FETCH_HEAD`);
|
|
1413
|
+
shellExec(`cd "${repoPath}" && git checkout -B ${branch} FETCH_HEAD`);
|
|
1414
|
+
},
|
|
1415
|
+
|
|
1335
1416
|
/**
|
|
1336
1417
|
* Returns metadata about unpushed commits in a git repository.
|
|
1337
1418
|
* Fetches from origin, then counts commits ahead of the remote branch.
|
|
@@ -1483,18 +1564,63 @@ Prevent build private config repo.`,
|
|
|
1483
1564
|
},
|
|
1484
1565
|
|
|
1485
1566
|
/**
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1567
|
+
* Resolves the in-pod site-root directory where a conf route's repository lives.
|
|
1568
|
+
*
|
|
1569
|
+
* General-purpose resolution, independent of any single runtime:
|
|
1570
|
+
* 1. An explicit `directory` (the conf-declared document root) always wins.
|
|
1571
|
+
* 2. Otherwise the runtime's base directory is used (e.g. `wp` → `/opt/lampp/htdocs/wp/<host>`).
|
|
1572
|
+
* 3. A subdirectory route (e.g. `/wp`) appends `<subDir>` to the resolved root.
|
|
1573
|
+
*
|
|
1574
|
+
* This mirrors {@link WpService.createApp}'s `vhostDir`/`wpDir` layout so backups target the
|
|
1575
|
+
* exact directory provisioning created, including subdirectory installs and custom directories.
|
|
1576
|
+
*
|
|
1577
|
+
* @param {object} opts
|
|
1578
|
+
* @param {string} opts.runtime - The runtime identifier (e.g. 'wp').
|
|
1579
|
+
* @param {string} opts.host - The virtual-host name.
|
|
1580
|
+
* @param {string} [opts.routePath='/'] - The conf route path the repository is mounted under.
|
|
1581
|
+
* @param {string} [opts.directory] - Explicit document root from conf; overrides the runtime base.
|
|
1582
|
+
* @returns {string|null} Absolute path inside the pod, or null when neither a directory nor a
|
|
1583
|
+
* known runtime base resolves.
|
|
1491
1584
|
* @memberof UnderpostRepository
|
|
1492
1585
|
*/
|
|
1493
|
-
runtimeSiteRoot(runtime, host) {
|
|
1494
|
-
const
|
|
1586
|
+
runtimeSiteRoot({ runtime, host, routePath = '/', directory } = {}) {
|
|
1587
|
+
const runtimeBase = {
|
|
1495
1588
|
wp: `/opt/lampp/htdocs/wp/${host}`,
|
|
1496
1589
|
};
|
|
1497
|
-
|
|
1590
|
+
const vhostDir = directory || runtimeBase[runtime];
|
|
1591
|
+
if (!vhostDir) return null;
|
|
1592
|
+
const subDir = routePath && routePath !== '/' ? routePath.replace(/^\/+/, '').replace(/\/+$/, '') : '';
|
|
1593
|
+
return subDir ? `${vhostDir}/${subDir}` : vhostDir;
|
|
1594
|
+
},
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Probes a running pod for the first candidate directory that is a git repository
|
|
1598
|
+
* (i.e. contains a `.git` entry). Used to locate a site root before backing it up so a
|
|
1599
|
+
* missing/unprovisioned directory is detected up-front instead of producing an opaque
|
|
1600
|
+
* shell `exit 1` from a failed `cd`.
|
|
1601
|
+
*
|
|
1602
|
+
* @param {object} opts
|
|
1603
|
+
* @param {string} opts.podName - Target pod name.
|
|
1604
|
+
* @param {string} opts.namespace - Kubernetes namespace.
|
|
1605
|
+
* @param {string[]} opts.candidates - Absolute paths to probe, most-specific first.
|
|
1606
|
+
* @returns {string|null} The first candidate that is a git repo, or null if none match.
|
|
1607
|
+
* @memberof UnderpostRepository
|
|
1608
|
+
*/
|
|
1609
|
+
podRepoDir({ podName, namespace, candidates }) {
|
|
1610
|
+
const probe = candidates.map((dir) => `if [ -d '${dir}/.git' ]; then echo '${dir}'; exit 0; fi`).join('; ');
|
|
1611
|
+
let out = '';
|
|
1612
|
+
try {
|
|
1613
|
+
out = Underpost.kubectl.exec({ podName, namespace, command: `${probe}; echo ''` }) || '';
|
|
1614
|
+
} catch (err) {
|
|
1615
|
+
logger.warn(`podRepoDir: probe failed in pod ${podName}`, err.message);
|
|
1616
|
+
return null;
|
|
1617
|
+
}
|
|
1618
|
+
return (
|
|
1619
|
+
out
|
|
1620
|
+
.split('\n')
|
|
1621
|
+
.map((line) => line.trim())
|
|
1622
|
+
.find(Boolean) || null
|
|
1623
|
+
);
|
|
1498
1624
|
},
|
|
1499
1625
|
|
|
1500
1626
|
/**
|
|
@@ -1543,12 +1669,28 @@ Prevent build private config repo.`,
|
|
|
1543
1669
|
const entry = confServer[host][routePath];
|
|
1544
1670
|
if (!entry.repository) continue;
|
|
1545
1671
|
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1672
|
+
const siteRootArgs = { runtime: entry.runtime, host, directory: entry.directory };
|
|
1673
|
+
const repoRoot = Underpost.repo.runtimeSiteRoot({ ...siteRootArgs, routePath });
|
|
1674
|
+
if (!repoRoot) {
|
|
1548
1675
|
logger.warn(`backupPodRepositories: no site-root mapping for runtime '${entry.runtime}' (${host})`);
|
|
1549
1676
|
continue;
|
|
1550
1677
|
}
|
|
1551
1678
|
|
|
1679
|
+
// Probe the pod for the actual git repo so an unprovisioned/missing site root is
|
|
1680
|
+
// detected and skipped cleanly instead of failing the in-pod `cd` with exit 1.
|
|
1681
|
+
// Fall back to the vhost dir (root route) to cover conf/path drift.
|
|
1682
|
+
const vhostRoot = Underpost.repo.runtimeSiteRoot({ ...siteRootArgs, routePath: '/' });
|
|
1683
|
+
const candidates = [...new Set([repoRoot, vhostRoot].filter(Boolean))];
|
|
1684
|
+
const siteRoot = Underpost.repo.podRepoDir({ podName, namespace, candidates });
|
|
1685
|
+
if (!siteRoot) {
|
|
1686
|
+
logger.warn(
|
|
1687
|
+
`backupPodRepositories: no git repository found in pod ${podName} for ${host} (checked ${candidates.join(
|
|
1688
|
+
', ',
|
|
1689
|
+
)}) — site may not be provisioned yet; skipping`,
|
|
1690
|
+
);
|
|
1691
|
+
continue;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1552
1694
|
const repoName = entry.repository.split('/').pop().split('.')[0];
|
|
1553
1695
|
|
|
1554
1696
|
// Build the backup script — secrets are injected as env vars in the exec,
|