• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

underpostnet / engine-cyberia / 37071661130

02 Oct 2026 10:17PM UTC coverage: 80.614% (-0.08%) from 80.693%
37071661130

push

github

underpostnet
[deploy] Release the Cyberia repositories from a source channel
Run the lampp deploy from its own source repository
[cyberia-cli] Add the release commands for the product repositories and their lock
Sync stored object layers through the profile
Run both cleanup steps of import-content --clean
Skip stored item ids when importing content
[cyberia] Build a content release from the content revision its deployment locks
Build the local Docker stack images from the workspace sources
Declare the release content of dd-cyberia in one module
Keep the other definitions of a label when a backup restores one
Reload the game servers that serve a written database
Refresh the map preview a write touches
Allow the object layer viewer on the itemledger client
Route an audio binding stated without settings by its event
Seed the default audio of one map through the map audio API
Add the overhead-occlusion foreground behavior
Resolve sagas through instance defaults and summons
Show the saga of every item and map in the studio
Rework the cyberia editors into the studio suite
Serve the authoring context of every map and item
[client-core] Name the build manifest where the repository identity travels
Replace the render payload with the Underpost Build Manifest
Declare the retro font faces once for the shells
Share the document bar and tabs across the studio editors
Add the object layer public route
Give the route to the view raised last when a view closes
Share the submenu landing across the shells
Share undo, drafts and layout across the studio editors
[cli] Render a Docker Compose stack from the stack a project declares
Release a container image by the digest of an exact revision
Add the source-release command for source channels, mirrors and release jobs
Project the keys of one configuration scope into its own Secret
Promote the colour that --traffic names
Remove the instance builds the topology no longer declares
Add the footprint, batch timeout and diagnose options
[db]... (continued)

6440 of 8332 branches covered (77.29%)

Branch coverage included in aggregate %.

673 of 731 new or added lines in 30 files covered. (92.07%)

43 existing lines in 3 files now uncovered.

7599 of 9083 relevant lines covered (83.66%)

363.41 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.41
/src/cli/docker-compose.js
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 dotenv from 'dotenv';
14
import { dump as yamlDump } from 'js-yaml';
15
import { getRootDirectory, shellExec } from '../server/runtime/process.js';
16
import { loggerFactory } from '../server/ops/logger.js';
17
import { loadProjectExport } from '../server/runtime/conf.js';
18
import NginxService from '../runtime/nginx/Nginx.js';
19

20
const logger = loggerFactory(import.meta);
102 ✔
21

22
/**
23
 * Reverse-proxy route table derived from
24
 * manifests/deployment/dd-default-development/proxy.yaml (Contour HTTPProxy).
25
 * Longer prefixes precede '/' so nginx matches them first.
26
 * @constant PROXY_HOSTS
27
 * @memberof UnderpostDockerCompose
28
 */
29
const PROXY_HOSTS = [
102 ✔
30
  {
31
    host: 'default.net',
32
    routes: [
33
      { location: '/peer', target: 'app:4002' },
34
      { location: '/', target: 'app:4001' },
35
    ],
36
  },
37
  {
38
    host: 'www.default.net',
39
    routes: [{ location: '/', target: 'app:4003' }],
40
  },
41
];
42

43
/** Fallback upstream for unmatched Host headers and the proxy healthcheck. */
44
const DEFAULT_UPSTREAM = 'app:4001';
102 ✔
45

46
/** Prometheus scrape target: the app's /metrics endpoint on its base port. */
47
const METRICS_TARGET = { service: 'app', port: 4001 };
102 ✔
48

49
/** Default deployment identifier (the self-bootstrapping engine deploy). */
50
const DEFAULT_DEPLOY_ID = 'dd-default';
102 ✔
51

52
/**
53
 * Generated artifact paths (relative to repo root) that `--generate` produces
54
 * and `--reset` prunes. The working env-file (docker/compose.env) is excluded
55
 * so a reset never destroys configured credentials.
56
 */
57
const GENERATED_ARTIFACTS = [
102 ✔
58
  'docker/mongodb/entrypoint.sh',
59
  'docker/nginx/default.conf',
60
  'docker/prometheus/prometheus.yml',
61
  'docker/grafana/provisioning',
62
  'docker/compose.app.yml',
63
  'docker/compose.env.example',
64
];
65

66
/**
67
 * @class UnderpostDockerCompose
68
 * @description Docker Compose development pipeline. A single {@link UnderpostDockerCompose.API.callback}
69
 * dispatches actions based on flag options, mirroring the cluster CLI pattern.
70
 * @memberof UnderpostDockerCompose
71
 */
