Skip to content

Commit 0d2866c

Browse files
authored
fix: support mounted .env files in container deployments (#1196)
* fix: support mounted .env files in container deployments without custom entrypoints * test: cover env loader edge cases
1 parent d79ca0d commit 0d2866c

16 files changed

Lines changed: 336 additions & 15 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ A Keychain MDIP node includes several interoperating microservices. If you follo
3939

4040
Customize your node in the kc/.env file. Environment variables are documented for each service in the READMEs linked in the Overview above.
4141

42+
When running the services outside `./start-node`, the supported configuration methods are:
43+
44+
- inject environment variables directly with Docker Compose or Kubernetes
45+
- mount a shared `.env` file at `/app/.env` inside the container
46+
- mount a `.env` file anywhere and set `KC_ENV_FILE` to that path
47+
48+
This matches local startup and avoids custom entrypoint wrappers just to source env files.
49+
4250
```
4351
KC_UID=1000 # Docker host UID
4452
KC_GID=1002 # Docker host GID

doc/02-server/deployment/index.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ Another important file controlling the behavior of an MDIP node is the `kc/docke
219219

220220
Operators wanting to operate MDIP in a Kubernetes or similar virtualized environment will find the environment variable dependencies for each MDIP component in the `kc/docker-compose.yml` file.
221221

222+
MDIP services support the same configuration model in container orchestration environments without custom wrapper scripts. Operators can either inject environment variables directly, mount a shared `.env` file at `/app/.env`, or mount a file elsewhere and set `KC_ENV_FILE` to the mounted path.
223+
222224
#### Gatekeeper Service
223225

224226
Below is the MDIP `gatekeeper` definition.
@@ -753,4 +755,3 @@ server {
753755
allow all;
754756
}
755757
```
756-

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const config = {
1414
moduleNameMapper: {
1515
'^@mdip/cipher/node$': '<rootDir>/packages/cipher/src/cipher-node.ts',
1616
'^@mdip/cipher/types': '<rootDir>/packages/cipher/src/types.ts',
17+
'^@mdip/common/env$': '<rootDir>/packages/common/src/env.ts',
1718
'^@mdip/common/errors$': '<rootDir>/packages/common/src/errors.ts',
1819
'^@mdip/common/utils$': '<rootDir>/packages/common/src/utils.ts',
1920
'^@mdip/common/logger': '<rootDir>/packages/common/src/logger.ts',

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/common/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
"require": "./dist/cjs/index.cjs",
1919
"types": "./dist/types/index.d.ts"
2020
},
21+
"./env": {
22+
"import": "./dist/esm/env.js",
23+
"require": "./dist/cjs/env.cjs",
24+
"types": "./dist/types/env.d.ts"
25+
},
2126
"./utils": {
2227
"import": "./dist/esm/utils.js",
2328
"require": "./dist/cjs/utils.cjs",
@@ -36,6 +41,9 @@
3641
},
3742
"typesVersions": {
3843
"*": {
44+
"env": [
45+
"./dist/types/env.d.ts"
46+
],
3947
"utils": [
4048
"./dist/types/utils.d.ts"
4149
],
@@ -57,6 +65,7 @@
5765
"author": "David McFadzean <davidmc@gmail.com>",
5866
"license": "MIT",
5967
"dependencies": {
68+
"dotenv": "^17.3.1",
6069
"pino": "^10.3.1",
6170
"pino-pretty": "^13.1.3"
6271
},

packages/common/rollup.cjs.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const external = [
1010

1111
const config = {
1212
input: {
13+
env: 'dist/esm/env.js',
1314
'index': 'dist/esm/index.js',
1415
utils: 'dist/esm/utils.js',
1516
errors: 'dist/esm/errors.js',

packages/common/src/env.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import dotenv from 'dotenv';
4+
5+
const DEFAULT_ENV_FILENAME = '.env';
6+
const DEFAULT_ENV_FILE_VAR = 'KC_ENV_FILE';
7+
8+
export interface LoadEnvOptions {
9+
cwd?: string;
10+
envFileVar?: string;
11+
filenames?: string[];
12+
}
13+
14+
function hasPackageJson(dir: string): boolean {
15+
return fs.existsSync(path.join(dir, 'package.json'));
16+
}
17+
18+
function hasWorkspaceRootMarker(dir: string): boolean {
19+
if (fs.existsSync(path.join(dir, '.git')) || fs.existsSync(path.join(dir, 'docker-compose.yml'))) {
20+
return true;
21+
}
22+
23+
const packageJsonPath = path.join(dir, 'package.json');
24+
25+
if (!fs.existsSync(packageJsonPath)) {
26+
return false;
27+
}
28+
29+
try {
30+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { workspaces?: unknown };
31+
return Array.isArray(packageJson.workspaces) || Boolean(packageJson.workspaces);
32+
} catch {
33+
return false;
34+
}
35+
}
36+
37+
function collectSearchDirectories(startDir: string): string[] {
38+
const directories: string[] = [];
39+
let currentDir = path.resolve(startDir);
40+
let nearestPackageIndex = -1;
41+
42+
while (true) {
43+
directories.push(currentDir);
44+
45+
if (nearestPackageIndex === -1 && hasPackageJson(currentDir)) {
46+
nearestPackageIndex = directories.length - 1;
47+
}
48+
49+
if (hasWorkspaceRootMarker(currentDir)) {
50+
return directories;
51+
}
52+
53+
const parentDir = path.dirname(currentDir);
54+
55+
if (parentDir === currentDir) {
56+
break;
57+
}
58+
59+
currentDir = parentDir;
60+
}
61+
62+
if (nearestPackageIndex >= 0) {
63+
return directories.slice(0, nearestPackageIndex + 1);
64+
}
65+
66+
return directories;
67+
}
68+
69+
export function resolveEnvFiles(options: LoadEnvOptions = {}): string[] {
70+
const cwd = options.cwd ?? process.cwd();
71+
const envFileVar = options.envFileVar ?? DEFAULT_ENV_FILE_VAR;
72+
const filenames = options.filenames ?? [DEFAULT_ENV_FILENAME];
73+
const explicitEnvFile = process.env[envFileVar]?.trim();
74+
const candidates: string[] = [];
75+
76+
if (explicitEnvFile) {
77+
const resolvedExplicitEnvFile = path.resolve(cwd, explicitEnvFile);
78+
79+
if (!fs.existsSync(resolvedExplicitEnvFile)) {
80+
throw new Error(`Environment file set by ${envFileVar} was not found: ${resolvedExplicitEnvFile}`);
81+
}
82+
83+
candidates.push(resolvedExplicitEnvFile);
84+
}
85+
86+
for (const dir of collectSearchDirectories(cwd)) {
87+
for (const filename of filenames) {
88+
candidates.push(path.join(dir, filename));
89+
}
90+
}
91+
92+
const uniqueFiles = new Set<string>();
93+
94+
return candidates.filter(candidate => {
95+
const resolvedCandidate = path.resolve(candidate);
96+
97+
if (uniqueFiles.has(resolvedCandidate)) {
98+
return false;
99+
}
100+
101+
uniqueFiles.add(resolvedCandidate);
102+
103+
return fs.existsSync(resolvedCandidate) && fs.statSync(resolvedCandidate).isFile();
104+
});
105+
}
106+
107+
export function loadEnv(options: LoadEnvOptions = {}): string[] {
108+
const loadedFiles: string[] = [];
109+
110+
for (const envFile of resolveEnvFiles(options)) {
111+
const result = dotenv.config({
112+
path: envFile,
113+
override: false,
114+
});
115+
116+
if (result.error) {
117+
throw result.error;
118+
}
119+
120+
loadedFiles.push(envFile);
121+
}
122+
123+
return loadedFiles;
124+
}

packages/common/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './env.js';
12
export * from './errors.js';
23
export * from './logger.js';
34
export * from './utils.js';

services/explorer/server.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import express from 'express';
22
import path from 'path';
33
import { fileURLToPath } from 'url';
4-
import dotenv from 'dotenv';
4+
import { loadEnv } from '@mdip/common/env';
55
import { childLogger } from '@mdip/common/logger';
66

7-
dotenv.config();
7+
loadEnv();
88
const log = childLogger({ service: 'explorer-server' });
99

1010
const __filename = fileURLToPath(import.meta.url);

services/gatekeeper/server/src/config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import dotenv from 'dotenv';
1+
import { loadEnv } from '@mdip/common/env';
22

3-
dotenv.config();
3+
loadEnv();
44

55
const DEFAULT_RATE_LIMIT_SKIP_PATHS = ['/api/v1/ready'];
66

0 commit comments

Comments
 (0)