underpost 3.2.22 → 3.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.github/workflows/ghpkg.ci.yml +1 -1
  2. package/.github/workflows/gitlab.ci.yml +1 -1
  3. package/.github/workflows/npmpkg.ci.yml +1 -1
  4. package/.github/workflows/publish.ci.yml +2 -2
  5. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -1
  6. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  7. package/CHANGELOG.md +141 -1
  8. package/CLI-HELP.md +56 -2
  9. package/README.md +3 -2
  10. package/baremetal/commission-workflows.json +1 -0
  11. package/bin/build.js +1 -0
  12. package/docker-compose.yml +224 -0
  13. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  15. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  16. package/package.json +27 -15
  17. package/scripts/kubeadm-node-setup.sh +317 -0
  18. package/scripts/rocky-kickstart.sh +877 -185
  19. package/scripts/rpmfusion-ffmpeg-setup.sh +26 -50
  20. package/src/cli/baremetal.js +717 -16
  21. package/src/cli/cluster.js +3 -1
  22. package/src/cli/deploy.js +6 -2
  23. package/src/cli/docker-compose.js +591 -0
  24. package/src/cli/fs.js +35 -11
  25. package/src/cli/index.js +75 -0
  26. package/src/cli/kickstart.js +142 -20
  27. package/src/cli/repository.js +72 -11
  28. package/src/cli/run.js +253 -2
  29. package/src/cli/ssh.js +190 -0
  30. package/src/cli/static.js +2 -2
  31. package/src/{server → client-builder}/client-build-docs.js +15 -5
  32. package/src/{server → client-builder}/client-build-live.js +3 -3
  33. package/src/{server → client-builder}/client-build.js +25 -22
  34. package/src/{server → client-builder}/client-dev-server.js +3 -3
  35. package/src/{server → client-builder}/client-icons.js +2 -2
  36. package/src/{server → client-builder}/ssr.js +5 -5
  37. package/src/client.build.js +1 -1
  38. package/src/client.dev.js +1 -1
  39. package/src/index.js +12 -1
  40. package/src/mailer/EmailRender.js +1 -1
  41. package/src/{server → projects/underpost}/catalog-underpost.js +1 -2
  42. package/src/runtime/express/Express.js +2 -2
  43. package/src/runtime/nginx/Nginx.js +250 -0
  44. package/src/server/catalog.js +7 -14
  45. package/src/server/conf.js +3 -3
  46. package/src/server.js +1 -1
  47. package/typedoc.json +3 -1
  48. package/src/server/ipfs-client.js +0 -599
  49. /package/src/client/ssr/{Render.js → RootDocument.js} +0 -0
  50. /package/src/{server → client-builder}/client-formatted.js +0 -0
@@ -691,7 +691,9 @@ EOF
691
691
  // shellExec(
692
692
  // `sudo sed -i '/SystemdCgroup = true/a selinux_disabled = true' /etc/containerd/config.toml`,
693
693
  // );
694
- shellExec(`sudo service docker restart`); // Restart docker after containerd config changes
694
+ // Restart docker after containerd config changes. Rocky 9 uses systemctl,
695
+ // not the legacy service command.
696
+ shellExec(`sudo systemctl restart docker || sudo service docker restart || true`);
695
697
  shellExec(`sudo systemctl enable --now containerd.service`);
696
698
  shellExec(`sudo systemctl restart containerd`); // Restart containerd to apply changes
697
699
 
package/src/cli/deploy.js CHANGED
@@ -969,8 +969,12 @@ EOF`);
969
969
  if (
970
970
  Underpost.deploy.isValidTLSContext({ host: Object.keys(confServer)[0], env, options }) &&
971
971
  !options.selfSigned
972
- )
973
- shellExec(`sudo kubectl apply -f ./${manifestsPath}/secret.yaml -n ${namespace}`);
972
+ ) {
973
+ const secretPath = `./${manifestsPath}/secret.yaml`;
974
+ if (fs.existsSync(secretPath) && fs.readFileSync(secretPath, 'utf8').trim()) {
975
+ shellExec(`sudo kubectl apply -f ${secretPath} -n ${namespace}`);
976
+ } else logger.info('Skipping secret.yaml apply (no objects yet; applied by the --cert step)');
977
+ }
974
978
  }
975
979
  }
976
980
  },
@@ -0,0 +1,591 @@
1
+ /**
2
+ * @description Docker Compose pipeline CLI for the local development stack that
3
+ * mirrors the Kubernetes manifests under `manifests/`. Manages dynamic
4
+ * generation of supporting config (nginx router, env-file) and the compose
5
+ * lifecycle (up/down/logs/...). General-purpose: any compose subcommand can be
6
+ * forwarded via `--exec`.
7
+ * @module src/cli/docker-compose.js
8
+ * @namespace UnderpostDockerCompose
9
+ */
10
+
11
+ import fs from 'fs-extra';
12
+ import nodePath from 'path';
13
+ import { getRootDirectory, shellExec } from '../server/process.js';
14
+ import { loggerFactory } from '../server/logger.js';
15
+ import Nginx from '../runtime/nginx/Nginx.js';
16
+
17
+ const logger = loggerFactory(import.meta);
18
+
19
+ /**
20
+ * Reverse-proxy route table derived from
21
+ * manifests/deployment/dd-default-development/proxy.yaml (Contour HTTPProxy).
22
+ * Longer prefixes precede '/' so nginx matches them first.
23
+ * @constant PROXY_HOSTS
24
+ * @memberof UnderpostDockerCompose
25
+ */
26
+ const PROXY_HOSTS = [
27
+ {
28
+ host: 'default.net',
29
+ routes: [
30
+ { location: '/peer', service: 'app', port: 4002 },
31
+ { location: '/', service: 'app', port: 4001 },
32
+ ],
33
+ },
34
+ {
35
+ host: 'www.default.net',
36
+ routes: [{ location: '/', service: 'app', port: 4003 }],
37
+ },
38
+ ];
39
+
40
+ /** Fallback upstream for unmatched Host headers and the proxy healthcheck. */
41
+ const DEFAULT_UPSTREAM = { service: 'app', port: 4001 };
42
+
43
+ /** Prometheus scrape target: the app's /metrics endpoint on its base port. */
44
+ const METRICS_TARGET = { service: 'app', port: 4001 };
45
+
46
+ /** Default deployment identifier (the self-bootstrapping engine deploy). */
47
+ const DEFAULT_DEPLOY_ID = 'dd-default';
48
+
49
+ /**
50
+ * Generated artifact paths (relative to repo root) that `--generate` produces
51
+ * and `--reset` prunes. The working env-file (docker/compose.env) is excluded
52
+ * so a reset never destroys configured credentials.
53
+ */
54
+ const GENERATED_ARTIFACTS = [
55
+ 'docker/mongodb/entrypoint.sh',
56
+ 'docker/nginx/default.conf',
57
+ 'docker/prometheus/prometheus.yml',
58
+ 'docker/grafana/provisioning',
59
+ 'docker/compose.app.yml',
60
+ 'docker/compose.env.example',
61
+ ];
62
+
63
+ /**
64
+ * @class UnderpostDockerCompose
65
+ * @description Docker Compose development pipeline. A single {@link UnderpostDockerCompose.API.callback}
66
+ * dispatches actions based on flag options, mirroring the cluster CLI pattern.
67
+ * @memberof UnderpostDockerCompose
68
+ */
69
+ class UnderpostDockerCompose {
70
+ /**
71
+ * Resolves a repo-root-relative path to an absolute path.
72
+ * @param {string} relPath - Path relative to the engine root.
73
+ * @returns {string} Absolute path.
74
+ * @memberof UnderpostDockerCompose
75
+ */
76
+ static resolve(relPath) {
77
+ return nodePath.isAbsolute(relPath) ? relPath : nodePath.join(getRootDirectory(), relPath);
78
+ }
79
+
80
+ /**
81
+ * Builds the base `docker compose` invocation with explicit file and env-file,
82
+ * so behavior is independent of the caller's working directory.
83
+ * @param {object} options - CLI options.
84
+ * @returns {string} The base command string (without a subcommand).
85
+ * @memberof UnderpostDockerCompose
86
+ */
87
+ static baseCmd(options = {}) {
88
+ const composeFile = UnderpostDockerCompose.resolve(options.composeFile || 'docker-compose.yml');
89
+ const envFile = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env');
90
+ const overrideFile = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml');
91
+ const overrideFlag = fs.existsSync(overrideFile) ? ` -f ${overrideFile}` : '';
92
+ return `docker compose --env-file ${envFile} -f ${composeFile}${overrideFlag}`;
93
+ }
94
+
95
+ /**
96
+ * Resolves the app container start command for a deployment, mirroring
97
+ * src/cli/deploy.js. The `dd-default` deploy self-bootstraps a fresh engine
98
+ * (matching manifests/deployment/dd-default-development); any other deploy-id
99
+ * follows the standard deploy command (matching
100
+ * manifests/deployment/dd-test-development and
101
+ * UnderpostDeploy.deploymentYamlPartsFactory's default cmd).
102
+ * @param {string} deployId - Deployment identifier (conf id, e.g. `dd-test`).
103
+ * @param {string} env - Deployment environment (e.g. `development`).
104
+ * @returns {string[]} Ordered shell steps joined with `&&` at render time.
105
+ * @memberof UnderpostDockerCompose
106
+ */
107
+ static appCommand(deployId = DEFAULT_DEPLOY_ID, env = 'development') {
108
+ if (deployId === DEFAULT_DEPLOY_ID)
109
+ return [
110
+ // `$$` escapes to a literal `$` so /bin/sh (not Compose) performs the
111
+ // command/variable substitution at container runtime.
112
+ 'cd $$(underpost root)/underpost',
113
+ 'node bin new --default-conf --conf-workflow-id template',
114
+ // Point the template .env.example at the Docker service-discovery hosts
115
+ // before `new engine` copies it. The generated dd-engine/.env.development
116
+ // is seeded from this file and is applied over the process env by
117
+ // loadConf (src/server/conf.js), so without this the engine would fall
118
+ // back to the .env.example localhost defaults for DB_HOST/VALKEY_HOST.
119
+ 'sed -i "s#^DB_HOST=.*#DB_HOST=$${DB_HOST}#" .env.example',
120
+ 'sed -i "s#^VALKEY_HOST=.*#VALKEY_HOST=$${VALKEY_HOST}#" .env.example',
121
+ 'mkdir -p /home/dd',
122
+ 'cd /home/dd',
123
+ 'underpost new engine',
124
+ ];
125
+ return ['underpost secret underpost --create-from-env', `underpost start --build --run ${deployId} ${env}`];
126
+ }
127
+
128
+ /**
129
+ * Renders a Compose override file that sets only the `app` service command
130
+ * for the selected deployment. Keeping the command in an override file makes
131
+ * the generator the single source of truth and leaves docker-compose.yml
132
+ * deployment-agnostic.
133
+ * @param {string} deployId - Deployment identifier.
134
+ * @param {string} env - Deployment environment.
135
+ * @returns {string} The override YAML document.
136
+ * @memberof UnderpostDockerCompose
137
+ */
138
+ static appOverrideContent(deployId = DEFAULT_DEPLOY_ID, env = 'development') {
139
+ const steps = UnderpostDockerCompose.appCommand(deployId, env).join(' &&\n ');
140
+ return `# Generated by 'underpost docker-compose --generate --deploy-id ${deployId}' — do not hand-edit.
141
+ # Overrides the app service start command for deploy '${deployId}' (${env}).
142
+ services:
143
+ app:
144
+ command:
145
+ - /bin/sh
146
+ - -c
147
+ - >
148
+ ${steps}
149
+ `;
150
+ }
151
+
152
+ /**
153
+ * Renders the dynamic env-file example content for the compose stack.
154
+ * @returns {string} The env-file template.
155
+ * @memberof UnderpostDockerCompose
156
+ */
157
+ static envExampleContent() {
158
+ return `# Generated by 'underpost docker-compose --generate' — copy to docker/compose.env.
159
+ # Loaded explicitly via --env-file so container service-discovery values are not
160
+ # overridden by the application's host-local (127.0.0.1) root .env.
161
+
162
+ # --- Container images ----------------------------------------------------
163
+ MONGO_IMAGE=mongo:latest
164
+ VALKEY_IMAGE=valkey/valkey:latest
165
+ APP_IMAGE=underpost/underpost-engine
166
+ APP_TAG=v3.2.22
167
+ PROXY_IMAGE=nginx:stable-alpine
168
+ PROMETHEUS_IMAGE=prom/prometheus:latest
169
+ GRAFANA_IMAGE=grafana/grafana:latest
170
+
171
+ # --- MongoDB (translation of Secret mongodb-secret) ----------------------
172
+ MONGO_INITDB_ROOT_USERNAME=admin
173
+ MONGO_INITDB_ROOT_PASSWORD=changeme
174
+
175
+ # --- Application DB connection (Docker service discovery, not localhost) --
176
+ NODE_ENV=development
177
+ DB_PROVIDER=mongoose
178
+ DB_HOST=mongodb://mongodb:27017
179
+ DB_NAME=default
180
+ DB_REPLICA_SET=rs0
181
+ DB_AUTH_SOURCE=admin
182
+
183
+ # --- Valkey (Docker service discovery) -----------------------------------
184
+ VALKEY_HOST=valkey-service
185
+ VALKEY_PORT=6379
186
+
187
+ # --- Monitoring ----------------------------------------------------------
188
+ GRAFANA_ADMIN_USER=admin
189
+ GRAFANA_ADMIN_PASSWORD=admin
190
+
191
+ # --- Host-published ports ------------------------------------------------
192
+ PROXY_HTTP_PORT=80
193
+ MONGO_HOST_PORT=27017
194
+ VALKEY_NODEPORT=32079
195
+ PROMETHEUS_PORT=9090
196
+ GRAFANA_PORT=3000
197
+ APP_PORT_4001=4001
198
+ APP_PORT_4002=4002
199
+ APP_PORT_4003=4003
200
+ APP_PORT_4004=4004
201
+ `;
202
+ }
203
+
204
+ /**
205
+ * Renders the MongoDB container entrypoint. Mirrors src/db/mongo/MongoBootstrap.js:
206
+ * generates the cluster-auth keyfile, launches mongod (replSet + keyFile + auth),
207
+ * and bootstraps the replica set + root user over the loopback localhost
208
+ * exception (single-node member is the `mongodb` service name so app clients
209
+ * connecting with replicaSet=rs0 resolve a reachable host). Idempotent across
210
+ * restarts: re-runs are no-ops once auth is enforced.
211
+ * @returns {string} entrypoint.sh content.
212
+ * @memberof UnderpostDockerCompose
213
+ */
214
+ static mongoEntrypointContent() {
215
+ return `#!/usr/bin/env bash
216
+ # Generated by 'underpost docker-compose --generate' — do not hand-edit.
217
+ set -e
218
+
219
+ KEYFILE=/opt/keyfile/mongodb-keyfile
220
+ RS="\${DB_REPLICA_SET:-rs0}"
221
+ MEMBER_HOST="mongodb:27017"
222
+
223
+ if [ ! -s "$KEYFILE" ]; then
224
+ openssl rand -base64 756 > "$KEYFILE"
225
+ fi
226
+ chmod 400 "$KEYFILE"
227
+ chown 999:999 "$KEYFILE"
228
+ mkdir -p /data/db
229
+ chown -R 999:999 /data/db
230
+
231
+ # One-time replica-set + root-user bootstrap via the localhost exception,
232
+ # performed in the background once mongod accepts loopback connections.
233
+ (
234
+ for i in $(seq 1 60); do
235
+ if mongosh --quiet --host 127.0.0.1 --eval 'db.adminCommand({ ping: 1 })' >/dev/null 2>&1; then
236
+ break
237
+ fi
238
+ sleep 1
239
+ done
240
+
241
+ cat > /tmp/mongo-bootstrap.js <<JS
242
+ var RS = "$RS";
243
+ var HOST = "$MEMBER_HOST";
244
+ var U = "$MONGO_INITDB_ROOT_USERNAME";
245
+ var P = "$MONGO_INITDB_ROOT_PASSWORD";
246
+ function waitPrimary() {
247
+ for (var i = 0; i < 60; i++) {
248
+ var h = db.hello();
249
+ if (h.isWritablePrimary) return true;
250
+ sleep(1000);
251
+ }
252
+ return false;
253
+ }
254
+ var initialized = false;
255
+ try {
256
+ var s = rs.status();
257
+ if (s && s.ok === 1) initialized = true;
258
+ } catch (e) {
259
+ var m = String(e);
260
+ if (m.indexOf("requires authentication") >= 0 || m.indexOf("not authorized") >= 0 || m.indexOf("Unauthorized") >= 0) {
261
+ initialized = true;
262
+ } else if (m.indexOf("NotYetInitialized") < 0 && m.indexOf("no replset config") < 0) {
263
+ throw e;
264
+ }
265
+ }
266
+ if (!initialized) {
267
+ rs.initiate({ _id: RS, members: [{ _id: 0, host: HOST }] });
268
+ waitPrimary();
269
+ var admin = db.getSiblingDB("admin");
270
+ try {
271
+ admin.createUser({ user: U, pwd: P, roles: [{ role: "root", db: "admin" }] });
272
+ print("BOOTSTRAP_USER_CREATED");
273
+ } catch (e) {
274
+ var s2 = String(e);
275
+ if (s2.indexOf("already exists") < 0 && s2.indexOf("not authorized") < 0 && s2.indexOf("requires authentication") < 0) {
276
+ throw e;
277
+ }
278
+ print("BOOTSTRAP_USER_SKIP");
279
+ }
280
+ print("BOOTSTRAP_DONE");
281
+ } else {
282
+ print("BOOTSTRAP_ALREADY_INITIALIZED");
283
+ }
284
+ JS
285
+
286
+ mongosh --quiet --host 127.0.0.1 /tmp/mongo-bootstrap.js > /tmp/mongo-bootstrap.log 2>&1 || true
287
+ ) &
288
+
289
+ exec gosu mongodb mongod \\
290
+ --replSet "$RS" --auth --clusterAuthMode keyFile --keyFile "$KEYFILE" --bind_ip_all
291
+ `;
292
+ }
293
+
294
+ /**
295
+ * Renders the Prometheus scrape config (mirrors manifests/prometheus).
296
+ * @returns {string} prometheus.yml content.
297
+ * @memberof UnderpostDockerCompose
298
+ */
299
+ static prometheusContent() {
300
+ return `# Generated by 'underpost docker-compose --generate' — do not hand-edit.
301
+ global:
302
+ scrape_interval: 30s
303
+ evaluation_interval: 30s
304
+
305
+ scrape_configs:
306
+ - job_name: 'underpost-app'
307
+ metrics_path: /metrics
308
+ scheme: http
309
+ static_configs:
310
+ - targets: ['${METRICS_TARGET.service}:${METRICS_TARGET.port}']
311
+ - job_name: 'prometheus'
312
+ static_configs:
313
+ - targets: ['localhost:9090']
314
+ `;
315
+ }
316
+
317
+ /**
318
+ * Renders the Grafana datasource provisioning file wiring the Prometheus
319
+ * service as the default datasource.
320
+ * @returns {string} datasource.yml content.
321
+ * @memberof UnderpostDockerCompose
322
+ */
323
+ static grafanaDatasourceContent() {
324
+ return `# Generated by 'underpost docker-compose --generate' — do not hand-edit.
325
+ apiVersion: 1
326
+ datasources:
327
+ - name: Prometheus
328
+ type: prometheus
329
+ access: proxy
330
+ url: http://prometheus:9090
331
+ isDefault: true
332
+ editable: true
333
+ `;
334
+ }
335
+
336
+ /**
337
+ * Renders all dynamic supporting files: the nginx reverse-proxy config (from
338
+ * PROXY_HOSTS), the monitoring configs (Prometheus + Grafana datasource), and
339
+ * the env-file example. Creates a working env-file from the example only when
340
+ * one does not already exist (never overwrites credentials).
341
+ * @param {object} options - CLI options.
342
+ * @returns {void}
343
+ * @memberof UnderpostDockerCompose
344
+ */
345
+ static generate(options = {}) {
346
+ Nginx.removeRouter();
347
+ for (const { host, routes } of PROXY_HOSTS) Nginx.createApp({ host, routes });
348
+ Nginx.createDefaultServer(DEFAULT_UPSTREAM);
349
+ Nginx.writeConf(UnderpostDockerCompose.resolve(options.nginxConf || 'docker/nginx/default.conf'));
350
+
351
+ const mongoEntrypointPath = UnderpostDockerCompose.resolve('docker/mongodb/entrypoint.sh');
352
+ fs.mkdirpSync(nodePath.dirname(mongoEntrypointPath));
353
+ fs.writeFileSync(mongoEntrypointPath, UnderpostDockerCompose.mongoEntrypointContent(), { mode: 0o755 });
354
+ logger.info('mongodb entrypoint written', { path: mongoEntrypointPath });
355
+
356
+ const promPath = UnderpostDockerCompose.resolve('docker/prometheus/prometheus.yml');
357
+ fs.mkdirpSync(nodePath.dirname(promPath));
358
+ fs.writeFileSync(promPath, UnderpostDockerCompose.prometheusContent(), 'utf8');
359
+ const grafanaDsPath = UnderpostDockerCompose.resolve('docker/grafana/provisioning/datasources/datasource.yml');
360
+ fs.mkdirpSync(nodePath.dirname(grafanaDsPath));
361
+ fs.writeFileSync(grafanaDsPath, UnderpostDockerCompose.grafanaDatasourceContent(), 'utf8');
362
+ logger.info('monitoring config written', { prometheus: promPath, grafanaDatasource: grafanaDsPath });
363
+
364
+ const examplePath = UnderpostDockerCompose.resolve('docker/compose.env.example');
365
+ fs.mkdirpSync(nodePath.dirname(examplePath));
366
+ fs.writeFileSync(examplePath, UnderpostDockerCompose.envExampleContent(), 'utf8');
367
+ logger.info('env example written', { path: examplePath });
368
+
369
+ const envPath = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env');
370
+ if (!fs.existsSync(envPath)) {
371
+ fs.copySync(examplePath, envPath);
372
+ logger.warn('created working env-file from example — set real credentials', { path: envPath });
373
+ } else logger.info('working env-file already present (left untouched)', { path: envPath });
374
+
375
+ const deployId = options.deployId || DEFAULT_DEPLOY_ID;
376
+ const env = options.env || 'development';
377
+ const overridePath = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml');
378
+ fs.writeFileSync(overridePath, UnderpostDockerCompose.appOverrideContent(deployId, env), 'utf8');
379
+ logger.info('app command override written', { path: overridePath, deployId, env });
380
+ }
381
+
382
+ /**
383
+ * Installs Docker Engine and the Compose v2 plugin on RHEL-compatible hosts
384
+ * (Rocky Linux) from the official Docker CE repository. Idempotent and
385
+ * host-safe: skips installation when `docker compose` already works (unless
386
+ * `--force`), validates the platform, enables the service, and adds the
387
+ * invoking user to the `docker` group.
388
+ * @param {object} options - CLI options.
389
+ * @param {boolean} [options.force] - Reinstall even if Compose is already present.
390
+ * @returns {void}
391
+ * @memberof UnderpostDockerCompose
392
+ */
393
+ static install(options = {}) {
394
+ if (process.platform !== 'linux') {
395
+ logger.warn('docker-compose --install only supports Linux (RHEL/Rocky); skipping', {
396
+ platform: process.platform,
397
+ });
398
+ return;
399
+ }
400
+
401
+ if (!fs.existsSync('/etc/redhat-release')) {
402
+ logger.warn('Host does not look RHEL-compatible (/etc/redhat-release missing); proceeding with dnf anyway');
403
+ }
404
+
405
+ if (!options.force) {
406
+ const probe = shellExec('docker compose version', { silent: true, silentOnError: true });
407
+ if (probe && probe.code === 0) {
408
+ logger.info('Docker Compose already installed; skipping (use --force to reinstall)', {
409
+ version: (probe.stdout || '').trim(),
410
+ });
411
+ return;
412
+ }
413
+ }
414
+
415
+ shellExec(`sudo dnf -y install dnf-plugins-core`);
416
+ shellExec(`sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo`);
417
+ shellExec(`sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin`);
418
+
419
+ shellExec(`sudo systemctl enable --now docker`);
420
+
421
+ const user = options.user || process.env.SUDO_USER || process.env.USER || '';
422
+ if (user && user !== 'root') {
423
+ shellExec(`sudo groupadd docker 2>/dev/null || true`);
424
+ shellExec(`sudo usermod -aG docker ${user}`);
425
+ logger.info(`Added '${user}' to the docker group — log out/in (or run 'newgrp docker') to use docker rootless`);
426
+ }
427
+
428
+ const verify = shellExec('docker compose version', { silent: true, silentOnError: true, stdout: true });
429
+ logger.info('Docker Compose installation complete', { version: `${verify || ''}`.trim() });
430
+ }
431
+
432
+ /**
433
+ * Comprehensive reset of the Docker Compose stack — the compose equivalent of
434
+ * `cluster --reset` in src/cli/cluster.js. Tears down all stack containers,
435
+ * the project network, and the named volumes (destroying persisted MongoDB /
436
+ * Valkey / Prometheus / Grafana data), removes orphans, and prunes generated
437
+ * artifacts so the next `--up` is a clean slate. The working env-file
438
+ * (credentials) is preserved unless `--force` is passed.
439
+ * @param {object} options - CLI options.
440
+ * @param {boolean} [options.force] - Also remove the working env-file (docker/compose.env).
441
+ * @returns {void}
442
+ * @memberof UnderpostDockerCompose
443
+ */
444
+ static reset(options = {}) {
445
+ logger.info('=== DOCKER COMPOSE RESET (destroys containers, network, and volume data) ===');
446
+
447
+ // Phase 1: tear down containers, orphans, named volumes, and compose-built images.
448
+ shellExec(`${UnderpostDockerCompose.baseCmd(options)} down --remove-orphans --volumes --rmi local`, {
449
+ silentOnError: true,
450
+ });
451
+
452
+ // Phase 2: prune generated artifacts (regenerated on the next --generate/--up).
453
+ const artifacts = options.force
454
+ ? [...GENERATED_ARTIFACTS, options.envFile || 'docker/compose.env']
455
+ : GENERATED_ARTIFACTS;
456
+ for (const rel of artifacts) {
457
+ const target = UnderpostDockerCompose.resolve(rel);
458
+ if (fs.existsSync(target)) {
459
+ fs.removeSync(target);
460
+ logger.info('removed generated artifact', { path: target });
461
+ }
462
+ }
463
+
464
+ logger.info('Docker Compose reset complete. Run `--up` to recreate the stack.');
465
+ }
466
+
467
+ static API = {
468
+ /**
469
+ * @method callback
470
+ * @description Single CLI entrypoint for the docker-compose pipeline. The
471
+ * action is selected by flag options; multiple lifecycle flags may be
472
+ * combined (e.g. `--generate --up`). When no action flag is supplied it
473
+ * defaults to `--up`.
474
+ * @param {string} [target] - Optional service name (logs/shell/restart) or
475
+ * passthrough subcommand context.
476
+ * @param {object} [options] - CLI options.
477
+ * @param {boolean} [options.install] - Install Docker + Compose on RHEL/Rocky hosts.
478
+ * @param {boolean} [options.reset] - Comprehensive teardown of the whole stack (containers, network, volumes) + prune generated artifacts.
479
+ * @param {boolean} [options.force] - Force reinstall (--install), remove volumes (--down), or also drop the env-file (--reset).
480
+ * @param {boolean} [options.generate] - Render nginx config + env-file.
481
+ * @param {boolean} [options.up] - Start the full stack detached (implies generate).
482
+ * @param {boolean} [options.down] - Stop and remove containers.
483
+ * @param {boolean} [options.volumes] - With --down, also remove named volumes (destroys data).
484
+ * @param {boolean} [options.restart] - Restart services (optionally a single `target`).
485
+ * @param {boolean} [options.build] - With --up rebuild images; alone, `build --no-cache`.
486
+ * @param {boolean} [options.pull] - Pull upstream images.
487
+ * @param {boolean} [options.logs] - Follow logs (optionally a single `target`).
488
+ * @param {boolean} [options.status] - Show a formatted status table.
489
+ * @param {boolean} [options.shell] - Open an interactive shell in `target` (default: app).
490
+ * @param {string} [options.exec] - General-purpose passthrough docker compose subcommand.
491
+ * @param {string} [options.deployId] - Deployment to run as the app (default: dd-default). `dd-default` self-bootstraps a fresh engine; any other id runs the standard `underpost start` command.
492
+ * @param {string} [options.env] - Deployment environment for non-default deploy ids (default: development).
493
+ * @param {string} [options.composeFile] - Override compose file path.
494
+ * @param {string} [options.envFile] - Override env-file path.
495
+ * @param {string} [options.nginxConf] - Override generated nginx config path.
496
+ * @param {string} [options.appOverride] - Override generated app-command override path.
497
+ * @returns {Promise<void>}
498
+ * @memberof UnderpostDockerCompose
499
+ */
500
+ async callback(target = '', options = {}) {
501
+ try {
502
+ if (options.install) {
503
+ UnderpostDockerCompose.install(options);
504
+ const onlyInstall = !options.up && !options.down && !options.generate && !options.exec;
505
+ if (onlyInstall) return;
506
+ }
507
+
508
+ if (options.reset) {
509
+ UnderpostDockerCompose.reset(options);
510
+ const onlyReset = !options.up && !options.generate;
511
+ if (onlyReset) return;
512
+ }
513
+
514
+ // "Bring up" is the explicit --up or the no-action default.
515
+ const isUp =
516
+ options.up ||
517
+ !(
518
+ options.exec ||
519
+ options.down ||
520
+ options.restart ||
521
+ options.build ||
522
+ options.pull ||
523
+ options.logs ||
524
+ options.status ||
525
+ options.shell ||
526
+ options.generate
527
+ );
528
+
529
+ // Generate dynamic config BEFORE composing the invocation: baseCmd()
530
+ // conditionally includes `-f compose.app.yml`, so the override must
531
+ // exist first (notably after --reset, which prunes it).
532
+ if (isUp || options.generate) UnderpostDockerCompose.generate(options);
533
+
534
+ const base = UnderpostDockerCompose.baseCmd(options);
535
+
536
+ if (options.exec) {
537
+ shellExec(`${base} ${options.exec}`);
538
+ return;
539
+ }
540
+
541
+ if (isUp) {
542
+ shellExec(`${base} up -d${options.build ? ' --build' : ''}`);
543
+ return;
544
+ }
545
+
546
+ if (options.down) {
547
+ shellExec(`${base} down --remove-orphans${options.volumes ? ' --volumes' : ''}`);
548
+ return;
549
+ }
550
+
551
+ if (options.restart) {
552
+ shellExec(`${base} restart${target ? ` ${target}` : ''}`);
553
+ return;
554
+ }
555
+
556
+ if (options.build) {
557
+ shellExec(`${base} build --no-cache${target ? ` ${target}` : ''}`);
558
+ return;
559
+ }
560
+
561
+ if (options.pull) {
562
+ shellExec(`${base} pull`);
563
+ return;
564
+ }
565
+
566
+ if (options.logs) {
567
+ shellExec(`${base} logs -f --tail=200${target ? ` ${target}` : ''}`);
568
+ return;
569
+ }
570
+
571
+ if (options.status) {
572
+ shellExec(`${base} ps --format 'table {{.Name}}\\t{{.Service}}\\t{{.Status}}\\t{{.Ports}}'`);
573
+ return;
574
+ }
575
+
576
+ if (options.shell) {
577
+ const service = target || 'app';
578
+ shellExec(`${base} exec ${service} ${service === 'app' ? '/bin/bash' : '/bin/sh'}`);
579
+ return;
580
+ }
581
+
582
+ // Remaining case: --generate alone (config already written above).
583
+ } catch (error) {
584
+ logger.error(error);
585
+ process.exit(1);
586
+ }
587
+ },
588
+ };
589
+ }
590
+
591
+ export default UnderpostDockerCompose;