72
class UnderpostDockerCompose {
73
  /**
74
   * Resolves a repo-root-relative path to an absolute path.
75
   * @param {string} relPath - Path relative to the engine root.
76
   * @returns {string} Absolute path.
77
   * @memberof UnderpostDockerCompose
78
   */
79
  static resolve(relPath) {
80
    return nodePath.isAbsolute(relPath) ? relPath : nodePath.join(getRootDirectory(), relPath);
219 !
81
  }
82

83
  /**
84
   * Resolves the canonical directory for a custom docker-compose workflow,
85
   * keyed by `--deploy-id` + `--docker-compose-id`:
86
   * `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>`.
87
   * Its operator-owned `compose.env` is never generated. See
88
   * {@link UnderpostDockerCompose.generateStack} for the rest of the directory.
89
   * Returns null when `--docker-compose-id` is not set (the default,
90
   * self-generating workflow).
91
   * @param {object} options - CLI options.
92
   * @returns {string|null} Repo-root-relative canonical dir, or null.
93
   * @memberof UnderpostDockerCompose
94
   */
95
  static composeIdBase(options = {}) {
58 ✔
96
    if (!options.dockerComposeId) return null;
58 ✔
97
    const deployId = options.deployId || DEFAULT_DEPLOY_ID;
18 !
98
    return `engine-private/conf/${deployId}/docker-compose/${options.dockerComposeId}`;
58 ✔
99
  }
100

101
  /**
102
   * Builds the base `docker compose` invocation with explicit file and env-file,
103
   * so behavior is independent of the caller's working directory.
104
   *
105
   * Custom workflow (`--docker-compose-id`): the compose file, env-file, and
106
   * project-owned bind-mounted config all live in the canonical dir, so compose
107
   * runs with `--project-directory` pinned there and no app-override.
108
   * @param {object} options - CLI options.
109
   * @returns {string} The base command string (without a subcommand).
110
   * @memberof UnderpostDockerCompose
111
   */
112
  static baseCmd(options = {}) {
25 ✔
113
    const base = UnderpostDockerCompose.composeIdBase(options);
25 ✔
114
    if (base) {
25 ✔
115
      const projectDir = UnderpostDockerCompose.resolve(base);
2 ✔
116
      const composeFile = UnderpostDockerCompose.resolve(options.composeFile || `${base}/docker-compose.yml`);
2 ✔
117
      const envFile = UnderpostDockerCompose.resolve(options.envFile || `${base}/compose.env`);
2 ✔
118
      return `docker compose --project-directory ${projectDir} --env-file ${envFile} -f ${composeFile}`;
2 ✔
119
    }
120
    const composeFile = UnderpostDockerCompose.resolve(options.composeFile || 'docker-compose.yml');
23 ✔
121
    const envFile = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env');
25 ✔
122
    const overrideFile = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml');
25 ✔
123
    const overrideFlag = fs.existsSync(overrideFile) ? ` -f ${overrideFile}` : '';
25 ✔
124
    return `docker compose --env-file ${envFile} -f ${composeFile}${overrideFlag}`;
25 ✔
125
  }
126

127
  /**
128
   * Resolves the app container start command for a deployment, mirroring
129
   * src/cli/deploy.js. The `dd-default` deploy self-bootstraps a fresh engine
130
   * (matching manifests/deployment/dd-default-development); any other deploy-id
131
   * follows the standard deploy command (matching
132
   * manifests/deployment/dd-test-development and
133
   * UnderpostDeploy.deploymentYamlPartsFactory's default cmd).
134
   * @param {string} deployId - Deployment identifier (conf id, e.g. `dd-test`).
135
   * @param {string} env - Deployment environment (e.g. `development`).
136
   * @returns {string[]} Ordered shell steps joined with `&&` at render time.
137
   * @memberof UnderpostDockerCompose
138
   */
139
  static appCommand(deployId = DEFAULT_DEPLOY_ID, env = 'development') {
30 ✔
140
    if (deployId === DEFAULT_DEPLOY_ID)
15 ✔
141
      return [
13 ✔
142
        // `$$` escapes to a literal `$` so /bin/sh (not Compose) performs the
143
        // command/variable substitution at container runtime.
144
        'cd $$(underpost root)/underpost',
145
        'node bin new --default-conf --conf-workflow-id template',
146
        // Point the template .env.example at the Docker service-discovery hosts
147
        // before `new engine` copies it. The generated dd-engine/.env.development
148
        // is seeded from this file and is applied over the process env by
149
        // loadConf (src/server/runtime/conf.js), so without this the engine would fall
150
        // back to the .env.example localhost defaults for DB_HOST/VALKEY_HOST.
151
        'sed -i "s#^DB_HOST=.*#DB_HOST=$${DB_HOST}#" .env.example',
152
        'sed -i "s#^VALKEY_HOST=.*#VALKEY_HOST=$${VALKEY_HOST}#" .env.example',
153
        'mkdir -p /home/dd',
154
        'cd /home/dd',
155
        'underpost new engine',
156
      ];
157
    return [`underpost start --build --run ${deployId} ${env}`];
2 ✔
158
  }
159

160
  /**
161
   * Renders a Compose override file that sets only the `app` service command
162
   * for the selected deployment. Keeping the command in an override file makes
163
   * the generator the single source of truth and leaves docker-compose.yml
164
   * deployment-agnostic.
165
   * @param {string} deployId - Deployment identifier.
166
   * @param {string} env - Deployment environment.
167
   * @returns {string} The override YAML document.
168
   * @memberof UnderpostDockerCompose
169
   */
170
  static appOverrideContent(deployId = DEFAULT_DEPLOY_ID, env = 'development') {
26 ✔
171
    const steps = UnderpostDockerCompose.appCommand(deployId, env).join(' &&\n        ');
13 ✔
172
    return `# Generated by 'underpost docker-compose --generate --deploy-id ${deployId}' — do not hand-edit.
13 ✔
173
# Overrides the app service start command for deploy '${deployId}' (${env}).
174
services:
175
  app:
176
    command:
177
      - /bin/sh
178
      - -c
179
      - >
180
        ${steps}
181
`;
182
  }
183

184
  /**
185
   * Renders the dynamic env-file example content for the compose stack.
186
   * @returns {string} The env-file template.
187
   * @memberof UnderpostDockerCompose
188
   */
189
  static envExampleContent() {
190
    return `# Generated by 'underpost docker-compose --generate' — copy to docker/compose.env.
14 ✔
191
# Loaded explicitly via --env-file so container service-discovery values are not
192
# overridden by the application's host-local (127.0.0.1) root .env.
193

194
# --- Container images ----------------------------------------------------
195
MONGO_IMAGE=mongo:latest
196
VALKEY_IMAGE=valkey/valkey:latest
197
APP_IMAGE=underpost/underpost-engine
198
APP_TAG=v3.4.5
199
PROXY_IMAGE=nginx:stable-alpine
200
PROMETHEUS_IMAGE=prom/prometheus:latest
201
GRAFANA_IMAGE=grafana/grafana:latest
202

203
# --- MongoDB (translation of Secret mongodb-secret) ----------------------
204
MONGO_INITDB_ROOT_USERNAME=admin
205
MONGO_INITDB_ROOT_PASSWORD=changeme
206

207
# --- Application DB connection (Docker service discovery, not localhost) --
208
NODE_ENV=development
209
DB_PROVIDER=mongoose
210
DB_HOST=mongodb://mongodb:27017
211
DB_NAME=default
212
DB_REPLICA_SET=rs0
213
DB_AUTH_SOURCE=admin
214

215
# --- Valkey (Docker service discovery) -----------------------------------
216
VALKEY_HOST=valkey-service
217
VALKEY_PORT=6379
218

219
# --- Monitoring ----------------------------------------------------------
220
GRAFANA_ADMIN_USER=admin
221
GRAFANA_ADMIN_PASSWORD=admin
222

223
# --- Host-published ports ------------------------------------------------
224
PROXY_HTTP_PORT=80
225
MONGO_HOST_PORT=27017
226
VALKEY_NODEPORT=32079
227
PROMETHEUS_PORT=9090
228
GRAFANA_PORT=3000
229
APP_PORT_4001=4001
230
APP_PORT_4002=4002
231
APP_PORT_4003=4003
232
APP_PORT_4004=4004
233
`;
234
  }
235

236
  /**
237
   * Renders the MongoDB container entrypoint. Mirrors src/db/mongo/MongoBootstrap.js:
238
   * generates the cluster-auth keyfile, launches mongod (replSet + keyFile + auth),
239
   * and bootstraps the replica set + root user over the loopback localhost
240
   * exception (single-node member is the `mongodb` service name so app clients
241
   * connecting with replicaSet=rs0 resolve a reachable host). Idempotent across
242
   * restarts: re-runs are no-ops once auth is enforced.
243
   * @returns {string} entrypoint.sh content.
244
   * @memberof UnderpostDockerCompose
245
   */
246
  static mongoEntrypointContent() {
247
    return `#!/usr/bin/env bash
17 ✔
248
# Generated by 'underpost docker-compose --generate' — do not hand-edit.
249
set -e
250

251
KEYFILE=/opt/keyfile/mongodb-keyfile
252
RS="\${DB_REPLICA_SET:-rs0}"
253
MEMBER_HOST="mongodb:27017"
254

255
if [ ! -s "$KEYFILE" ]; then
256
  openssl rand -base64 756 > "$KEYFILE"
257
fi
258
chmod 400 "$KEYFILE"
259
chown 999:999 "$KEYFILE"
260
mkdir -p /data/db
261
chown -R 999:999 /data/db
262

263
# One-time replica-set + root-user bootstrap via the localhost exception,
264
# performed in the background once mongod accepts loopback connections.
265
(
266
  for i in $(seq 1 60); do
267
    if mongosh --quiet --host 127.0.0.1 --eval 'db.adminCommand({ ping: 1 })' >/dev/null 2>&1; then
268
      break
269
    fi
270
    sleep 1
271
  done
272

273
  cat > /tmp/mongo-bootstrap.js <<JS
274
var RS = "$RS";
275
var HOST = "$MEMBER_HOST";
276
var U = "$MONGO_INITDB_ROOT_USERNAME";
277
var P = "$MONGO_INITDB_ROOT_PASSWORD";
278
function waitPrimary() {
279
  for (var i = 0; i < 60; i++) {
280
    var h = db.hello();
281
    if (h.isWritablePrimary) return true;
282
    sleep(1000);
283
  }
284
  return false;
285
}
286
var initialized = false;
287
try {
288
  var s = rs.status();
289
  if (s && s.ok === 1) initialized = true;
290
} catch (e) {
291
  var m = String(e);
292
  if (m.indexOf("requires authentication") >= 0 || m.indexOf("not authorized") >= 0 || m.indexOf("Unauthorized") >= 0) {
293
    initialized = true;
294
  } else if (m.indexOf("NotYetInitialized") < 0 && m.indexOf("no replset config") < 0) {
295
    throw e;
296
  }
297
}
298
if (!initialized) {
299
  rs.initiate({ _id: RS, members: [{ _id: 0, host: HOST }] });
300
  waitPrimary();
301
  var admin = db.getSiblingDB("admin");
302
  try {
303
    admin.createUser({ user: U, pwd: P, roles: [{ role: "root", db: "admin" }] });
304
    print("BOOTSTRAP_USER_CREATED");
305
  } catch (e) {
306
    var s2 = String(e);
307
    if (s2.indexOf("already exists") < 0 && s2.indexOf("not authorized") < 0 && s2.indexOf("requires authentication") < 0) {
308
      throw e;
309
    }
310
    print("BOOTSTRAP_USER_SKIP");
311
  }
312
  print("BOOTSTRAP_DONE");
313
} else {
314
  print("BOOTSTRAP_ALREADY_INITIALIZED");
315
}
316
JS
317

318
  mongosh --quiet --host 127.0.0.1 /tmp/mongo-bootstrap.js > /tmp/mongo-bootstrap.log 2>&1 || true
319
) &
320

321
exec gosu mongodb mongod \\
322
  --replSet "$RS" --auth --clusterAuthMode keyFile --keyFile "$KEYFILE" --bind_ip_all
323
`;
324
  }
325

326
  /**
327
   * Renders the Prometheus scrape config (mirrors manifests/prometheus).
328
   * @returns {string} prometheus.yml content.
329
   * @memberof UnderpostDockerCompose
330
   */
331
  static prometheusContent() {
332
    return `# Generated by 'underpost docker-compose --generate' — do not hand-edit.
14 ✔
333
global:
334
  scrape_interval: 30s
335
  evaluation_interval: 30s
336

337
scrape_configs:
338
  - job_name: 'underpost-app'
339
    metrics_path: /metrics
340
    scheme: http
341
    static_configs:
342
      - targets: ['${METRICS_TARGET.service}:${METRICS_TARGET.port}']
343
  - job_name: 'prometheus'
344
    static_configs:
345
      - targets: ['localhost:9090']
346
`;
347
  }
348

349
  /**
350
   * Renders the Grafana datasource provisioning file wiring the Prometheus
351
   * service as the default datasource.
352
   * @returns {string} datasource.yml content.
353
   * @memberof UnderpostDockerCompose
354
   */
355
  static grafanaDatasourceContent() {
356
    return `# Generated by 'underpost docker-compose --generate' — do not hand-edit.
14 ✔
357
apiVersion: 1
358
datasources:
359
  - name: Prometheus
360
    type: prometheus
361
    access: proxy
362
    url: http://prometheus:9090
363
    isDefault: true
364
    editable: true
365
`;
366
  }
367

368
  /**
369
   * Renders the IPFS (Kubo) init hook: binds the RPC API and the gateway to every interface, so
370
   * the other services reach the daemon at `http://ipfs:5001`. Mirrors manifests/ipfs/configmap.yaml.
371
   * @returns {string} configure-ipfs.sh content.
372
   * @memberof UnderpostDockerCompose
373
   */
374
  static ipfsInitContent() {
NEW
375
    return `#!/bin/sh
×
376
# Generated by 'underpost docker-compose --generate' — do not hand-edit.
377
set -e
378

379
ipfs config Addresses.API /ip4/0.0.0.0/tcp/5001
380
ipfs config Addresses.Gateway /ip4/0.0.0.0/tcp/8080
381
ipfs config --json Swarm.ConnMgr.HighWater 2000
382
ipfs config --json Datastore.BloomFilterSize 1048576
383
ipfs config Datastore.StorageMax 100GB
384
`;
385
  }
386

387
  /**
388
   * Renders a custom workflow from the stack its project declares: the `composeStacks[<docker-compose-id>]`
389
   * export of `src/projects/<project>/compose-stack.js`. The stack gets `{ deployId, env, composeEnv, nginx }`,
390
   * declares its gateway on `nginx` and returns the compose document. The rendering holds
391
   * `docker-compose.yml`, `nginx.conf`, and the platform init script of a `mongodb` and an `ipfs` service.
392
   * @param {object} options - CLI options.
393
   * @returns {Promise<Object<string,string>|null>} Content by absolute path; null without a declared stack.
394
   * @throws {Error} When the canonical directory lacks a file the workflow reads.
395
   * @memberof UnderpostDockerCompose
396
   */
397
  static async renderStack(options = {}) {
6 ✔
398
    const base = UnderpostDockerCompose.composeIdBase(options);
6 ✔
399
    const deployId = options.deployId || DEFAULT_DEPLOY_ID;
6 !
400
    const envFile = UnderpostDockerCompose.resolve(options.envFile || `${base}/compose.env`);
6 ✔
401
    const composeFile = UnderpostDockerCompose.resolve(options.composeFile || `${base}/docker-compose.yml`);
6 ✔
402
    const stack = (await loadProjectExport(deployId, 'compose-stack.js', 'composeStacks'))?.[options.dockerComposeId];
6 ✔
403
    const missing = (stack ? [envFile] : [composeFile, envFile]).filter((path) => !fs.existsSync(path));
9 ✔
404
    if (missing.length) throw new Error(`Custom Docker Compose workflow is missing: ${missing.join(', ')}`);
6 ✔
405
    if (!stack) return null;
4 ✔
406

407
    const origin = `'underpost docker-compose --generate --deploy-id ${deployId} --docker-compose-id ${options.dockerComposeId}'`;
2 ✔
408
    const nginx = new NginxService();
2 ✔
409
    const compose = stack({
2 ✔
410
      deployId,
411
      env: options.env || 'development',
3 ✔
412
      composeEnv: dotenv.parse(fs.readFileSync(envFile, 'utf8')),
413
      nginx,
414
    });
415
    const dir = UnderpostDockerCompose.resolve(base);
6 ✔
416
    const files = {
6 ✔
417
      [composeFile]: `# Generated by ${origin} — do not hand-edit.\n${yamlDump(compose, { lineWidth: -1, noRefs: true })}`,
418
    };
419
    if (nginx.servers.length) files[`${dir}/nginx.conf`] = nginx.render({ origin });
6 ✔
420
    if (compose.services?.mongodb)
2 !
421
      files[`${dir}/mongodb/entrypoint.sh`] = UnderpostDockerCompose.mongoEntrypointContent();
2 ✔
422
    if (compose.services?.ipfs) files[`${dir}/ipfs/configure-ipfs.sh`] = UnderpostDockerCompose.ipfsInitContent();
2 !
423
    return files;
2 ✔
424
  }
425

426
  /**
427
   * Writes the rendering of a custom workflow (see {@link UnderpostDockerCompose.renderStack}). Without a
428
   * declared stack, the directory's own `docker-compose.yml` is used as-is.
429
   * @param {object} options - CLI options.
430
   * @returns {Promise<void>}
431
   * @memberof UnderpostDockerCompose
432
   */
433
  static async generateStack(options = {}) {
6 ✔
434
    const files = await UnderpostDockerCompose.renderStack(options);
6 ✔
435
    if (!files) {
4 ✔
436
      logger.info('custom Docker Compose workflow uses canonical files as-is', {
2 ✔
437
        path: UnderpostDockerCompose.composeIdBase(options),
438
      });
439
      return;
2 ✔
440
    }
441
    for (const [target, content] of Object.entries(files)) {
2 ✔
442
      fs.mkdirpSync(nodePath.dirname(target));
6 ✔
443
      fs.writeFileSync(target, content, target.endsWith('.sh') ? { mode: 0o755 } : 'utf8');
6 ✔
444
    }
445
    logger.info('custom Docker Compose workflow generated', {
2 ✔
446
      deployId: options.deployId || DEFAULT_DEPLOY_ID,
2 !
447
      env: options.env || 'development',
7 ✔
448
      written: Object.keys(files),
449
    });
450
  }
451

452
  /**
453
   * Renders all dynamic supporting files: the nginx reverse-proxy config (from
454
   * PROXY_HOSTS), the monitoring configs (Prometheus + Grafana datasource), and
455
   * the env-file example. Creates a working env-file from the example only when
456
   * one does not already exist (never overwrites credentials). A custom workflow
457
   * renders its own stack instead: see {@link UnderpostDockerCompose.generateStack}.
458
   * @param {object} options - CLI options.
459
   * @returns {Promise<void>}
460
   * @memberof UnderpostDockerCompose
461
   */
462
  static async generate(options = {}) {
19 ✔
463
    if (UnderpostDockerCompose.composeIdBase(options)) return UnderpostDockerCompose.generateStack(options);
19 ✔
464

465
    const nginx = new NginxService();
13 ✔
466
    for (const { host, routes } of PROXY_HOSTS)
13 ✔
467
      nginx.addServer({
26 ✔
468
        names: [host],
469
        health: true,
470
        locations: routes.map(({ location, target }) => ({ match: location, proxy: target })),
39 ✔
471
      });
472
    nginx.addServer({
13 ✔
473
      listen: ['80 default_server'],
474
      health: true,
475
      locations: [{ match: '/', proxy: DEFAULT_UPSTREAM }],
476
    });
477
    nginx.writeConf(UnderpostDockerCompose.resolve(options.nginxConf || 'docker/nginx/default.conf'), {
13 ✔
478
      origin: "'underpost docker-compose --generate'",
479
    });
480

481
    const mongoEntrypointPath = UnderpostDockerCompose.resolve('docker/mongodb/entrypoint.sh');
19 ✔
482
    fs.mkdirpSync(nodePath.dirname(mongoEntrypointPath));
19 ✔
483
    fs.writeFileSync(mongoEntrypointPath, UnderpostDockerCompose.mongoEntrypointContent(), { mode: 0o755 });
19 ✔
484
    logger.info('mongodb entrypoint written', { path: mongoEntrypointPath });
19 ✔
485

486
    const promPath = UnderpostDockerCompose.resolve('docker/prometheus/prometheus.yml');
19 ✔
487
    fs.mkdirpSync(nodePath.dirname(promPath));
19 ✔
488
    fs.writeFileSync(promPath, UnderpostDockerCompose.prometheusContent(), 'utf8');
19 ✔
489
    const grafanaDsPath = UnderpostDockerCompose.resolve('docker/grafana/provisioning/datasources/datasource.yml');
19 ✔
490
    fs.mkdirpSync(nodePath.dirname(grafanaDsPath));
19 ✔
491
    fs.writeFileSync(grafanaDsPath, UnderpostDockerCompose.grafanaDatasourceContent(), 'utf8');
19 ✔
492
    logger.info('monitoring config written', { prometheus: promPath, grafanaDatasource: grafanaDsPath });
19 ✔
493

494
    const examplePath = UnderpostDockerCompose.resolve('docker/compose.env.example');
19 ✔
495
    fs.mkdirpSync(nodePath.dirname(examplePath));
19 ✔
496
    fs.writeFileSync(examplePath, UnderpostDockerCompose.envExampleContent(), 'utf8');
19 ✔
497
    logger.info('env example written', { path: examplePath });
19 ✔
498

499
    const envPath = UnderpostDockerCompose.resolve(options.envFile || 'docker/compose.env');
19 ✔
500
    if (!fs.existsSync(envPath)) {
19 ✔
501
      fs.copySync(examplePath, envPath);
12 ✔
502
      logger.warn('created working env-file from example — set real credentials', { path: envPath });
12 ✔
503
    } else logger.info('working env-file already present (left untouched)', { path: envPath });
1 ✔
504

505
    const deployId = options.deployId || DEFAULT_DEPLOY_ID;
13 ✔
506
    const env = options.env || 'development';
19 ✔
507
    const overridePath = UnderpostDockerCompose.resolve(options.appOverride || 'docker/compose.app.yml');
19 ✔
508
    fs.writeFileSync(overridePath, UnderpostDockerCompose.appOverrideContent(deployId, env), 'utf8');
19 ✔
509
    logger.info('app command override written', { path: overridePath, deployId, env });
19 ✔
510
  }
511

512
  /**
513
   * Installs Docker Engine and the Compose v2 plugin on RHEL-compatible hosts
514
   * (Rocky Linux) from the official Docker CE repository. Idempotent and
515
   * host-safe: skips installation when `docker compose` already works (unless
516
   * `--force`), validates the platform, enables the service, and adds the
517
   * invoking user to the `docker` group.
518
   * @param {object} options - CLI options.
519
   * @param {boolean} [options.force] - Reinstall even if Compose is already present.
520
   * @returns {void}
521
   * @memberof UnderpostDockerCompose
522
   */
523
  static install(options = {}) {
6 ✔
524
    if (process.platform !== 'linux') {
6 ✔
525
      logger.warn('docker-compose --install only supports Linux (RHEL/Rocky); skipping', {
1 ✔
526
        platform: process.platform,
527
      });
528
      return;
1 ✔
529
    }
530

531
    if (!fs.existsSync('/etc/redhat-release')) {
5 ✔
532
      logger.warn('Host does not look RHEL-compatible (/etc/redhat-release missing); proceeding with dnf anyway');
1 ✔
533
    }
534

535
    if (!options.force) {
5 ✔
536
      const probe = shellExec('docker compose version', { silent: true, silentOnError: true });
4 ✔
537
      if (probe && probe.code === 0) {
4 ✔
538
        logger.info('Docker Compose already installed; skipping (use --force to reinstall)', {
3 ✔
539
          version: (probe.stdout || '').trim(),
3 !
540
        });
541
        return;
3 ✔
542
      }
543
    }
544

545
    shellExec(`sudo dnf -y install dnf-plugins-core`);
2 ✔
546
    shellExec(`sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo`);
2 ✔
547
    shellExec(`sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin`);
2 ✔
548

549
    shellExec(`sudo systemctl enable --now docker`);
2 ✔
550

551
    const user = options.user || process.env.SUDO_USER || process.env.USER || '';
2 !
552
    if (user && user !== 'root') {
6 ✔
553
      shellExec(`sudo groupadd docker 2>/dev/null || true`);
1 ✔
554
      shellExec(`sudo usermod -aG docker ${user}`);
1 ✔
555
      logger.info(`Added '${user}' to the docker group — log out/in (or run 'newgrp docker') to use docker rootless`);
1 ✔
556
    }
557

558
    const verify = shellExec('docker compose version', { silent: true, silentOnError: true, stdout: true });
2 ✔
559
    logger.info('Docker Compose installation complete', { version: `${verify || ''}`.trim() });
2 ✔
560
  }
561

562
  /**
563
   * Comprehensive reset of the Docker Compose stack — the compose equivalent of
564
   * `cluster --reset` in src/cli/cluster.js. Tears down all stack containers,
565
   * the project network, and the named volumes (destroying persisted MongoDB /
566
   * Valkey / Prometheus / Grafana data), removes orphans, and prunes generated
567
   * artifacts so the next `--up` is a clean slate. The working env-file
568
   * (credentials) is preserved unless `--force` is passed.
569
   * @param {object} options - CLI options.
570
   * @param {boolean} [options.force] - Also remove the working env-file (docker/compose.env).
571
   * @returns {void}
572
   * @memberof UnderpostDockerCompose
573
   */
574
  static reset(options = {}) {
5 ✔
575
    logger.info('=== DOCKER COMPOSE RESET (destroys containers, network, and volume data) ===');
5 ✔
576

577
    // Phase 1: tear down containers, orphans, named volumes, and compose-built images.
578
    shellExec(`${UnderpostDockerCompose.baseCmd(options)} down --remove-orphans --volumes --rmi local`, {
5 ✔
579
      silentOnError: true,
580
    });
581

582
    // A named custom workflow owns every file beneath its canonical directory.
583
    // Reset tears down Docker resources but never prunes project-owned files.
584
    const composeIdBase = UnderpostDockerCompose.composeIdBase(options);
5 ✔
585
    if (composeIdBase) {
5 ✔
586
      logger.info('Docker Compose reset complete. Run `--up` to recreate the stack.');
1 ✔
587
      return;
1 ✔
588
    }
589

590
    // Phase 2: prune generated artifacts (regenerated on the next --generate/--up).
591
    const artifacts = options.force
4 ✔
592
      ? [...GENERATED_ARTIFACTS, options.envFile || 'docker/compose.env']
2 ✔
593
      : GENERATED_ARTIFACTS;
594
    for (const rel of artifacts) {
5 ✔
595
      const target = UnderpostDockerCompose.resolve(rel);
25 ✔
596
      if (fs.existsSync(target)) {
25 ✔
597
        fs.removeSync(target);
2 ✔
598
        logger.info('removed generated artifact', { path: target });
2 ✔
599
      }
600
    }
601

602
    logger.info('Docker Compose reset complete. Run `--up` to recreate the stack.');
4 ✔
603
  }
604

605
  static API = {
102 ✔
606
    /**
607
     * @method callback
608
     * @description Single CLI entrypoint for the docker-compose pipeline. The
609
     * action is selected by flag options; multiple lifecycle flags may be
610
     * combined (e.g. `--generate --up`). When no action flag is supplied it
611
     * defaults to `--up`.
612
     * @param {string} [target] - Optional service name (logs/shell/restart) or
613
     *   passthrough subcommand context.
614
     * @param {object} [options] - CLI options.
615
     * @param {boolean} [options.install] - Install Docker + Compose on RHEL/Rocky hosts.
616
     * @param {boolean} [options.reset] - Comprehensive teardown of the whole stack (containers, network, volumes) + prune generated artifacts.
617
     * @param {boolean} [options.force] - Force reinstall (--install), remove volumes (--down), or also drop the env-file (--reset).
618
     * @param {boolean} [options.generate] - Render nginx config + env-file.
619
     * @param {boolean} [options.up] - Start the full stack detached (implies generate).
620
     * @param {boolean} [options.down] - Stop and remove containers.
621
     * @param {boolean} [options.volumes] - With --down, also remove named volumes (destroys data).
622
     * @param {boolean} [options.restart] - Restart services (optionally a single `target`).
623
     * @param {boolean} [options.build] - With --up rebuild images; alone, `build --no-cache`.
624
     * @param {boolean} [options.pull] - Pull upstream images.
625
     * @param {boolean} [options.logs] - Follow logs (optionally a single `target`).
626
     * @param {boolean} [options.status] - Show a formatted status table.
627
     * @param {boolean} [options.shell] - Open an interactive shell in `target` (default: app).
628
     * @param {string} [options.exec] - General-purpose passthrough docker compose subcommand.
629
     * @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.
630
     * @param {string} [options.dockerComposeId] - Custom-workflow selector. When set, use the canonical stack at `engine-private/conf/<deploy-id>/docker-compose/<docker-compose-id>/`, rendered from its project stack when the project declares one.
631
     * @param {string} [options.env] - Deployment environment of a non-default deploy id or a custom stack (default: development).
632
     * @param {string} [options.composeFile] - Override compose file path.
633
     * @param {string} [options.envFile] - Override env-file path.
634
     * @param {string} [options.nginxConf] - Override generated nginx config path.
635
     * @param {string} [options.appOverride] - Override generated app-command override path.
636
     * @returns {Promise<void>}
637
     * @memberof UnderpostDockerCompose
638
     */
639
    async callback(target = '', options = {}) {
42 ✔
640
      try {
21 ✔
641
        if (options.install) {
21 ✔
642
          UnderpostDockerCompose.install(options);
2 ✔
643
          const onlyInstall = !options.up && !options.down && !options.generate && !options.exec;
2 ✔
644
          if (onlyInstall) return;
2 ✔
645
        }
646

647
        if (options.reset) {
20 ✔
648
          UnderpostDockerCompose.reset(options);
2 ✔
649
          const onlyReset = !options.up && !options.generate;
2 ✔
650
          if (onlyReset) return;
2 ✔
651
        }
652

653
        // "Bring up" is the explicit --up or the no-action default.
654
        const isUp =
655
          options.up ||
19 ✔
656
          !(
657
            options.exec ||
73 ✔
658
            options.down ||
659
            options.restart ||
660
            options.build ||
661
            options.pull ||
662
            options.logs ||
663
            options.status ||
664
            options.shell ||
665
            options.generate
666
          );
667

668
        // Generate dynamic config BEFORE composing the invocation: baseCmd()
669
        // conditionally includes `-f compose.app.yml`, so the override must
670
        // exist first (notably after --reset, which prunes it).
671
        if (isUp || options.generate) await UnderpostDockerCompose.generate(options);
21 ✔
672

673
        const base = UnderpostDockerCompose.baseCmd(options);
19 ✔
674

675
        if (options.exec) {
19 ✔
676
          shellExec(`${base} ${options.exec}`);
1 ✔
677
          return;
1 ✔
678
        }
679

680
        if (isUp) {
18 ✔
681
          shellExec(`${base} up -d${options.build ? ' --build' : ''}`);
6 ✔
682
          return;
6 ✔
683
        }
684

685
        if (options.down) {
12 ✔
686
          shellExec(`${base} down --remove-orphans${options.volumes ? ' --volumes' : ''}`);
2 ✔
687
          return;
2 ✔
688
        }
689

690
        if (options.restart) {
10 ✔
691
          shellExec(`${base} restart${target ? ` ${target}` : ''}`);
2 ✔
692
          return;
2 ✔
693
        }
694

695
        if (options.build) {
8 ✔
696
          shellExec(`${base} build --no-cache${target ? ` ${target}` : ''}`);
1 !
697
          return;
1 ✔
698
        }
699

700
        if (options.pull) {
7 ✔
701
          shellExec(`${base} pull`);
1 ✔
702
          return;
1 ✔
703
        }
704

705
        if (options.logs) {
6 ✔
706
          shellExec(`${base} logs -f --tail=200${target ? ` ${target}` : ''}`);
2 ✔
707
          return;
2 ✔
708
        }
709

710
        if (options.status) {
4 ✔
711
          shellExec(`${base} ps --format 'table {{.Name}}\\t{{.Service}}\\t{{.Status}}\\t{{.Ports}}'`);
1 ✔
712
          return;
1 ✔
713
        }
714

715
        if (options.shell) {
3 ✔
716
          const service = target || 'app';
2 ✔
717
          shellExec(`${base} exec ${service} ${service === 'app' ? '/bin/bash' : '/bin/sh'}`);
2 ✔
718
          return;
2 ✔
719
        }
720

721
        // Remaining case: --generate alone (config already written above).
722
      } catch (error) {
723
        logger.error(error);
1 ✔
724
        process.exit(1);
1 ✔
725
      }
726
    },
727
  };
728
}
729

730
export default UnderpostDockerCompose;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc