underpost 3.2.8 → 3.2.10
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/npmpkg.ci.yml +1 -0
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -0
- package/.vscode/settings.json +10 -5
- package/CHANGELOG.md +223 -2
- package/CLI-HELP.md +36 -7
- package/README.md +38 -9
- package/bin/build.js +27 -11
- package/bin/deploy.js +20 -21
- package/bin/file.js +32 -13
- package/bin/index.js +2 -1
- package/bin/vs.js +1 -1
- package/bump.config.js +26 -0
- package/conf.js +20 -4
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +2 -2
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +2 -2
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +4 -2
- package/manifests/kind-config-dev.yaml +8 -0
- package/manifests/mongodb/pv-pvc.yaml +44 -8
- package/manifests/mongodb/statefulset.yaml +55 -68
- package/package.json +40 -25
- package/scripts/k3s-node-setup.sh +30 -11
- package/scripts/nat-iptables.sh +103 -18
- package/src/api/core/core.router.js +19 -14
- package/src/api/core/core.service.js +5 -5
- package/src/api/default/default.router.js +22 -18
- package/src/api/default/default.service.js +5 -5
- package/src/api/document/document.router.js +28 -23
- package/src/api/document/document.service.js +100 -23
- package/src/api/file/file.router.js +19 -13
- package/src/api/file/file.service.js +9 -7
- package/src/api/test/test.router.js +17 -12
- package/src/api/types.js +24 -0
- package/src/api/user/guest.service.js +5 -4
- package/src/api/user/user.router.js +297 -288
- package/src/api/user/user.service.js +100 -35
- package/src/cli/baremetal.js +20 -11
- package/src/cli/cluster.js +243 -55
- package/src/cli/db.js +106 -62
- package/src/cli/deploy.js +297 -154
- package/src/cli/fs.js +19 -3
- package/src/cli/index.js +37 -9
- package/src/cli/ipfs.js +4 -6
- package/src/cli/kubectl.js +4 -1
- package/src/cli/lxd.js +217 -135
- package/src/cli/release.js +289 -131
- package/src/cli/repository.js +91 -34
- package/src/cli/run.js +297 -56
- package/src/cli/test.js +9 -3
- package/src/client/Default.index.js +9 -3
- package/src/client/components/core/Auth.js +19 -5
- package/src/client/components/core/Docs.js +6 -34
- package/src/client/components/core/FileExplorer.js +6 -6
- package/src/client/components/core/Modal.js +65 -2
- package/src/client/components/core/PanelForm.js +56 -52
- package/src/client/components/core/Recover.js +4 -4
- package/src/client/components/core/Worker.js +170 -350
- package/src/client/services/default/default.management.js +20 -25
- package/src/client/services/user/guest.service.js +10 -3
- package/src/client/sw/core.sw.js +174 -112
- package/src/db/DataBaseProvider.js +120 -20
- package/src/db/mongo/MongoBootstrap.js +587 -0
- package/src/db/mongo/MongooseDB.js +126 -22
- package/src/index.js +1 -1
- package/src/runtime/express/Express.js +2 -2
- package/src/runtime/wp/Wp.js +8 -5
- package/src/server/auth.js +2 -2
- package/src/server/client-build-docs.js +1 -1
- package/src/server/client-build.js +94 -129
- package/src/server/conf.js +20 -65
- package/src/server/data-query.js +32 -20
- package/src/server/dns.js +22 -0
- package/src/server/process.js +180 -19
- package/src/server/runtime.js +1 -1
- package/src/server/start.js +26 -7
- package/src/server/valkey.js +9 -2
- package/src/ws/IoInterface.js +16 -16
- package/src/ws/core/channels/core.ws.chat.js +11 -11
- package/src/ws/core/channels/core.ws.mailer.js +29 -29
- package/src/ws/core/channels/core.ws.stream.js +19 -19
- package/src/ws/core/core.ws.connection.js +8 -8
- package/src/ws/core/core.ws.server.js +6 -5
- package/src/ws/default/channels/default.ws.main.js +10 -10
- package/src/ws/default/default.ws.connection.js +4 -4
- package/src/ws/default/default.ws.server.js +4 -3
- package/typedoc.json +10 -1
- package/src/client/ssr/email/DefaultRecoverEmail.js +0 -21
- package/src/client/ssr/email/DefaultVerifyEmail.js +0 -17
- /package/src/client/ssr/{offline → views}/Maintenance.js +0 -0
- /package/src/client/ssr/{offline → views}/NoNetworkConnection.js +0 -0
- /package/src/client/ssr/{pages → views}/Test.js +0 -0
package/src/cli/run.js
CHANGED
|
@@ -24,6 +24,7 @@ import { range, setPad, timer } from '../client/components/core/CommonJs.js';
|
|
|
24
24
|
import os from 'os';
|
|
25
25
|
import Underpost from '../index.js';
|
|
26
26
|
import dotenv from 'dotenv';
|
|
27
|
+
import { MongoBootstrap } from '../db/mongo/MongoBootstrap.js';
|
|
27
28
|
|
|
28
29
|
const waitForPort = (port, host = '127.0.0.1', { maxAttempts = 30, interval = 2000 } = {}) =>
|
|
29
30
|
new Promise((resolve, reject) => {
|
|
@@ -97,6 +98,7 @@ const logger = loggerFactory(import.meta);
|
|
|
97
98
|
* @property {string} deployId - The deployment ID.
|
|
98
99
|
* @property {string} instanceId - The instance ID.
|
|
99
100
|
* @property {string} user - The user to run as.
|
|
101
|
+
* @property {string} group - The group to use.
|
|
100
102
|
* @property {string} pid - The process ID.
|
|
101
103
|
* @property {boolean} disablePrivateConfUpdate - Whether to disable private configuration updates.
|
|
102
104
|
* @property {string} monitorStatus - The monitor status option.
|
|
@@ -110,6 +112,10 @@ const logger = loggerFactory(import.meta);
|
|
|
110
112
|
* @property {string|Array<{ip: string, hostnames: string[]}>} hostAliases - Adds entries to the Pod /etc/hosts via Kubernetes hostAliases.
|
|
111
113
|
* As a string (CLI): semicolon-separated entries of "ip=hostname1,hostname2" (e.g., "127.0.0.1=foo.local,bar.local;10.1.2.3=foo.remote").
|
|
112
114
|
* As an array (programmatic): objects with `ip` and `hostnames` fields (e.g., [{ ip: "127.0.0.1", hostnames: ["foo.local"] }]).
|
|
115
|
+
* @property {boolean} gitClean - Whether to perform a `git clean` before running.
|
|
116
|
+
* @property {boolean} copy - Whether to copy the command to the clipboard instead of executing it.
|
|
117
|
+
* @property {boolean} skipFullBuild - Whether to skip the full client bundle build during deployment (supported by: sync, template-deploy).
|
|
118
|
+
* @property {boolean} pullBundle - Whether to pull the bundle before running. Use together with --skip-full-build to skip the local build entirely (supported by: sync, template-deploy).
|
|
113
119
|
* @memberof UnderpostRun
|
|
114
120
|
*/
|
|
115
121
|
const DEFAULT_OPTION = {
|
|
@@ -161,6 +167,7 @@ const DEFAULT_OPTION = {
|
|
|
161
167
|
deployId: '',
|
|
162
168
|
instanceId: '',
|
|
163
169
|
user: '',
|
|
170
|
+
group: '',
|
|
164
171
|
pid: '',
|
|
165
172
|
disablePrivateConfUpdate: false,
|
|
166
173
|
monitorStatus: '',
|
|
@@ -174,6 +181,8 @@ const DEFAULT_OPTION = {
|
|
|
174
181
|
hostAliases: '',
|
|
175
182
|
gitClean: false,
|
|
176
183
|
copy: false,
|
|
184
|
+
skipFullBuild: false,
|
|
185
|
+
pullBundle: false,
|
|
177
186
|
};
|
|
178
187
|
|
|
179
188
|
/**
|
|
@@ -219,7 +228,7 @@ class UnderpostRun {
|
|
|
219
228
|
// Detect MongoDB primary pod using method
|
|
220
229
|
let primaryMongoHost = 'mongodb-0.mongodb-service';
|
|
221
230
|
try {
|
|
222
|
-
const primaryPodName =
|
|
231
|
+
const primaryPodName = MongoBootstrap.getPrimaryPodName({
|
|
223
232
|
namespace: options.namespace,
|
|
224
233
|
podName: 'mongodb-0',
|
|
225
234
|
});
|
|
@@ -435,6 +444,15 @@ class UnderpostRun {
|
|
|
435
444
|
deployType = 'init';
|
|
436
445
|
}
|
|
437
446
|
|
|
447
|
+
// If --build is set and path is a sync-engine-* target, push the pre-built client bundle
|
|
448
|
+
// to Cloudinary so the remote container can pull it instead of rebuilding from source.
|
|
449
|
+
if (options.build && deployConfId && deployConfId.startsWith('engine-')) {
|
|
450
|
+
const confName = deployConfId.replace(/^engine-/, '');
|
|
451
|
+
const pushDeployId = options.deployId || `dd-${confName}`;
|
|
452
|
+
logger.info(`[template-deploy] Running push-bundle for deployId: ${pushDeployId}`);
|
|
453
|
+
shellExec(`${baseCommand} run push-bundle --deploy-id ${pushDeployId}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
438
456
|
// Dispatch npmpkg CI workflow — this builds pwa-microservices-template first.
|
|
439
457
|
// If deployConfId is set, npmpkg.ci.yml will dispatch the engine-<conf-id> CI
|
|
440
458
|
// with sync=true after template build completes. The engine CI then dispatches
|
|
@@ -490,14 +508,16 @@ class UnderpostRun {
|
|
|
490
508
|
},
|
|
491
509
|
/**
|
|
492
510
|
* @method docker-image
|
|
493
|
-
* @description Dispatches the Docker image CI workflow (`docker-image.ci.yml`)
|
|
494
|
-
*
|
|
511
|
+
* @description Dispatches the Docker image CI workflow (`docker-image[.<runtime>].ci.yml`) via `workflow_dispatch`.
|
|
512
|
+
* Repository resolution is delegated to `Underpost.repo.resolveInstanceRepo(path)`.
|
|
513
|
+
* @param {string} path - Optional runtime / workflow suffix (e.g. `cyberia-server`, `cyberia-client`).
|
|
495
514
|
* @param {Object} options - The default underpost runner options for customizing workflow
|
|
496
515
|
* @memberof UnderpostRun
|
|
497
516
|
*/
|
|
498
517
|
'docker-image': (path, options = DEFAULT_OPTION) => {
|
|
518
|
+
const repo = Underpost.repo.resolveInstanceRepo(path);
|
|
499
519
|
Underpost.repo.dispatchWorkflow({
|
|
500
|
-
repo
|
|
520
|
+
repo,
|
|
501
521
|
workflowFile: `docker-image${path ? `.${path}` : ''}.ci.yml`,
|
|
502
522
|
ref: 'master',
|
|
503
523
|
inputs: {},
|
|
@@ -521,16 +541,14 @@ class UnderpostRun {
|
|
|
521
541
|
* @memberof UnderpostRun
|
|
522
542
|
*/
|
|
523
543
|
pull: (path, options = DEFAULT_OPTION) => {
|
|
544
|
+
// shellExec is fail-fast by default — any non-zero exit throws and
|
|
545
|
+
// propagates up to the workflow step. No per-call flag required.
|
|
524
546
|
if (!fs.existsSync(`/home/dd`) || !fs.existsSync(`/home/dd/engine`)) {
|
|
525
547
|
fs.mkdirSync(`/home/dd`, { recursive: true });
|
|
526
|
-
shellExec(`cd /home/dd && underpost clone ${process.env.GITHUB_USERNAME}/engine`, {
|
|
527
|
-
silent: true,
|
|
528
|
-
});
|
|
548
|
+
shellExec(`cd /home/dd && underpost clone ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
|
|
529
549
|
} else {
|
|
530
550
|
shellExec(`underpost run clean`);
|
|
531
|
-
shellExec(`cd /home/dd/engine && underpost pull . ${process.env.GITHUB_USERNAME}/engine`, {
|
|
532
|
-
silent: true,
|
|
533
|
-
});
|
|
551
|
+
shellExec(`cd /home/dd/engine && underpost pull . ${process.env.GITHUB_USERNAME}/engine`, { silent: true });
|
|
534
552
|
}
|
|
535
553
|
if (!fs.existsSync(`/home/dd/engine/engine-private`))
|
|
536
554
|
shellExec(`cd /home/dd/engine && underpost clone ${process.env.GITHUB_USERNAME}/engine-private`, {
|
|
@@ -539,9 +557,7 @@ class UnderpostRun {
|
|
|
539
557
|
else
|
|
540
558
|
shellExec(
|
|
541
559
|
`cd /home/dd/engine/engine-private && underpost pull . ${process.env.GITHUB_USERNAME}/engine-private`,
|
|
542
|
-
{
|
|
543
|
-
silent: true,
|
|
544
|
-
},
|
|
560
|
+
{ silent: true },
|
|
545
561
|
);
|
|
546
562
|
},
|
|
547
563
|
/**
|
|
@@ -628,6 +644,11 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
|
|
|
628
644
|
/**
|
|
629
645
|
* @method sync
|
|
630
646
|
* @description Cleans up, and then runs a deployment synchronization command (`underpost deploy --kubeadm --build-manifest --sync...`) using parameters parsed from `path` (deployId, replicas, versions, image, node).
|
|
647
|
+
*
|
|
648
|
+
* Forwards `--image-pull-policy <policy>` to the underlying `deploy --build-manifest` invocation when `options.imagePullPolicy` is set,
|
|
649
|
+
* which then plumbs through `buildManifest` and `deploymentYamlPartsFactory` to override the container `imagePullPolicy` in the generated
|
|
650
|
+
* `deployment.yaml`. Useful when you want to force `Always` so the kubelet re-pulls a mutable tag on every rollout. Example:
|
|
651
|
+
* `node bin run sync dd-core --kubeadm --image-pull-policy Always`
|
|
631
652
|
* @param {string} path - The input value, identifier, or path for the operation (used as a comma-separated string containing deploy parameters).
|
|
632
653
|
* @param {Object} options - The default underpost runner options for customizing workflow
|
|
633
654
|
* @memberof UnderpostRun
|
|
@@ -683,12 +704,16 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
|
|
|
683
704
|
: '';
|
|
684
705
|
const gitCleanFlag = options.gitClean ? ' --git-clean' : '';
|
|
685
706
|
|
|
707
|
+
const skipFullBuildFlag = options.skipFullBuild ? ' --skip-full-build' : '';
|
|
708
|
+
const pullBundleFlag = options.pullBundle ? ' --pull-bundle' : '';
|
|
709
|
+
const imagePullPolicyFlag = options.imagePullPolicy ? ` --image-pull-policy ${options.imagePullPolicy}` : '';
|
|
710
|
+
|
|
686
711
|
shellExec(
|
|
687
712
|
`${baseCommand} deploy${clusterFlag} --build-manifest --sync --info-router --replicas ${replicas} --node ${node}${
|
|
688
713
|
image ? ` --image ${image}` : ''
|
|
689
714
|
}${versions ? ` --versions ${versions}` : ''}${
|
|
690
715
|
options.namespace ? ` --namespace ${options.namespace}` : ''
|
|
691
|
-
}${timeoutFlags}${cmdString}${gitCleanFlag} ${deployId} ${env}`,
|
|
716
|
+
}${timeoutFlags}${cmdString}${gitCleanFlag}${skipFullBuildFlag}${pullBundleFlag}${imagePullPolicyFlag} ${deployId} ${env}`,
|
|
692
717
|
);
|
|
693
718
|
|
|
694
719
|
if (isDeployRunnerContext(path, options)) {
|
|
@@ -699,7 +724,7 @@ echo -e "[code]\nname=Visual Studio Code\nbaseurl=https://packages.microsoft.com
|
|
|
699
724
|
shellExec(
|
|
700
725
|
`${baseCommand} deploy${clusterFlag}${cmdString} --replicas ${replicas} --disable-update-proxy ${deployId} ${env} --versions ${versions}${
|
|
701
726
|
options.namespace ? ` --namespace ${options.namespace}` : ''
|
|
702
|
-
}${timeoutFlags}${gitCleanFlag}`,
|
|
727
|
+
}${timeoutFlags}${gitCleanFlag}${imagePullPolicyFlag}`,
|
|
703
728
|
);
|
|
704
729
|
if (!targetTraffic)
|
|
705
730
|
targetTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
|
|
@@ -1005,6 +1030,9 @@ EOF
|
|
|
1005
1030
|
cmd: _cmd,
|
|
1006
1031
|
volumes: _volumes,
|
|
1007
1032
|
metadata: _metadata,
|
|
1033
|
+
lifecycle: _lifecycle,
|
|
1034
|
+
readinessProbe: _readinessProbe,
|
|
1035
|
+
livenessProbe: _livenessProbe,
|
|
1008
1036
|
} = instance;
|
|
1009
1037
|
if (id !== _id) continue;
|
|
1010
1038
|
const _deployId = `${deployId}-${_id}`;
|
|
@@ -1069,6 +1097,20 @@ EOF
|
|
|
1069
1097
|
),
|
|
1070
1098
|
);
|
|
1071
1099
|
|
|
1100
|
+
// Resolve env-scoped lifecycle/probe blocks: each can be either
|
|
1101
|
+
// { ...envObj } // shared shape
|
|
1102
|
+
// { development: {...}, production: {...} } // env-specific
|
|
1103
|
+
const pickEnv = (v) => (v && (v.development || v.production) ? v[env] : v);
|
|
1104
|
+
|
|
1105
|
+
// Convention: an instance config may place `imagePullPolicy` inside
|
|
1106
|
+
// the env-scoped lifecycle block (alongside postStart/preStop).
|
|
1107
|
+
// Extract it onto the container spec (where K8S expects it) and
|
|
1108
|
+
// strip it from the lifecycle hash so the rendered YAML stays valid.
|
|
1109
|
+
// CLI override (`--image-pull-policy`) wins over the conf value.
|
|
1110
|
+
const { lifecycle: lifecycleForManifest, imagePullPolicy: lifecycleImagePullPolicy } =
|
|
1111
|
+
Underpost.deploy.extractInstanceImagePullPolicy(pickEnv(_lifecycle));
|
|
1112
|
+
const instanceImagePullPolicy = options.imagePullPolicy || lifecycleImagePullPolicy;
|
|
1113
|
+
|
|
1072
1114
|
let deploymentYaml = `---
|
|
1073
1115
|
${Underpost.deploy
|
|
1074
1116
|
.deploymentYamlPartsFactory({
|
|
@@ -1081,6 +1123,11 @@ ${Underpost.deploy
|
|
|
1081
1123
|
namespace: options.namespace,
|
|
1082
1124
|
volumes: _volumes,
|
|
1083
1125
|
cmd: resolvedCmd,
|
|
1126
|
+
lifecycle: lifecycleForManifest,
|
|
1127
|
+
readinessProbe: pickEnv(_readinessProbe),
|
|
1128
|
+
livenessProbe: pickEnv(_livenessProbe),
|
|
1129
|
+
containerPort: _toPort,
|
|
1130
|
+
imagePullPolicy: instanceImagePullPolicy,
|
|
1084
1131
|
})
|
|
1085
1132
|
.replace('{{ports}}', buildKindPorts(_fromPort, _toPort))}
|
|
1086
1133
|
`;
|
|
@@ -1169,14 +1216,34 @@ EOF
|
|
|
1169
1216
|
volumes: _volumes,
|
|
1170
1217
|
metadata: _metadata,
|
|
1171
1218
|
runtime: _runtime,
|
|
1219
|
+
lifecycle: _lifecycle,
|
|
1220
|
+
readinessProbe: _readinessProbe,
|
|
1221
|
+
livenessProbe: _livenessProbe,
|
|
1172
1222
|
} = instance;
|
|
1173
1223
|
|
|
1174
|
-
// Resolve Dockerfile source
|
|
1175
|
-
|
|
1176
|
-
|
|
1224
|
+
// Resolve Dockerfile source. Dev/prod variant rules:
|
|
1225
|
+
// - When the instance defines a `runtime`, look under
|
|
1226
|
+
// `src/runtime/<runtime>/`. In `--dev` mode prefer `Dockerfile.dev`
|
|
1227
|
+
// when it exists, falling back to `Dockerfile`.
|
|
1228
|
+
// - When `runtime` is not set, look in the project root with the
|
|
1229
|
+
// same `.dev` → no-suffix precedence.
|
|
1230
|
+
// Dockerfile.dev is a full Dockerfile (not an overlay) — each runtime
|
|
1231
|
+
// owns the contract between its dev image and its prod image (debug
|
|
1232
|
+
// build flags, extra tooling, default ports, etc.).
|
|
1233
|
+
const dockerfileBase = _runtime ? `src/runtime/${_runtime}` : rootPath;
|
|
1234
|
+
const dockerfileCandidates = options.dev
|
|
1235
|
+
? [`${dockerfileBase}/Dockerfile.dev`, `${dockerfileBase}/Dockerfile`]
|
|
1236
|
+
: [`${dockerfileBase}/Dockerfile`];
|
|
1237
|
+
const dockerfileSourcePath = dockerfileCandidates.find((p) => fs.existsSync(p));
|
|
1238
|
+
if (dockerfileSourcePath) {
|
|
1239
|
+
if (options.dev && !dockerfileSourcePath.endsWith('.dev')) {
|
|
1240
|
+
logger.warn(
|
|
1241
|
+
`[instance-build-manifest] --dev requested but no Dockerfile.dev present; falling back to ${dockerfileSourcePath}`,
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1177
1244
|
fs.copyFileSync(dockerfileSourcePath, dockerfileManifestPath);
|
|
1178
1245
|
} else {
|
|
1179
|
-
logger.warn(`[instance-build-manifest] Dockerfile not found
|
|
1246
|
+
logger.warn(`[instance-build-manifest] Dockerfile not found; tried: ${dockerfileCandidates.join(', ')}`);
|
|
1180
1247
|
}
|
|
1181
1248
|
|
|
1182
1249
|
const _deployId = `${deployId}-${_id}`;
|
|
@@ -1221,6 +1288,17 @@ EOF
|
|
|
1221
1288
|
),
|
|
1222
1289
|
);
|
|
1223
1290
|
|
|
1291
|
+
// Env-aware lifecycle / probe selection. Each block may either be
|
|
1292
|
+
// a single object (shared across envs) or `{ development, production }`.
|
|
1293
|
+
const pickEnv = (v) => (v && (v.development || v.production) ? v[env] : v);
|
|
1294
|
+
|
|
1295
|
+
// Convention: an instance config may place `imagePullPolicy` inside
|
|
1296
|
+
// the env-scoped lifecycle block (alongside postStart/preStop).
|
|
1297
|
+
// Extract it onto the container spec and strip it from the lifecycle hash.
|
|
1298
|
+
const { lifecycle: lifecycleForManifest, imagePullPolicy: lifecycleImagePullPolicy } =
|
|
1299
|
+
Underpost.deploy.extractInstanceImagePullPolicy(pickEnv(_lifecycle));
|
|
1300
|
+
const instanceImagePullPolicy = options.imagePullPolicy || lifecycleImagePullPolicy;
|
|
1301
|
+
|
|
1224
1302
|
const deploymentYaml =
|
|
1225
1303
|
`---\n` +
|
|
1226
1304
|
Underpost.deploy
|
|
@@ -1234,6 +1312,11 @@ EOF
|
|
|
1234
1312
|
namespace: options.namespace,
|
|
1235
1313
|
volumes: _volumes,
|
|
1236
1314
|
cmd: resolvedCmd,
|
|
1315
|
+
lifecycle: lifecycleForManifest,
|
|
1316
|
+
readinessProbe: pickEnv(_readinessProbe),
|
|
1317
|
+
livenessProbe: pickEnv(_livenessProbe),
|
|
1318
|
+
containerPort: _toPort,
|
|
1319
|
+
imagePullPolicy: instanceImagePullPolicy,
|
|
1237
1320
|
})
|
|
1238
1321
|
.replace('{{ports}}', buildKindPorts(_fromPort, _toPort));
|
|
1239
1322
|
|
|
@@ -1288,6 +1371,44 @@ EOF
|
|
|
1288
1371
|
shellExec(`${options.underpostRoot}/scripts/rocky-setup.sh${options.dev ? ' --install-dev' : ``}`);
|
|
1289
1372
|
},
|
|
1290
1373
|
|
|
1374
|
+
/**
|
|
1375
|
+
* @method install-crio
|
|
1376
|
+
* @description Installs and configures CRI-O as the container runtime for kubeadm clusters.
|
|
1377
|
+
* Adds the stable v1.33 CRI-O yum repository, installs the `cri-o` package, configures
|
|
1378
|
+
* the systemd cgroup driver, enables the `crio` service, and writes `/etc/crictl.yaml`
|
|
1379
|
+
* so that `crictl` targets the CRI-O socket by default.
|
|
1380
|
+
* @param {string} path - Unused.
|
|
1381
|
+
* @param {Object} options - The default underpost runner options for customizing workflow.
|
|
1382
|
+
* @memberof UnderpostRun
|
|
1383
|
+
*/
|
|
1384
|
+
'install-crio': (path, options = DEFAULT_OPTION) => {
|
|
1385
|
+
logger.info('Installing CRI-O...');
|
|
1386
|
+
shellExec(`cat <<EOF | sudo tee /etc/yum.repos.d/cri-o.repo
|
|
1387
|
+
[cri-o]
|
|
1388
|
+
name=CRI-O
|
|
1389
|
+
baseurl=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/
|
|
1390
|
+
enabled=1
|
|
1391
|
+
gpgcheck=1
|
|
1392
|
+
gpgkey=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/v1.33/rpm/repodata/repomd.xml.key
|
|
1393
|
+
EOF`);
|
|
1394
|
+
shellExec(`sudo dnf -y install cri-o`);
|
|
1395
|
+
// crictl is in the kubernetes repo but excluded by default — install it explicitly
|
|
1396
|
+
shellExec(`sudo yum install -y cri-tools --disableexcludes=kubernetes`);
|
|
1397
|
+
// Ensure CRI-O uses systemd cgroup driver (matches kubelet default)
|
|
1398
|
+
shellExec(`sudo sed -i 's/^#\?cgroup_manager =.*/cgroup_manager = "systemd"/' /etc/crio/crio.conf`, {
|
|
1399
|
+
silentOnError: true,
|
|
1400
|
+
});
|
|
1401
|
+
shellExec(`sudo systemctl enable --now crio`);
|
|
1402
|
+
logger.info('CRI-O installed and started.');
|
|
1403
|
+
// Write crictl config so all crictl calls default to the CRI-O socket
|
|
1404
|
+
shellExec(`cat <<EOF | sudo tee /etc/crictl.yaml
|
|
1405
|
+
runtime-endpoint: unix:///var/run/crio/crio.sock
|
|
1406
|
+
image-endpoint: unix:///var/run/crio/crio.sock
|
|
1407
|
+
timeout: 10
|
|
1408
|
+
debug: false
|
|
1409
|
+
EOF`);
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1291
1412
|
/**
|
|
1292
1413
|
* @method dd-container
|
|
1293
1414
|
* @description Deploys a development or debug container tasks jobs, setting up necessary volumes and images, and running specified commands within the container.
|
|
@@ -1430,6 +1551,9 @@ EOF
|
|
|
1430
1551
|
/**
|
|
1431
1552
|
* @method promote
|
|
1432
1553
|
* @description Switches traffic between blue/green deployments for a specified deployment ID(s) (uses `dd.router` for 'dd', or a specific ID).
|
|
1554
|
+
* When `--tls` is set, rebuilds the proxy manifest with `--cert` so the HTTPProxy includes
|
|
1555
|
+
* TLS config, deletes stale Certificate resources, then reapplies the proxy and secret.yaml
|
|
1556
|
+
* (cert-manager Certificate resources) for each affected deployment.
|
|
1433
1557
|
* @param {string} path - The input value, identifier, or path for the operation (used as a comma-separated string: `deployId,env,replicas`).
|
|
1434
1558
|
* @param {Object} options - The default underpost runner options for customizing workflow
|
|
1435
1559
|
* @memberof UnderpostRun
|
|
@@ -1438,11 +1562,34 @@ EOF
|
|
|
1438
1562
|
let [inputDeployId, inputEnv, inputReplicas] = path.split(',');
|
|
1439
1563
|
if (!inputEnv) inputEnv = 'production';
|
|
1440
1564
|
if (!inputReplicas) inputReplicas = 1;
|
|
1565
|
+
// TODO: normalize: --tls maps to --cert for deploy.js isValidTLSContext compatibility
|
|
1566
|
+
if (options.tls) options.cert = true;
|
|
1567
|
+
|
|
1568
|
+
const applyCerts = (deployId, targetTraffic) => {
|
|
1569
|
+
if (!options.tls) return;
|
|
1570
|
+
// Rebuild proxy.yaml with --cert so the HTTPProxy includes TLS virtualhost config
|
|
1571
|
+
shellExec(
|
|
1572
|
+
`node bin deploy --build-manifest --cert --traffic ${targetTraffic} --replicas ${inputReplicas} --namespace ${options.namespace} ${deployId} ${inputEnv}`,
|
|
1573
|
+
);
|
|
1574
|
+
// Delete stale Certificate resources before reapplying
|
|
1575
|
+
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
|
|
1576
|
+
if (fs.existsSync(confServerPath)) {
|
|
1577
|
+
for (const host of Object.keys(JSON.parse(fs.readFileSync(confServerPath, 'utf8'))))
|
|
1578
|
+
shellExec(`sudo kubectl delete Certificate ${host} -n ${options.namespace} --ignore-not-found`);
|
|
1579
|
+
}
|
|
1580
|
+
shellExec(
|
|
1581
|
+
`sudo kubectl apply -f ./engine-private/conf/${deployId}/build/${inputEnv}/proxy.yaml -n ${options.namespace}`,
|
|
1582
|
+
);
|
|
1583
|
+
const secretPath = `./engine-private/conf/${deployId}/build/${inputEnv}/secret.yaml`;
|
|
1584
|
+
if (fs.existsSync(secretPath)) shellExec(`kubectl apply -f ${secretPath} -n ${options.namespace}`);
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1441
1587
|
if (inputDeployId === 'dd') {
|
|
1442
1588
|
for (const deployId of fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').split(',')) {
|
|
1443
1589
|
const currentTraffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace: options.namespace });
|
|
1444
1590
|
const targetTraffic = currentTraffic === 'blue' ? 'green' : 'blue';
|
|
1445
1591
|
Underpost.deploy.switchTraffic(deployId, inputEnv, targetTraffic, inputReplicas, options.namespace, options);
|
|
1592
|
+
applyCerts(deployId, targetTraffic);
|
|
1446
1593
|
}
|
|
1447
1594
|
} else {
|
|
1448
1595
|
const currentTraffic = Underpost.deploy.getCurrentTraffic(inputDeployId, { namespace: options.namespace });
|
|
@@ -1455,6 +1602,7 @@ EOF
|
|
|
1455
1602
|
options.namespace,
|
|
1456
1603
|
options,
|
|
1457
1604
|
);
|
|
1605
|
+
applyCerts(inputDeployId, targetTraffic);
|
|
1458
1606
|
}
|
|
1459
1607
|
},
|
|
1460
1608
|
/**
|
|
@@ -2097,15 +2245,23 @@ EOF
|
|
|
2097
2245
|
* @memberof UnderpostRun
|
|
2098
2246
|
*/
|
|
2099
2247
|
kill: (path = '', options = DEFAULT_OPTION) => {
|
|
2100
|
-
if (options.pid)
|
|
2248
|
+
if (options.pid)
|
|
2249
|
+
return shellExec(`sudo kill -9 ${options.pid}`, {
|
|
2250
|
+
silentOnError: true,
|
|
2251
|
+
});
|
|
2101
2252
|
for (const _path of path.split(',')) {
|
|
2102
2253
|
if (_path.split('+')[1]) {
|
|
2103
2254
|
let [port, sumPortOffSet] = _path.split('+');
|
|
2104
2255
|
port = parseInt(port);
|
|
2105
2256
|
sumPortOffSet = parseInt(sumPortOffSet);
|
|
2106
2257
|
for (const sumPort of range(0, sumPortOffSet))
|
|
2107
|
-
shellExec(`sudo kill -9 $(lsof -t -i:${parseInt(port) + parseInt(sumPort)})
|
|
2108
|
-
|
|
2258
|
+
shellExec(`sudo kill -9 $(lsof -t -i:${parseInt(port) + parseInt(sumPort)})`, {
|
|
2259
|
+
silentOnError: true,
|
|
2260
|
+
});
|
|
2261
|
+
} else
|
|
2262
|
+
shellExec(`sudo kill -9 $(lsof -t -i:${_path})`, {
|
|
2263
|
+
silentOnError: true,
|
|
2264
|
+
});
|
|
2109
2265
|
}
|
|
2110
2266
|
},
|
|
2111
2267
|
/**
|
|
@@ -2371,9 +2527,11 @@ EOF`;
|
|
|
2371
2527
|
/**
|
|
2372
2528
|
* @method pull-bundle
|
|
2373
2529
|
* @description Downloads split zip parts from file storage, merges and extracts them, and moves the result into the public directory.
|
|
2374
|
-
* Steps: set
|
|
2375
|
-
*
|
|
2376
|
-
*
|
|
2530
|
+
* Steps: set env, download parts (omit-unzip), merge zip, unzip, remove zip + parts, move to public/<host>[/path].
|
|
2531
|
+
* Iterates over every non-singleReplica, non-redirect, non-disabledRebuild route in conf.server.json
|
|
2532
|
+
* so that multi-path deployments are handled correctly.
|
|
2533
|
+
* @param {string} path - Optional comma-separated host name(s) to restrict processing (e.g. 'underpost.net' or 'a.com,b.com').
|
|
2534
|
+
* If omitted, all hosts from `engine-private/conf/<deployId>/conf.server.json` are used.
|
|
2377
2535
|
* @param {Object} options - The default underpost runner options for customizing workflow.
|
|
2378
2536
|
* @param {string} [options.deployId] - Deploy ID for storage lookup (defaults to 'dd-default').
|
|
2379
2537
|
* @param {boolean} [options.dev] - Use development environment; defaults to production.
|
|
@@ -2384,21 +2542,16 @@ EOF`;
|
|
|
2384
2542
|
const env = options.dev ? 'development' : 'production';
|
|
2385
2543
|
const deployId = options.deployId || 'dd-default';
|
|
2386
2544
|
const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
|
|
2387
|
-
const
|
|
2545
|
+
const confServer = fs.existsSync(confServerPath) ? loadConfServerJson(confServerPath) : {};
|
|
2546
|
+
const hostsArg = path
|
|
2388
2547
|
? path
|
|
2389
2548
|
.split(',')
|
|
2390
2549
|
.map((h) => h.trim())
|
|
2391
2550
|
.filter(Boolean)
|
|
2392
|
-
:
|
|
2393
|
-
? Object.keys(loadConfServerJson(confServerPath))
|
|
2394
|
-
: [];
|
|
2551
|
+
: Object.keys(confServer);
|
|
2395
2552
|
|
|
2396
|
-
if (
|
|
2397
|
-
logger.error('pull-bundle: no hosts resolved', {
|
|
2398
|
-
deployId,
|
|
2399
|
-
path,
|
|
2400
|
-
confServerPath,
|
|
2401
|
-
});
|
|
2553
|
+
if (hostsArg.length === 0) {
|
|
2554
|
+
logger.error('pull-bundle: no hosts resolved', { deployId, path, confServerPath });
|
|
2402
2555
|
return;
|
|
2403
2556
|
}
|
|
2404
2557
|
|
|
@@ -2407,30 +2560,118 @@ EOF`;
|
|
|
2407
2560
|
shellExec(
|
|
2408
2561
|
`${baseCommand} fs build --recursive --deploy-id ${deployId} --storage-file-path engine-private/conf/${deployId}/storage.bundle.json --pull --omit-unzip`,
|
|
2409
2562
|
);
|
|
2410
|
-
for (const host of hosts) {
|
|
2411
|
-
const zipPath = `build/${host}-.zip`;
|
|
2412
|
-
const hasZip = fs.existsSync(zipPath);
|
|
2413
|
-
const hasParts =
|
|
2414
|
-
fs.existsSync('./build') &&
|
|
2415
|
-
fs
|
|
2416
|
-
.readdirSync('./build')
|
|
2417
|
-
.some((name) => name.startsWith(`${host}-.zip.part`) || name.startsWith(`${host}-.zip-part`));
|
|
2418
|
-
|
|
2419
|
-
if (!hasZip && !hasParts) {
|
|
2420
|
-
logger.warn(`Bundle not found for host '${host}'. Skipping host.`, {
|
|
2421
|
-
zipPath,
|
|
2422
|
-
deployId,
|
|
2423
|
-
});
|
|
2424
|
-
continue;
|
|
2425
|
-
}
|
|
2426
2563
|
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2564
|
+
for (const host of hostsArg) {
|
|
2565
|
+
// Gather all routes for this host; fall back to root '/' when host is not in confServer
|
|
2566
|
+
// (e.g. when hosts were provided explicitly via the path argument).
|
|
2567
|
+
const routePaths = confServer[host] ? Object.keys(confServer[host]) : ['/'];
|
|
2568
|
+
|
|
2569
|
+
for (const routePath of routePaths) {
|
|
2570
|
+
const routeConf = confServer[host] ? confServer[host][routePath] || {} : {};
|
|
2571
|
+
// Skip routes that are not built by buildClient (mirrors buildClient skip conditions)
|
|
2572
|
+
if (routeConf.singleReplica || routeConf.redirect || routeConf.disabledRebuild) continue;
|
|
2573
|
+
|
|
2574
|
+
// buildClient names the zip as "<host>-<path-no-slashes>.zip"
|
|
2575
|
+
// e.g. host="underpost.net", path="/" → buildId="underpost.net-", zip="build/underpost.net-.zip"
|
|
2576
|
+
// e.g. host="app.net", path="/admin" → buildId="app.net-admin", zip="build/app.net-admin.zip"
|
|
2577
|
+
const buildId = `${host}-${routePath.replaceAll('/', '')}`;
|
|
2578
|
+
const zipPath = `build/${buildId}.zip`;
|
|
2579
|
+
const buildDir = './build';
|
|
2580
|
+
const hasZip = fs.existsSync(zipPath);
|
|
2581
|
+
const hasParts =
|
|
2582
|
+
fs.existsSync(buildDir) &&
|
|
2583
|
+
fs
|
|
2584
|
+
.readdirSync(buildDir)
|
|
2585
|
+
.some((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`));
|
|
2586
|
+
|
|
2587
|
+
if (!hasZip && !hasParts) {
|
|
2588
|
+
logger.warn(`Bundle not found for '${host}${routePath}'. Skipping.`, { zipPath, deployId });
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
if (hasParts) shellExec(`${baseCommand} client --merge-zip ${zipPath}`);
|
|
2593
|
+
shellExec(`${baseCommand} client --unzip ${zipPath}`);
|
|
2594
|
+
shellExec(`sudo rm -rf ${zipPath}`);
|
|
2595
|
+
|
|
2596
|
+
// Clean up downloaded part wrapper zips left by --omit-unzip pull
|
|
2597
|
+
if (fs.existsSync(buildDir)) {
|
|
2598
|
+
fs.readdirSync(buildDir)
|
|
2599
|
+
.filter((name) => name.startsWith(`${buildId}.zip.part`) || name.startsWith(`${buildId}.zip-part`))
|
|
2600
|
+
.forEach((partFile) => shellExec(`sudo rm -rf ${buildDir}/${partFile}`));
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// unzipClientBuild extracts to buildId with trailing '-' stripped
|
|
2604
|
+
// e.g. "build/underpost.net-" → "build/underpost.net"
|
|
2605
|
+
// e.g. "build/app.net-admin" → "build/app.net-admin" (no trailing dash, no change)
|
|
2606
|
+
const extractedDir = `build/${buildId.replace(/-$/, '')}`;
|
|
2607
|
+
if (!fs.existsSync(extractedDir)) {
|
|
2608
|
+
logger.warn(`Extracted build dir not found: ${extractedDir}. Skipping move for '${host}${routePath}'.`);
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
// Destination mirrors the public directory layout used by the server
|
|
2613
|
+
const publicDestPath = routePath === '/' ? `public/${host}` : `public/${host}${routePath}`;
|
|
2614
|
+
if (fs.existsSync(publicDestPath)) shellExec(`sudo rm -rf ${publicDestPath}`);
|
|
2615
|
+
// Ensure parent directory exists for sub-paths
|
|
2616
|
+
if (routePath !== '/') shellExec(`sudo mkdir -p public/${host}`);
|
|
2617
|
+
shellExec(`sudo mv ${extractedDir} ${publicDestPath}`);
|
|
2618
|
+
}
|
|
2432
2619
|
}
|
|
2433
2620
|
},
|
|
2621
|
+
|
|
2622
|
+
/**
|
|
2623
|
+
* @method setup-shared-dir
|
|
2624
|
+
* @description Run once for initial shared-directory setup. Creates the group, adds the user,
|
|
2625
|
+
* creates the directory, sets ownership, applies the SGID bit, and configures default ACLs so
|
|
2626
|
+
* all future files inside the directory automatically inherit group write permissions.
|
|
2627
|
+
* Use `reload-shared-dir` for subsequent permission repairs without recreating the group.
|
|
2628
|
+
* @param {string} path - Target directory to set up (defaults to `/home/dd/engine`).
|
|
2629
|
+
* Customise via the `path` argument or leave empty to use the default.
|
|
2630
|
+
* @param {Object} options - The default underpost runner options for customizing workflow.
|
|
2631
|
+
* Key fields: `options.user` (default `'admin'`), `options.group` (default `'engine-dev'`).
|
|
2632
|
+
* @memberof UnderpostRun
|
|
2633
|
+
*/
|
|
2634
|
+
'setup-shared-dir': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
|
|
2635
|
+
const dir = path || '/home/dd/engine';
|
|
2636
|
+
const user = options.user || 'admin';
|
|
2637
|
+
const group = options.group || 'engine-dev';
|
|
2638
|
+
|
|
2639
|
+
logger.info(`[setup-shared-dir] dir=${dir} user=${user} group=${group}`);
|
|
2640
|
+
|
|
2641
|
+
shellExec(`sudo groupadd ${group} 2>/dev/null || true`);
|
|
2642
|
+
shellExec(`sudo usermod -aG ${group} ${user}`);
|
|
2643
|
+
shellExec(`sudo mkdir -p ${dir}`);
|
|
2644
|
+
shellExec(`sudo chown -R ${user}:${group} ${dir}`);
|
|
2645
|
+
shellExec(`sudo chmod -R 2775 ${dir}`);
|
|
2646
|
+
shellExec(`sudo setfacl -d -m g:${group}:rwx ${dir}`);
|
|
2647
|
+
shellExec(`sudo setfacl -m g:${group}:rwx ${dir}`);
|
|
2648
|
+
|
|
2649
|
+
logger.info(`[setup-shared-dir] Shared directory setup complete: ${dir}`);
|
|
2650
|
+
},
|
|
2651
|
+
|
|
2652
|
+
/**
|
|
2653
|
+
* @method reload-shared-dir
|
|
2654
|
+
* @description Re-applies recursive permissions and ACLs to repair permission drift on an
|
|
2655
|
+
* already-configured shared directory. Does **not** recreate the group, add users, or modify
|
|
2656
|
+
* ownership. Use this after VS Code permission errors or when existing files lose group write
|
|
2657
|
+
* access due to tool or process interference.
|
|
2658
|
+
* @param {string} path - Target directory to repair (defaults to `/home/dd/engine`).
|
|
2659
|
+
* Customise via the `path` argument or leave empty to use the default.
|
|
2660
|
+
* @param {Object} options - The default underpost runner options for customizing workflow.
|
|
2661
|
+
* Key fields: `options.group` (default `'engine-dev'`).
|
|
2662
|
+
* @memberof UnderpostRun
|
|
2663
|
+
*/
|
|
2664
|
+
'reload-shared-dir': (path = '/home/dd/engine', options = DEFAULT_OPTION) => {
|
|
2665
|
+
const dir = path || '/home/dd/engine';
|
|
2666
|
+
const group = options.group || 'engine-dev';
|
|
2667
|
+
|
|
2668
|
+
logger.info(`[reload-shared-dir] dir=${dir} group=${group}`);
|
|
2669
|
+
|
|
2670
|
+
shellExec(`sudo chmod -R 2775 ${dir}`);
|
|
2671
|
+
shellExec(`sudo setfacl -R -m g:${group}:rwx ${dir}`);
|
|
2672
|
+
|
|
2673
|
+
logger.info(`[reload-shared-dir] Shared directory permissions reloaded: ${dir}`);
|
|
2674
|
+
},
|
|
2434
2675
|
};
|
|
2435
2676
|
|
|
2436
2677
|
static API = {
|
package/src/cli/test.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @namespace UnderpostTest
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import fs from 'fs-extra';
|
|
7
8
|
import { timer } from '../client/components/core/CommonJs.js';
|
|
8
9
|
import { MariaDB } from '../db/mariadb/MariaDB.js';
|
|
9
10
|
import { getNpmRootPath } from '../server/conf.js';
|
|
@@ -42,7 +43,13 @@ class UnderpostTest {
|
|
|
42
43
|
*/
|
|
43
44
|
run() {
|
|
44
45
|
actionInitLog();
|
|
45
|
-
|
|
46
|
+
const underpostTestPath = `${getNpmRootPath()}/underpost`;
|
|
47
|
+
if (fs.existsSync(underpostTestPath)) {
|
|
48
|
+
shellExec(`cd ${underpostTestPath} && npm run test`);
|
|
49
|
+
} else {
|
|
50
|
+
logger.warn(`Global underpost not found at ${underpostTestPath}, running local npm test instead`);
|
|
51
|
+
shellExec('npm test');
|
|
52
|
+
}
|
|
46
53
|
},
|
|
47
54
|
/**
|
|
48
55
|
* @method callback
|
|
@@ -148,8 +155,7 @@ class UnderpostTest {
|
|
|
148
155
|
const pods = Underpost.kubectl.get(podName, kindType);
|
|
149
156
|
let result = pods.find((p) => p.STATUS === status || (status === 'Running' && p.STATUS === 'Completed'));
|
|
150
157
|
logger.info(
|
|
151
|
-
`Testing pod ${podName}... ${result ? 1 : 0}/1 - elapsed time ${deltaMs * (index + 1)}s - attempt ${
|
|
152
|
-
index + 1
|
|
158
|
+
`Testing pod ${podName}... ${result ? 1 : 0}/1 - elapsed time ${deltaMs * (index + 1)}s - attempt ${index + 1
|
|
153
159
|
}/${maxAttempts}`,
|
|
154
160
|
pods[0] ? pods[0].STATUS : 'Not found kind object',
|
|
155
161
|
);
|
|
@@ -19,6 +19,12 @@ const DefaultTemplate = async () => {
|
|
|
19
19
|
});
|
|
20
20
|
});
|
|
21
21
|
return html`
|
|
22
|
+
<style>
|
|
23
|
+
.feature-icon {
|
|
24
|
+
font-size: 2.5rem;
|
|
25
|
+
margin-bottom: 1rem;
|
|
26
|
+
}
|
|
27
|
+
</style>
|
|
22
28
|
<div class="landing-container">
|
|
23
29
|
<div class="content-wrapper">
|
|
24
30
|
<h1 class="animated-text">
|
|
@@ -27,17 +33,17 @@ const DefaultTemplate = async () => {
|
|
|
27
33
|
</h1>
|
|
28
34
|
<div class="features">
|
|
29
35
|
<div class="feature-card">
|
|
30
|
-
<i class="icon"
|
|
36
|
+
<i class="fas fa-rocket feature-icon"></i>
|
|
31
37
|
<h3>Fast & Reliable</h3>
|
|
32
38
|
<p>Lightning-fast performance with 99.9% uptime</p>
|
|
33
39
|
</div>
|
|
34
40
|
<div class="feature-card">
|
|
35
|
-
<i class="icon"
|
|
41
|
+
<i class="fas fa-palette feature-icon"></i>
|
|
36
42
|
<h3>Beautiful UI</h3>
|
|
37
43
|
<p>Modern and intuitive user interface</p>
|
|
38
44
|
</div>
|
|
39
45
|
<div class="feature-card">
|
|
40
|
-
<i class="icon"
|
|
46
|
+
<i class="fas fa-bolt feature-icon"></i>
|
|
41
47
|
<h3>Powerful Features</h3>
|
|
42
48
|
<p>Everything you need in one place</p>
|
|
43
49
|
</div>
|