underpost 2.89.37 → 2.89.44
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/README.md +3 -2
- package/bin/deploy.js +22 -15
- package/cli.md +22 -2
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +6 -2
- package/manifests/deployment/dd-test-development/proxy.yaml +2 -0
- package/manifests/deployment/kafka/deployment.yaml +0 -2
- package/manifests/deployment/spark/spark-pi-py.yaml +0 -1
- package/manifests/deployment/tensorflow/tf-gpu-test.yaml +0 -2
- package/manifests/envoy-service-nodeport.yaml +0 -1
- package/manifests/kubeadm-calico-config.yaml +10 -115
- package/manifests/letsencrypt-prod.yaml +0 -1
- package/manifests/mariadb/statefulset.yaml +1 -1
- package/manifests/mongodb/statefulset.yaml +11 -11
- package/manifests/mongodb-4.4/service-deployment.yaml +1 -3
- package/manifests/mysql/pv-pvc.yaml +1 -1
- package/manifests/mysql/statefulset.yaml +1 -1
- package/manifests/valkey/service.yaml +0 -1
- package/manifests/valkey/statefulset.yaml +2 -3
- package/package.json +1 -1
- package/scripts/device-scan.sh +43 -21
- package/scripts/rpmfusion-ffmpeg-setup.sh +1 -0
- package/src/cli/cluster.js +51 -26
- package/src/cli/deploy.js +52 -28
- package/src/cli/index.js +22 -1
- package/src/cli/monitor.js +9 -5
- package/src/cli/repository.js +1 -1
- package/src/cli/run.js +30 -18
- package/src/client/components/core/Logger.js +1 -1
- package/src/client/components/core/Modal.js +5 -0
- package/src/client/components/core/ObjectLayerEngineModal.js +334 -71
- package/src/client/components/core/ObjectLayerEngineViewer.js +170 -403
- package/src/client/components/core/Router.js +10 -1
- package/src/client/services/default/default.management.js +25 -5
- package/src/index.js +1 -1
- package/src/server/client-build.js +5 -4
- package/src/server/conf.js +1 -1
- package/manifests/kubelet-config.yaml +0 -65
- package/manifests/mongodb/backup-access.yaml +0 -16
- package/manifests/mongodb/backup-cronjob.yaml +0 -42
- package/manifests/mongodb/backup-pv-pvc.yaml +0 -22
- package/manifests/mongodb/configmap.yaml +0 -26
package/scripts/device-scan.sh
CHANGED
|
@@ -1,26 +1,42 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
+
set -u -o pipefail
|
|
2
3
|
|
|
3
4
|
for iface_path in /sys/class/net/*; do
|
|
5
|
+
[ -e "$iface_path" ] || continue
|
|
4
6
|
name=$(basename "$iface_path")
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
|
|
8
|
+
# MAC address
|
|
9
|
+
if [ -r "$iface_path/address" ]; then
|
|
10
|
+
mac=$(< "$iface_path/address")
|
|
11
|
+
else
|
|
12
|
+
mac="—"
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
# IPv4: collect all IPv4 CIDRs, strip masks, join with commas (or show —)
|
|
16
|
+
ip_info=$(ip -4 -o addr show dev "$name" 2>/dev/null | awk '{print $4}')
|
|
17
|
+
if [ -n "$ip_info" ]; then
|
|
18
|
+
# Use word-splitting intentionally to iterate lines from ip_info
|
|
19
|
+
ip=$(printf "%s\n" $ip_info | awk -F/ '{print $1}' | paste -sd, -)
|
|
20
|
+
else
|
|
21
|
+
ip="—"
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# operstate and mtu
|
|
25
|
+
operstate=$(< "$iface_path/operstate" 2>/dev/null || echo "—")
|
|
26
|
+
mtu=$(< "$iface_path/mtu" 2>/dev/null || echo "—")
|
|
27
|
+
|
|
28
|
+
# Driver (if available)
|
|
29
|
+
if [ -e "$iface_path/device/driver" ]; then
|
|
13
30
|
driver=$(basename "$(readlink -f "$iface_path/device/driver")")
|
|
14
31
|
else
|
|
15
32
|
driver="—"
|
|
16
33
|
fi
|
|
17
34
|
|
|
18
|
-
#
|
|
35
|
+
# PCI vendor:device (if available)
|
|
19
36
|
pci_dev="$iface_path/device"
|
|
20
|
-
if [ -
|
|
37
|
+
if [ -r "$pci_dev/vendor" ] && [ -r "$pci_dev/device" ]; then
|
|
21
38
|
vendor_id=$(< "$pci_dev/vendor")
|
|
22
39
|
device_id=$(< "$pci_dev/device")
|
|
23
|
-
# parse 0x8086 to 8086, etc.
|
|
24
40
|
vendor_id=${vendor_id#0x}
|
|
25
41
|
device_id=${device_id#0x}
|
|
26
42
|
pci="${vendor_id}:${device_id}"
|
|
@@ -28,16 +44,22 @@ for iface_path in /sys/class/net/*; do
|
|
|
28
44
|
pci="—"
|
|
29
45
|
fi
|
|
30
46
|
|
|
31
|
-
# Link
|
|
47
|
+
# Link speed: only append unit if numeric
|
|
32
48
|
speed=$(cat "$iface_path/speed" 2>/dev/null || echo "—")
|
|
49
|
+
if [[ "$speed" =~ ^[0-9]+$ ]]; then
|
|
50
|
+
speed_label="${speed} Mb/s"
|
|
51
|
+
else
|
|
52
|
+
speed_label="$speed"
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
# Print formatted output
|
|
56
|
+
printf 'Interface: %s\n' "$name"
|
|
57
|
+
printf ' MAC: %s\n' "$mac"
|
|
58
|
+
printf ' IPv4: %s\n' "$ip"
|
|
59
|
+
printf ' State: %s\n' "$operstate"
|
|
60
|
+
printf ' MTU: %s\n' "$mtu"
|
|
61
|
+
printf ' Driver: %s\n' "$driver"
|
|
62
|
+
printf ' PCI Vendor:Device: %s\n' "$pci"
|
|
63
|
+
printf ' Link Speed: %s\n\n' "$speed_label"
|
|
33
64
|
|
|
34
|
-
echo "Interface: $name"
|
|
35
|
-
echo " MAC: $mac"
|
|
36
|
-
echo " IPv4: $ip"
|
|
37
|
-
echo " State: $operstate"
|
|
38
|
-
echo " MTU: $mtu"
|
|
39
|
-
echo " Driver: $driver"
|
|
40
|
-
echo " PCI Vendor:Device ID: $pci"
|
|
41
|
-
echo " Link Speed: ${speed}Mb/s"
|
|
42
|
-
echo
|
|
43
65
|
done
|
|
@@ -32,6 +32,7 @@ echo "6) Try to install audio helper packages that sometimes block ffmpeg (ladsp
|
|
|
32
32
|
# These may be provided by CRB/EPEL or other compatible repos
|
|
33
33
|
dnf -y install ladspa || echo "ladspa not available from enabled repos (will try later)"
|
|
34
34
|
dnf -y install rubberband || echo "rubberband not available from enabled repos (will try later)"
|
|
35
|
+
dnf -y install libwebp-tools || echo "libwebp-tools not available from enabled repos (will try later)"
|
|
35
36
|
|
|
36
37
|
echo "7) Try installing ffmpeg (several fallbacks tried)"
|
|
37
38
|
if dnf -y install ffmpeg ffmpeg-devel --allowerasing; then
|
package/src/cli/cluster.js
CHANGED
|
@@ -44,7 +44,8 @@ class UnderpostCluster {
|
|
|
44
44
|
* @param {boolean} [options.listPods=false] - List Kubernetes pods.
|
|
45
45
|
* @param {boolean} [options.reset=false] - Perform a comprehensive reset of Kubernetes and container environments.
|
|
46
46
|
* @param {boolean} [options.dev=false] - Run in development mode (adjusts paths).
|
|
47
|
-
* @param {string} [options.nsUse=''] - Set the current kubectl namespace.
|
|
47
|
+
* @param {string} [options.nsUse=''] - Set the current kubectl namespace (creates namespace if it doesn't exist).
|
|
48
|
+
* @param {string} [options.namespace='default'] - Kubernetes namespace for cluster operations.
|
|
48
49
|
* @param {boolean} [options.infoCapacity=false] - Display resource capacity information for the cluster.
|
|
49
50
|
* @param {boolean} [options.infoCapacityPod=false] - Display resource capacity information for pods.
|
|
50
51
|
* @param {boolean} [options.pullImage=false] - Pull necessary Docker images before deployment.
|
|
@@ -79,6 +80,7 @@ class UnderpostCluster {
|
|
|
79
80
|
reset: false,
|
|
80
81
|
dev: false,
|
|
81
82
|
nsUse: '',
|
|
83
|
+
namespace: 'default',
|
|
82
84
|
infoCapacity: false,
|
|
83
85
|
infoCapacityPod: false,
|
|
84
86
|
pullImage: false,
|
|
@@ -116,8 +118,26 @@ class UnderpostCluster {
|
|
|
116
118
|
if (options.infoCapacity === true)
|
|
117
119
|
return logger.info('', UnderpostCluster.API.getResourcesCapacity(options.kubeadm || options.k3s)); // Adjust for k3s
|
|
118
120
|
if (options.listPods === true) return console.table(UnderpostDeploy.API.get(podName ?? undefined));
|
|
121
|
+
// Set default namespace if not specified
|
|
122
|
+
if (!options.namespace) options.namespace = 'default';
|
|
123
|
+
|
|
119
124
|
if (options.nsUse && typeof options.nsUse === 'string') {
|
|
125
|
+
// Verify if namespace exists, create if not
|
|
126
|
+
const namespaceExists = shellExec(`kubectl get namespace ${options.nsUse} --ignore-not-found -o name`, {
|
|
127
|
+
stdout: true,
|
|
128
|
+
silent: true,
|
|
129
|
+
}).trim();
|
|
130
|
+
|
|
131
|
+
if (!namespaceExists) {
|
|
132
|
+
logger.info(`Namespace '${options.nsUse}' does not exist. Creating it...`);
|
|
133
|
+
shellExec(`kubectl create namespace ${options.nsUse}`);
|
|
134
|
+
logger.info(`Namespace '${options.nsUse}' created successfully.`);
|
|
135
|
+
} else {
|
|
136
|
+
logger.info(`Namespace '${options.nsUse}' already exists.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
120
139
|
shellExec(`kubectl config set-context --current --namespace=${options.nsUse}`);
|
|
140
|
+
logger.info(`Context switched to namespace: ${options.nsUse}`);
|
|
121
141
|
return;
|
|
122
142
|
}
|
|
123
143
|
if (options.info === true) {
|
|
@@ -242,14 +262,17 @@ class UnderpostCluster {
|
|
|
242
262
|
shellExec(
|
|
243
263
|
`sudo kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.3/manifests/tigera-operator.yaml`,
|
|
244
264
|
);
|
|
245
|
-
shellExec(
|
|
265
|
+
shellExec(
|
|
266
|
+
`sudo kubectl apply -f ${underpostRoot}/manifests/kubeadm-calico-config.yaml -n ${options.namespace}`,
|
|
267
|
+
);
|
|
268
|
+
|
|
246
269
|
// Untaint control plane node to allow scheduling pods
|
|
247
270
|
const nodeName = os.hostname();
|
|
248
271
|
shellExec(`kubectl taint nodes ${nodeName} node-role.kubernetes.io/control-plane:NoSchedule-`);
|
|
249
272
|
// Install local-path-provisioner for dynamic PVCs (optional but recommended)
|
|
250
273
|
logger.info('Installing local-path-provisioner...');
|
|
251
274
|
shellExec(
|
|
252
|
-
`kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml`,
|
|
275
|
+
`kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/master/deploy/local-path-storage.yaml -n ${options.namespace}`,
|
|
253
276
|
);
|
|
254
277
|
} else {
|
|
255
278
|
// Kind cluster initialization (if not using kubeadm or k3s)
|
|
@@ -286,13 +309,13 @@ class UnderpostCluster {
|
|
|
286
309
|
}
|
|
287
310
|
|
|
288
311
|
if (options.grafana === true) {
|
|
289
|
-
shellExec(`kubectl delete deployment grafana --ignore-not-found`);
|
|
290
|
-
shellExec(`kubectl apply -k ${underpostRoot}/manifests/grafana`);
|
|
312
|
+
shellExec(`kubectl delete deployment grafana -n ${options.namespace} --ignore-not-found`);
|
|
313
|
+
shellExec(`kubectl apply -k ${underpostRoot}/manifests/grafana -n ${options.namespace}`);
|
|
291
314
|
const yaml = `${fs
|
|
292
315
|
.readFileSync(`${underpostRoot}/manifests/grafana/deployment.yaml`, 'utf8')
|
|
293
316
|
.replace('{{GF_SERVER_ROOT_URL}}', options.hosts.split(',')[0])}`;
|
|
294
317
|
console.log(yaml);
|
|
295
|
-
shellExec(`kubectl apply -f - <<EOF
|
|
318
|
+
shellExec(`kubectl apply -f - -n ${options.namespace} <<EOF
|
|
296
319
|
${yaml}
|
|
297
320
|
EOF
|
|
298
321
|
`);
|
|
@@ -311,7 +334,7 @@ EOF
|
|
|
311
334
|
.join(',')}]`,
|
|
312
335
|
)}`;
|
|
313
336
|
console.log(yaml);
|
|
314
|
-
shellExec(`kubectl apply -f - <<EOF
|
|
337
|
+
shellExec(`kubectl apply -f - -n ${options.namespace} <<EOF
|
|
315
338
|
${yaml}
|
|
316
339
|
EOF
|
|
317
340
|
`);
|
|
@@ -340,15 +363,15 @@ EOF
|
|
|
340
363
|
// For kubeadm/k3s, ensure it's available for containerd
|
|
341
364
|
shellExec(`sudo crictl pull valkey/valkey:latest`);
|
|
342
365
|
}
|
|
343
|
-
shellExec(`kubectl delete statefulset valkey-service --ignore-not-found`);
|
|
344
|
-
shellExec(`kubectl apply -k ${underpostRoot}/manifests/valkey`);
|
|
366
|
+
shellExec(`kubectl delete statefulset valkey-service -n ${options.namespace} --ignore-not-found`);
|
|
367
|
+
shellExec(`kubectl apply -k ${underpostRoot}/manifests/valkey -n ${options.namespace}`);
|
|
345
368
|
await UnderpostTest.API.statusMonitor('valkey-service', 'Running', 'pods', 1000, 60);
|
|
346
369
|
}
|
|
347
370
|
if (options.full === true || options.mariadb === true) {
|
|
348
371
|
shellExec(
|
|
349
|
-
`sudo kubectl create secret generic mariadb-secret --from-file=username=/home/dd/engine/engine-private/mariadb-username --from-file=password=/home/dd/engine/engine-private/mariadb-password --dry-run=client -o yaml | kubectl apply -f
|
|
372
|
+
`sudo kubectl create secret generic mariadb-secret --from-file=username=/home/dd/engine/engine-private/mariadb-username --from-file=password=/home/dd/engine/engine-private/mariadb-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
350
373
|
);
|
|
351
|
-
shellExec(`kubectl delete statefulset mariadb-statefulset --ignore-not-found`);
|
|
374
|
+
shellExec(`kubectl delete statefulset mariadb-statefulset -n ${options.namespace} --ignore-not-found`);
|
|
352
375
|
|
|
353
376
|
if (options.pullImage === true) {
|
|
354
377
|
// shellExec(`sudo podman pull mariadb:latest`);
|
|
@@ -360,17 +383,17 @@ EOF
|
|
|
360
383
|
// For kubeadm/k3s, ensure it's available for containerd
|
|
361
384
|
shellExec(`sudo crictl pull mariadb:latest`);
|
|
362
385
|
}
|
|
363
|
-
shellExec(`kubectl apply -f ${underpostRoot}/manifests/mariadb/storage-class.yaml`);
|
|
364
|
-
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mariadb`);
|
|
386
|
+
shellExec(`kubectl apply -f ${underpostRoot}/manifests/mariadb/storage-class.yaml -n ${options.namespace}`);
|
|
387
|
+
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mariadb -n ${options.namespace}`);
|
|
365
388
|
}
|
|
366
389
|
if (options.full === true || options.mysql === true) {
|
|
367
390
|
shellExec(
|
|
368
|
-
`sudo kubectl create secret generic mysql-secret --from-file=username=/home/dd/engine/engine-private/mysql-username --from-file=password=/home/dd/engine/engine-private/mysql-password --dry-run=client -o yaml | kubectl apply -f
|
|
391
|
+
`sudo kubectl create secret generic mysql-secret --from-file=username=/home/dd/engine/engine-private/mysql-username --from-file=password=/home/dd/engine/engine-private/mysql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
369
392
|
);
|
|
370
393
|
shellExec(`sudo mkdir -p /mnt/data`);
|
|
371
394
|
shellExec(`sudo chmod 777 /mnt/data`);
|
|
372
395
|
shellExec(`sudo chown -R root:root /mnt/data`);
|
|
373
|
-
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mysql`);
|
|
396
|
+
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mysql -n ${options.namespace}`);
|
|
374
397
|
}
|
|
375
398
|
if (options.full === true || options.postgresql === true) {
|
|
376
399
|
if (options.pullImage === true) {
|
|
@@ -383,9 +406,9 @@ EOF
|
|
|
383
406
|
shellExec(`sudo crictl pull postgres:latest`);
|
|
384
407
|
}
|
|
385
408
|
shellExec(
|
|
386
|
-
`sudo kubectl create secret generic postgres-secret --from-file=password=/home/dd/engine/engine-private/postgresql-password --dry-run=client -o yaml | kubectl apply -f
|
|
409
|
+
`sudo kubectl create secret generic postgres-secret --from-file=password=/home/dd/engine/engine-private/postgresql-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
387
410
|
);
|
|
388
|
-
shellExec(`kubectl apply -k ${underpostRoot}/manifests/postgresql`);
|
|
411
|
+
shellExec(`kubectl apply -k ${underpostRoot}/manifests/postgresql -n ${options.namespace}`);
|
|
389
412
|
}
|
|
390
413
|
if (options.mongodb4 === true) {
|
|
391
414
|
if (options.pullImage === true) {
|
|
@@ -397,7 +420,7 @@ EOF
|
|
|
397
420
|
// For kubeadm/k3s, ensure it's available for containerd
|
|
398
421
|
shellExec(`sudo crictl pull mongo:4.4`);
|
|
399
422
|
}
|
|
400
|
-
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mongodb-4.4`);
|
|
423
|
+
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mongodb-4.4 -n ${options.namespace}`);
|
|
401
424
|
|
|
402
425
|
const deploymentName = 'mongodb-deployment';
|
|
403
426
|
|
|
@@ -428,14 +451,14 @@ EOF
|
|
|
428
451
|
shellExec(`sudo crictl pull mongo:latest`);
|
|
429
452
|
}
|
|
430
453
|
shellExec(
|
|
431
|
-
`sudo kubectl create secret generic mongodb-keyfile --from-file=/home/dd/engine/engine-private/mongodb-keyfile --dry-run=client -o yaml | kubectl apply -f
|
|
454
|
+
`sudo kubectl create secret generic mongodb-keyfile --from-file=/home/dd/engine/engine-private/mongodb-keyfile --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
432
455
|
);
|
|
433
456
|
shellExec(
|
|
434
|
-
`sudo kubectl create secret generic mongodb-secret --from-file=username=/home/dd/engine/engine-private/mongodb-username --from-file=password=/home/dd/engine/engine-private/mongodb-password --dry-run=client -o yaml | kubectl apply -f
|
|
457
|
+
`sudo kubectl create secret generic mongodb-secret --from-file=username=/home/dd/engine/engine-private/mongodb-username --from-file=password=/home/dd/engine/engine-private/mongodb-password --dry-run=client -o yaml | kubectl apply -f - -n ${options.namespace}`,
|
|
435
458
|
);
|
|
436
|
-
shellExec(`kubectl delete statefulset mongodb --ignore-not-found`);
|
|
437
|
-
shellExec(`kubectl apply -f ${underpostRoot}/manifests/mongodb/storage-class.yaml`);
|
|
438
|
-
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mongodb`);
|
|
459
|
+
shellExec(`kubectl delete statefulset mongodb -n ${options.namespace} --ignore-not-found`);
|
|
460
|
+
shellExec(`kubectl apply -f ${underpostRoot}/manifests/mongodb/storage-class.yaml -n ${options.namespace}`);
|
|
461
|
+
shellExec(`kubectl apply -k ${underpostRoot}/manifests/mongodb -n ${options.namespace}`);
|
|
439
462
|
|
|
440
463
|
const successInstance = await UnderpostTest.API.statusMonitor('mongodb-0', 'Running', 'pods', 1000, 60 * 10);
|
|
441
464
|
|
|
@@ -456,10 +479,12 @@ EOF
|
|
|
456
479
|
}
|
|
457
480
|
|
|
458
481
|
if (options.full === true || options.contour === true) {
|
|
459
|
-
shellExec(`kubectl apply -f https://projectcontour.io/quickstart/contour.yaml`);
|
|
482
|
+
shellExec(`kubectl apply -f https://projectcontour.io/quickstart/contour.yaml -n ${options.namespace}`);
|
|
460
483
|
if (options.kubeadm === true) {
|
|
461
484
|
// Envoy service might need NodePort for kubeadm
|
|
462
|
-
shellExec(
|
|
485
|
+
shellExec(
|
|
486
|
+
`sudo kubectl apply -f ${underpostRoot}/manifests/envoy-service-nodeport.yaml -n ${options.namespace}`,
|
|
487
|
+
);
|
|
463
488
|
}
|
|
464
489
|
// K3s has a built-in LoadBalancer (Klipper-lb) that can expose services,
|
|
465
490
|
// so a specific NodePort service might not be needed or can be configured differently.
|
|
@@ -479,7 +504,7 @@ EOF
|
|
|
479
504
|
|
|
480
505
|
const letsEncName = 'letsencrypt-prod';
|
|
481
506
|
shellExec(`sudo kubectl delete ClusterIssuer ${letsEncName} --ignore-not-found`);
|
|
482
|
-
shellExec(`sudo kubectl apply -f ${underpostRoot}/manifests/${letsEncName}.yaml`);
|
|
507
|
+
shellExec(`sudo kubectl apply -f ${underpostRoot}/manifests/${letsEncName}.yaml -n ${options.namespace}`);
|
|
483
508
|
}
|
|
484
509
|
},
|
|
485
510
|
|
package/src/cli/deploy.js
CHANGED
|
@@ -129,10 +129,11 @@ class UnderpostDeploy {
|
|
|
129
129
|
* @param {object} resources - Resource configuration for the deployment.
|
|
130
130
|
* @param {number} replicas - Number of replicas for the deployment.
|
|
131
131
|
* @param {string} image - Docker image for the deployment.
|
|
132
|
+
* @param {string} namespace - Kubernetes namespace for the deployment.
|
|
132
133
|
* @returns {string} - YAML deployment configuration for the specified deployment.
|
|
133
134
|
* @memberof UnderpostDeploy
|
|
134
135
|
*/
|
|
135
|
-
deploymentYamlPartsFactory({ deployId, env, suffix, resources, replicas, image }) {
|
|
136
|
+
deploymentYamlPartsFactory({ deployId, env, suffix, resources, replicas, image, namespace }) {
|
|
136
137
|
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
|
|
137
138
|
let volumes = [
|
|
138
139
|
{
|
|
@@ -149,6 +150,7 @@ class UnderpostDeploy {
|
|
|
149
150
|
kind: Deployment
|
|
150
151
|
metadata:
|
|
151
152
|
name: ${deployId}-${env}-${suffix}
|
|
153
|
+
namespace: ${namespace ? namespace : 'default'}
|
|
152
154
|
labels:
|
|
153
155
|
app: ${deployId}-${env}-${suffix}
|
|
154
156
|
spec:
|
|
@@ -188,6 +190,7 @@ apiVersion: v1
|
|
|
188
190
|
kind: Service
|
|
189
191
|
metadata:
|
|
190
192
|
name: ${deployId}-${env}-${suffix}-service
|
|
193
|
+
namespace: ${namespace}
|
|
191
194
|
spec:
|
|
192
195
|
selector:
|
|
193
196
|
app: ${deployId}-${env}-${suffix}
|
|
@@ -201,6 +204,7 @@ spec:
|
|
|
201
204
|
* @param {object} options - Options for the manifest build process.
|
|
202
205
|
* @param {string} options.replicas - Number of replicas for each deployment.
|
|
203
206
|
* @param {string} options.image - Docker image for the deployment.
|
|
207
|
+
* @param {string} options.namespace - Kubernetes namespace for the deployment.
|
|
204
208
|
* @returns {Promise<void>} - Promise that resolves when the manifest is built.
|
|
205
209
|
* @memberof UnderpostDeploy
|
|
206
210
|
*/
|
|
@@ -208,6 +212,7 @@ spec:
|
|
|
208
212
|
const resources = UnderpostDeploy.API.resourcesFactory();
|
|
209
213
|
const replicas = options.replicas;
|
|
210
214
|
const image = options.image;
|
|
215
|
+
if (!options.namespace) options.namespace = 'default';
|
|
211
216
|
|
|
212
217
|
for (const _deployId of deployList.split(',')) {
|
|
213
218
|
const deployId = _deployId.trim();
|
|
@@ -235,6 +240,7 @@ ${UnderpostDeploy.API.deploymentYamlPartsFactory({
|
|
|
235
240
|
resources,
|
|
236
241
|
replicas,
|
|
237
242
|
image,
|
|
243
|
+
namespace: options.namespace,
|
|
238
244
|
}).replace('{{ports}}', buildKindPorts(fromPort, toPort))}
|
|
239
245
|
`;
|
|
240
246
|
}
|
|
@@ -257,6 +263,7 @@ apiVersion: projectcontour.io/v1
|
|
|
257
263
|
kind: HTTPProxy
|
|
258
264
|
metadata:
|
|
259
265
|
name: ${host}
|
|
266
|
+
namespace: ${options.namespace}
|
|
260
267
|
spec:
|
|
261
268
|
virtualhost:
|
|
262
269
|
fqdn: ${host}${
|
|
@@ -316,16 +323,18 @@ spec:
|
|
|
316
323
|
/**
|
|
317
324
|
* Builds a Certificate resource for a host using cert-manager.
|
|
318
325
|
* @param {string} host - Hostname for which the certificate is being built.
|
|
326
|
+
* @param {string} namespace - Kubernetes namespace for the certificate.
|
|
319
327
|
* @returns {string} - Certificate resource YAML for the specified host.
|
|
320
328
|
* @memberof UnderpostDeploy
|
|
321
329
|
*/
|
|
322
|
-
buildCertManagerCertificate({ host }) {
|
|
330
|
+
buildCertManagerCertificate({ host, namespace }) {
|
|
323
331
|
return `
|
|
324
332
|
---
|
|
325
333
|
apiVersion: cert-manager.io/v1
|
|
326
334
|
kind: Certificate
|
|
327
335
|
metadata:
|
|
328
336
|
name: ${host}
|
|
337
|
+
namespace: ${namespace}
|
|
329
338
|
spec:
|
|
330
339
|
commonName: ${host}
|
|
331
340
|
dnsNames:
|
|
@@ -376,6 +385,7 @@ spec:
|
|
|
376
385
|
* @param {boolean} options.status - Whether to display deployment status.
|
|
377
386
|
* @param {boolean} options.etcHosts - Whether to display the /etc/hosts file.
|
|
378
387
|
* @param {boolean} options.disableUpdateUnderpostConfig - Whether to disable Underpost config updates.
|
|
388
|
+
* @param {string} [options.namespace] - Kubernetes namespace for the deployment.
|
|
379
389
|
* @returns {Promise<void>} - Promise that resolves when the deployment process is complete.
|
|
380
390
|
* @memberof UnderpostDeploy
|
|
381
391
|
*/
|
|
@@ -404,6 +414,7 @@ spec:
|
|
|
404
414
|
status: false,
|
|
405
415
|
etcHosts: false,
|
|
406
416
|
disableUpdateUnderpostConfig: false,
|
|
417
|
+
namespace: '',
|
|
407
418
|
},
|
|
408
419
|
) {
|
|
409
420
|
if (options.infoUtil === true)
|
|
@@ -437,7 +448,6 @@ kubectl wait --for=jsonpath='{.status.phase}'=Running pod/busybox1
|
|
|
437
448
|
kubectl wait --for='jsonpath={.status.conditions[?(@.type=="Ready")].status}=True' pod/busybox1
|
|
438
449
|
kubectl wait --for=delete pod/busybox1 --timeout=60s
|
|
439
450
|
|
|
440
|
-
fqdn: <service>.<namespace>.<kind(svc/pod)>.<cluster-domain(cluster.local)>
|
|
441
451
|
node bin run cluster-build
|
|
442
452
|
node bin run template-deploy
|
|
443
453
|
node bin run ssh-deploy (sync-)engine-core
|
|
@@ -447,6 +457,7 @@ node bin run promote dd-default production
|
|
|
447
457
|
node bin dockerfile-pull-base-images --dev --path 'image-path' --kind-load
|
|
448
458
|
node bin/deploy update-default-conf <deploy-id>
|
|
449
459
|
|
|
460
|
+
fqdn: <service>.<namespace>.<kind(svc/pod)>.<cluster-domain(cluster.local)>
|
|
450
461
|
kubectl run --rm -it test-dns --image=busybox:latest --restart=Never -- /bin/sh -c "
|
|
451
462
|
nslookup kubernetes.default.svc.cluster.local;
|
|
452
463
|
nslookup mongodb-service.default.svc.cluster.local;
|
|
@@ -468,10 +479,11 @@ docker login nvcr.io
|
|
|
468
479
|
Username: $oauthtoken
|
|
469
480
|
Password: <Your Key>
|
|
470
481
|
`);
|
|
482
|
+
const namespace = options.namespace ? options.namespace : 'default';
|
|
471
483
|
if (!deployList && options.certHosts) {
|
|
472
484
|
for (const host of options.certHosts.split(',')) {
|
|
473
|
-
shellExec(`sudo kubectl apply -f - <<EOF
|
|
474
|
-
${UnderpostDeploy.API.buildCertManagerCertificate({ host })}
|
|
485
|
+
shellExec(`sudo kubectl apply -f - -n ${namespace} <<EOF
|
|
486
|
+
${UnderpostDeploy.API.buildCertManagerCertificate({ host, namespace })}
|
|
475
487
|
EOF`);
|
|
476
488
|
}
|
|
477
489
|
return;
|
|
@@ -540,8 +552,12 @@ EOF`);
|
|
|
540
552
|
|
|
541
553
|
if (!options.disableUpdateDeployment)
|
|
542
554
|
for (const version of options.versions.split(',')) {
|
|
543
|
-
shellExec(
|
|
544
|
-
|
|
555
|
+
shellExec(
|
|
556
|
+
`sudo kubectl delete svc ${deployId}-${env}-${version}-service -n ${namespace} --ignore-not-found`,
|
|
557
|
+
);
|
|
558
|
+
shellExec(
|
|
559
|
+
`sudo kubectl delete deployment ${deployId}-${env}-${version} -n ${namespace} --ignore-not-found`,
|
|
560
|
+
);
|
|
545
561
|
if (!options.disableUpdateVolume) {
|
|
546
562
|
for (const volume of confVolume) {
|
|
547
563
|
const pvcId = `${volume.claimName}-${deployId}-${env}-${version}`;
|
|
@@ -549,9 +565,9 @@ EOF`);
|
|
|
549
565
|
const rootVolumeHostPath = `/home/dd/engine/volume/${pvId}`;
|
|
550
566
|
if (!fs.existsSync(rootVolumeHostPath)) fs.mkdirSync(rootVolumeHostPath, { recursive: true });
|
|
551
567
|
fs.copySync(volume.volumeMountPath, rootVolumeHostPath);
|
|
552
|
-
shellExec(`kubectl delete pvc ${pvcId}`);
|
|
553
|
-
shellExec(`kubectl delete pv ${pvId}`);
|
|
554
|
-
shellExec(`kubectl apply -f - <<EOF
|
|
568
|
+
shellExec(`kubectl delete pvc ${pvcId} -n ${namespace} --ignore-not-found`);
|
|
569
|
+
shellExec(`kubectl delete pv ${pvId} --ignore-not-found`);
|
|
570
|
+
shellExec(`kubectl apply -f - -n ${namespace} <<EOF
|
|
555
571
|
${UnderpostDeploy.API.persistentVolumeFactory({
|
|
556
572
|
hostPath: rootVolumeHostPath,
|
|
557
573
|
pvcId,
|
|
@@ -564,9 +580,9 @@ EOF
|
|
|
564
580
|
|
|
565
581
|
for (const host of Object.keys(confServer)) {
|
|
566
582
|
if (!options.disableUpdateProxy) {
|
|
567
|
-
shellExec(`sudo kubectl delete HTTPProxy ${host}`);
|
|
583
|
+
shellExec(`sudo kubectl delete HTTPProxy ${host} -n ${namespace} --ignore-not-found`);
|
|
568
584
|
if (UnderpostDeploy.API.isValidTLSContext({ host, env, options }))
|
|
569
|
-
shellExec(`sudo kubectl delete Certificate ${host}`);
|
|
585
|
+
shellExec(`sudo kubectl delete Certificate ${host} -n ${namespace} --ignore-not-found`);
|
|
570
586
|
}
|
|
571
587
|
if (!options.remove) etcHosts.push(host);
|
|
572
588
|
}
|
|
@@ -577,11 +593,13 @@ EOF
|
|
|
577
593
|
: `manifests/deployment/${deployId}-${env}`;
|
|
578
594
|
|
|
579
595
|
if (!options.remove) {
|
|
580
|
-
if (!options.disableUpdateDeployment)
|
|
581
|
-
|
|
596
|
+
if (!options.disableUpdateDeployment)
|
|
597
|
+
shellExec(`sudo kubectl apply -f ./${manifestsPath}/deployment.yaml -n ${namespace}`);
|
|
598
|
+
if (!options.disableUpdateProxy)
|
|
599
|
+
shellExec(`sudo kubectl apply -f ./${manifestsPath}/proxy.yaml -n ${namespace}`);
|
|
582
600
|
|
|
583
601
|
if (UnderpostDeploy.API.isValidTLSContext({ host: Object.keys(confServer)[0], env, options }))
|
|
584
|
-
shellExec(`sudo kubectl apply -f ./${manifestsPath}/secret.yaml`);
|
|
602
|
+
shellExec(`sudo kubectl apply -f ./${manifestsPath}/secret.yaml -n ${namespace}`);
|
|
585
603
|
}
|
|
586
604
|
}
|
|
587
605
|
if (options.etcHosts === true) {
|
|
@@ -598,15 +616,19 @@ EOF
|
|
|
598
616
|
* Retrieves information about a deployment.
|
|
599
617
|
* @param {string} deployId - Deployment ID for which information is being retrieved.
|
|
600
618
|
* @param {string} kindType - Type of Kubernetes resource to retrieve information for (e.g. 'pods').
|
|
619
|
+
* @param {string} namespace - Kubernetes namespace to retrieve information from.
|
|
601
620
|
* @returns {Array<object>} - Array of objects containing information about the deployment.
|
|
602
621
|
* @memberof UnderpostDeploy
|
|
603
622
|
*/
|
|
604
|
-
get(deployId, kindType = 'pods') {
|
|
605
|
-
const raw = shellExec(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
623
|
+
get(deployId, kindType = 'pods', namespace = '') {
|
|
624
|
+
const raw = shellExec(
|
|
625
|
+
`sudo kubectl get ${kindType}${namespace ? ` -n ${namespace}` : ` --all-namespaces`} -o wide`,
|
|
626
|
+
{
|
|
627
|
+
stdout: true,
|
|
628
|
+
disableLog: true,
|
|
629
|
+
silent: true,
|
|
630
|
+
},
|
|
631
|
+
);
|
|
610
632
|
|
|
611
633
|
const heads = raw
|
|
612
634
|
.split(`\n`)[0]
|
|
@@ -720,12 +742,13 @@ EOF
|
|
|
720
742
|
/**
|
|
721
743
|
* Creates a configmap for a deployment.
|
|
722
744
|
* @param {string} env - Environment for which the configmap is being created.
|
|
745
|
+
* @param {string} [namespace='default'] - Kubernetes namespace for the configmap.
|
|
723
746
|
* @memberof UnderpostDeploy
|
|
724
747
|
*/
|
|
725
|
-
configMap(env) {
|
|
726
|
-
shellExec(`kubectl delete configmap underpost-config`);
|
|
748
|
+
configMap(env, namespace = 'default') {
|
|
749
|
+
shellExec(`kubectl delete configmap underpost-config -n ${namespace} --ignore-not-found`);
|
|
727
750
|
shellExec(
|
|
728
|
-
`kubectl create configmap underpost-config --from-file=/home/dd/engine/engine-private/conf/dd-cron/.env.${env}`,
|
|
751
|
+
`kubectl create configmap underpost-config --from-file=/home/dd/engine/engine-private/conf/dd-cron/.env.${env} --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`,
|
|
729
752
|
);
|
|
730
753
|
},
|
|
731
754
|
/**
|
|
@@ -734,14 +757,15 @@ EOF
|
|
|
734
757
|
* @param {string} env - Environment for which the traffic is being switched.
|
|
735
758
|
* @param {string} targetTraffic - Target traffic status for the deployment.
|
|
736
759
|
* @param {number} replicas - Number of replicas for the deployment.
|
|
760
|
+
* @param {string} [namespace='default'] - Kubernetes namespace for the deployment.
|
|
737
761
|
* @memberof UnderpostDeploy
|
|
738
762
|
*/
|
|
739
|
-
switchTraffic(deployId, env, targetTraffic, replicas = 1) {
|
|
763
|
+
switchTraffic(deployId, env, targetTraffic, replicas = 1, namespace = 'default') {
|
|
740
764
|
UnderpostRootEnv.API.set(`${deployId}-${env}-traffic`, targetTraffic);
|
|
741
765
|
shellExec(
|
|
742
|
-
`node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} ${deployId} ${env}`,
|
|
766
|
+
`node bin deploy --info-router --build-manifest --traffic ${targetTraffic} --replicas ${replicas} --namespace ${namespace} ${deployId} ${env}`,
|
|
743
767
|
);
|
|
744
|
-
shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml`);
|
|
768
|
+
shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
|
|
745
769
|
},
|
|
746
770
|
|
|
747
771
|
/**
|
|
@@ -909,7 +933,7 @@ ${renderHosts}`,
|
|
|
909
933
|
getCurrentLoadedImages(node = 'kind-worker', specContainers = false) {
|
|
910
934
|
if (specContainers) {
|
|
911
935
|
const raw = shellExec(
|
|
912
|
-
`kubectl get pods --all-namespaces -o=jsonpath='{range .items[*]}{"\\n"}{.metadata.name}{":\\t"}{range .spec.containers[*]}{.image}{", "}{end}{end}'`,
|
|
936
|
+
`kubectl get pods --all-namespaces -o=jsonpath='{range .items[*]}{"\\n"}{.metadata.namespace}{"/"}{.metadata.name}{":\\t"}{range .spec.containers[*]}{.image}{", "}{end}{end}'`,
|
|
913
937
|
{
|
|
914
938
|
stdout: true,
|
|
915
939
|
silent: true,
|
package/src/cli/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import { commitData } from '../client/components/core/CommonJs.js';
|
|
|
7
7
|
import UnderpostLxd from './lxd.js';
|
|
8
8
|
import UnderpostBaremetal from './baremetal.js';
|
|
9
9
|
import UnderpostRun from './run.js';
|
|
10
|
+
import Dns from '../server/dns.js';
|
|
11
|
+
import { pbcopy } from '../server/process.js';
|
|
10
12
|
|
|
11
13
|
// Load environment variables from .env file
|
|
12
14
|
const underpostRootPath = getUnderpostRootPath();
|
|
@@ -129,6 +131,16 @@ program
|
|
|
129
131
|
.description('Displays the root path of the npm installation.')
|
|
130
132
|
.action(() => console.log(getNpmRootPath()));
|
|
131
133
|
|
|
134
|
+
program
|
|
135
|
+
.command('ip')
|
|
136
|
+
.option('--copy', 'Copies the IP addresses to the clipboard.')
|
|
137
|
+
.description('Displays the current public machine IP addresses.')
|
|
138
|
+
.action(async (options) => {
|
|
139
|
+
const ip = await Dns.getPublicIp();
|
|
140
|
+
if (options.copy) return pbcopy(ip);
|
|
141
|
+
return console.log(ip);
|
|
142
|
+
});
|
|
143
|
+
|
|
132
144
|
// 'cluster' command: Manage Kubernetes clusters
|
|
133
145
|
program
|
|
134
146
|
.command('cluster')
|
|
@@ -146,7 +158,10 @@ program
|
|
|
146
158
|
.option('--dedicated-gpu', 'Initializes the cluster with dedicated GPU base resources and environment settings.')
|
|
147
159
|
.option('--info', 'Retrieves information about all deployed Kubernetes objects.')
|
|
148
160
|
.option('--full', 'Initializes the cluster with all available statefulsets and services.')
|
|
149
|
-
.option(
|
|
161
|
+
.option(
|
|
162
|
+
'--ns-use <ns-name>',
|
|
163
|
+
"Switches the current Kubernetes context to the specified namespace (creates if it doesn't exist).",
|
|
164
|
+
)
|
|
150
165
|
.option('--kubeadm', 'Initializes the cluster using kubeadm for control plane management.')
|
|
151
166
|
.option('--grafana', 'Initializes the cluster with a Grafana deployment.')
|
|
152
167
|
.option(
|
|
@@ -166,6 +181,7 @@ program
|
|
|
166
181
|
.option('--k3s', 'Initializes the cluster using K3s (Lightweight Kubernetes).')
|
|
167
182
|
.option('--hosts <hosts>', 'A comma-separated list of cluster hostnames or IP addresses.')
|
|
168
183
|
.option('--remove-volume-host-paths', 'Removes specified volume host paths after execution.')
|
|
184
|
+
.option('--namespace <namespace>', 'Kubernetes namespace for cluster operations (defaults to "default").')
|
|
169
185
|
.action(Underpost.cluster.init)
|
|
170
186
|
.description('Manages Kubernetes clusters, defaulting to Kind cluster initialization.');
|
|
171
187
|
|
|
@@ -205,6 +221,7 @@ program
|
|
|
205
221
|
.option('--etc-hosts', 'Enables the etc-hosts context for deployment operations.')
|
|
206
222
|
.option('--restore-hosts', 'Restores default `/etc/hosts` entries.')
|
|
207
223
|
.option('--disable-update-underpost-config', 'Disables updates to Underpost configuration during deployment.')
|
|
224
|
+
.option('--namespace <namespace>', 'Kubernetes namespace for deployment operations (defaults to "default").')
|
|
208
225
|
.description('Manages application deployments, defaulting to deploying development pods.')
|
|
209
226
|
.action(Underpost.deploy.callback);
|
|
210
227
|
|
|
@@ -402,6 +419,10 @@ program
|
|
|
402
419
|
.option('--runtime-class-name <name>', 'Sets the runtime class name for the job in deploy-job.')
|
|
403
420
|
.option('--image-pull-policy <policy>', 'Sets the image pull policy for the job in deploy-job.')
|
|
404
421
|
.option('--api-version <version>', 'Sets the API version for the job manifest in deploy-job.')
|
|
422
|
+
.option(
|
|
423
|
+
'--labels <labels>',
|
|
424
|
+
'Optional: Specifies a comma-separated list of key-value pairs for labels (e.g., "app=my-app,env=prod").',
|
|
425
|
+
)
|
|
405
426
|
.option('--claim-name <name>', 'Optional: Specifies the claim name for volume mounting in deploy-job.')
|
|
406
427
|
.option('--kind <kind-type>', 'Specifies the kind of Kubernetes resource (e.g., Job, Deployment) for deploy-job.')
|
|
407
428
|
.option('--kubeadm', 'Flag to indicate Kubeadm cluster type context')
|
package/src/cli/monitor.js
CHANGED
|
@@ -95,12 +95,13 @@ class UnderpostMonitor {
|
|
|
95
95
|
if (traffic === 'blue') traffic = 'green';
|
|
96
96
|
else traffic = 'blue';
|
|
97
97
|
UnderpostRootEnv.API.set(`${deployId}-${env}-traffic`, traffic);
|
|
98
|
+
const namespace = options.namespace || 'default';
|
|
98
99
|
shellExec(
|
|
99
100
|
`node bin deploy --info-router --build-manifest --traffic ${traffic} --replicas ${
|
|
100
101
|
options.replicas ? options.replicas : 1
|
|
101
|
-
} ${deployId} ${env}`,
|
|
102
|
+
} --namespace ${namespace} ${deployId} ${env}`,
|
|
102
103
|
);
|
|
103
|
-
shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml`);
|
|
104
|
+
shellExec(`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${env}/proxy.yaml -n ${namespace}`);
|
|
104
105
|
};
|
|
105
106
|
|
|
106
107
|
const monitor = async (reject) => {
|
|
@@ -152,12 +153,15 @@ class UnderpostMonitor {
|
|
|
152
153
|
fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.json`, 'utf8'),
|
|
153
154
|
);
|
|
154
155
|
|
|
155
|
-
|
|
156
|
+
const namespace = options.namespace || 'default';
|
|
157
|
+
UnderpostDeploy.API.configMap(env, namespace);
|
|
156
158
|
|
|
157
159
|
for (const host of Object.keys(confServer)) {
|
|
158
|
-
shellExec(`sudo kubectl delete HTTPProxy ${host}`);
|
|
160
|
+
shellExec(`sudo kubectl delete HTTPProxy ${host} -n ${namespace} --ignore-not-found`);
|
|
159
161
|
}
|
|
160
|
-
shellExec(
|
|
162
|
+
shellExec(
|
|
163
|
+
`sudo kubectl rollout restart deployment/${deployId}-${env}-${traffic} -n ${namespace}`,
|
|
164
|
+
);
|
|
161
165
|
|
|
162
166
|
switchTraffic();
|
|
163
167
|
}
|
package/src/cli/repository.js
CHANGED
|
@@ -179,7 +179,7 @@ class UnderpostRepository {
|
|
|
179
179
|
return;
|
|
180
180
|
}
|
|
181
181
|
if (options.info) return logger.info('', commitData);
|
|
182
|
-
const _message = `${commitType}${subModule ? `(${subModule})` : ''}
|
|
182
|
+
const _message = `${commitType}${subModule ? `(${subModule})` : ''}: ${
|
|
183
183
|
commitData[commitType].emoji
|
|
184
184
|
} ${message ? message : commitData[commitType].description}`;
|
|
185
185
|
if (options.copy) return pbcopy(_message);
|