@alacard-project/config-sdk 1.1.1 → 1.1.4
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/dist/clients/config.client.d.ts +0 -3
- package/dist/clients/config.client.js +11 -70
- package/dist/{modules/config.module.d.ts → config.module.d.ts} +1 -1
- package/dist/{modules/config.module.js → config.module.js} +12 -14
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -2
- package/package.json +62 -59
- package/eslint.config.mjs +0 -29
- package/proto/config.proto +0 -39
- package/src/clients/config.client.ts +0 -252
- package/src/clients/vault.client.ts +0 -82
- package/src/constants/index.ts +0 -1
- package/src/enums/env.enum.ts +0 -7
- package/src/generated/config.ts +0 -834
- package/src/index.ts +0 -8
- package/src/modules/config.module.ts +0 -28
- package/src/types/config.types.ts +0 -38
- package/src/types/grpc.types.ts +0 -15
- package/src/types/types.ts +0 -12
- package/src/utils/nest-helpers.ts +0 -69
- package/test/config.client.spec.ts +0 -108
- package/test/vault.client.spec.ts +0 -62
- package/tsconfig.json +0 -21
- /package/dist/utils/{nest-helpers.d.ts → config.helpers.d.ts} +0 -0
- /package/dist/utils/{nest-helpers.js → config.helpers.js} +0 -0
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
import * as grpc from '@grpc/grpc-js';
|
|
2
|
-
import { Kafka, Consumer, EachMessagePayload } from 'kafkajs';
|
|
3
|
-
import * as path from 'path';
|
|
4
|
-
import * as dotenv from 'dotenv';
|
|
5
|
-
import CircuitBreaker from 'opossum';
|
|
6
|
-
import { Logger } from '@nestjs/common';
|
|
7
|
-
import { ConfigServiceClient } from '../generated/config';
|
|
8
|
-
import { GetConfigRequest } from '../types/grpc.types';
|
|
9
|
-
import { VaultClient } from './vault.client';
|
|
10
|
-
import { ConfigOptions, ConfigUpdatedEvent } from '../types/config.types';
|
|
11
|
-
|
|
12
|
-
export class ConfigClient {
|
|
13
|
-
private readonly logger: Logger = new Logger('ConfigSDK');
|
|
14
|
-
private configMap: Record<string, string> = {};
|
|
15
|
-
private consumer: Consumer | null = null;
|
|
16
|
-
private options: ConfigOptions;
|
|
17
|
-
private vaultClient: VaultClient | null = null;
|
|
18
|
-
private breaker: CircuitBreaker | null = null;
|
|
19
|
-
private static instance: ConfigClient | null = null;
|
|
20
|
-
|
|
21
|
-
private constructor(options: ConfigOptions) {
|
|
22
|
-
this.options = options;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
public static async initialize(options: ConfigOptions): Promise<ConfigClient> {
|
|
26
|
-
if (this.instance) {
|
|
27
|
-
return this.instance;
|
|
28
|
-
}
|
|
29
|
-
const client = new ConfigClient(options);
|
|
30
|
-
await client.init();
|
|
31
|
-
this.instance = client;
|
|
32
|
-
return client;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
public static getInstance(): ConfigClient {
|
|
36
|
-
if (!this.instance) {
|
|
37
|
-
throw new Error('[ConfigSDK] ConfigClient not initialized. Call initialize() first.');
|
|
38
|
-
}
|
|
39
|
-
return this.instance;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
private async init(): Promise<void> {
|
|
43
|
-
const { serviceName, environment, grpcUrl, version = 'v1', useDotenvFallback = true } = this.options;
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
// 1. Load from Process Env (Highest Priority for overrides)
|
|
47
|
-
this.loadProcessEnv();
|
|
48
|
-
|
|
49
|
-
// 2. Load from DotEnv (if enabled)
|
|
50
|
-
if (useDotenvFallback) {
|
|
51
|
-
this.loadDotEnv(environment);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// 3. Load from Vault (if configured)
|
|
55
|
-
if (this.options.vault) {
|
|
56
|
-
await this.loadVaultSecrets(serviceName);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 4. Fetch Remote Config via gRPC (with Circuit Breaker)
|
|
60
|
-
await this.fetchRemoteConfig(serviceName, environment, grpcUrl, version);
|
|
61
|
-
|
|
62
|
-
// 5. Start Kafka Watcher (if configured)
|
|
63
|
-
if (this.options.kafkaBrokers && this.options.kafkaBrokers.length > 0) {
|
|
64
|
-
await this.startWatching();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
this.logger.log(`Configuration initialized with ${Object.keys(this.configMap).length} keys`);
|
|
68
|
-
} catch (error: unknown) {
|
|
69
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
70
|
-
this.logger.error(`Critical initialization failure: ${errorMsg}. Service starting with loaded config.`);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
private loadProcessEnv(): void {
|
|
75
|
-
const ALLOWED_ENV_PREFIXES = ['APP_', 'KAFKA_', 'DB_', 'REDIS_', 'GRPC_', 'PORT', 'NODE_ENV', 'DATABASE_URL'];
|
|
76
|
-
for (const [key, value] of Object.entries(process.env)) {
|
|
77
|
-
if (value && ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) {
|
|
78
|
-
this.configMap[key] = value;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
private loadDotEnv(environment: string): void {
|
|
84
|
-
// Only support standard .env and environment-specific .env
|
|
85
|
-
const envFiles = [`.env.${environment}`, '.env'];
|
|
86
|
-
for (const file of envFiles) {
|
|
87
|
-
const filePath = path.resolve(process.cwd(), file);
|
|
88
|
-
const result = dotenv.config({ path: filePath });
|
|
89
|
-
if (result.parsed) {
|
|
90
|
-
// Do not overwrite existing keys (process.env wins)
|
|
91
|
-
for (const [key, value] of Object.entries(result.parsed)) {
|
|
92
|
-
if (!this.configMap[key]) {
|
|
93
|
-
this.configMap[key] = value;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
this.logger.log(`Loaded variables from ${file}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private async loadVaultSecrets(serviceName: string): Promise<void> {
|
|
102
|
-
if (!this.options.vault) return;
|
|
103
|
-
|
|
104
|
-
this.vaultClient = new VaultClient(this.options.vault);
|
|
105
|
-
try {
|
|
106
|
-
const vaultSecrets = await this.vaultClient.getKVSecrets(`config-service/${serviceName}`);
|
|
107
|
-
// Vault secrets should overwrite env vars if needed, or fill gaps.
|
|
108
|
-
// Usually Vault > Env, but here we treat Env > Vault for local overrides.
|
|
109
|
-
// Let's adopt a safe merge: Existing keys (from Env) take precedence.
|
|
110
|
-
for (const [key, value] of Object.entries(vaultSecrets)) {
|
|
111
|
-
if (!this.configMap[key]) {
|
|
112
|
-
this.configMap[key] = value as string;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
this.logger.log(`Loaded secrets from Vault for ${serviceName}`);
|
|
116
|
-
} catch (error: unknown) {
|
|
117
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
118
|
-
this.logger.warn(`Vault secrets unavailable: ${errorMsg}. Continuing with local config.`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
private initCircuitBreaker(client: ConfigServiceClient): void {
|
|
123
|
-
const fetchFunction = (request: GetConfigRequest, metadata: grpc.Metadata) => {
|
|
124
|
-
return new Promise<Record<string, string>>((resolve, reject) => {
|
|
125
|
-
client.getConfig(request, metadata, (error, response) => {
|
|
126
|
-
if (error) return reject(error);
|
|
127
|
-
resolve(response.values || {});
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
this.breaker = new CircuitBreaker(fetchFunction, {
|
|
133
|
-
timeout: 5000, // 5s timeout
|
|
134
|
-
errorThresholdPercentage: 50, // 50% errors opens the breaker
|
|
135
|
-
resetTimeout: 10000, // wait 10s before trying again
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
this.breaker.on('open', () => this.logger.warn('Circuit Breaker (Config gRPC) is OPEN'));
|
|
139
|
-
this.breaker.on('halfOpen', () => this.logger.log('Circuit Breaker (Config gRPC) is HALF_OPEN'));
|
|
140
|
-
this.breaker.on('close', () => this.logger.log('Circuit Breaker (Config gRPC) is CLOSED'));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
private async fetchRemoteConfig(serviceName: string, environment: string, grpcUrl: string, version: string): Promise<void> {
|
|
144
|
-
let credentials = grpc.credentials.createInsecure();
|
|
145
|
-
if (this.options.tls) {
|
|
146
|
-
credentials = grpc.credentials.createSsl(
|
|
147
|
-
this.options.tls.rootCert,
|
|
148
|
-
this.options.tls.clientKey,
|
|
149
|
-
this.options.tls.clientCert,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const client = new ConfigServiceClient(grpcUrl, credentials);
|
|
154
|
-
|
|
155
|
-
if (!this.breaker) {
|
|
156
|
-
this.initCircuitBreaker(client);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const request: GetConfigRequest = { serviceName, environment, version };
|
|
160
|
-
const metadata = new grpc.Metadata();
|
|
161
|
-
if (this.options.internalKey) {
|
|
162
|
-
metadata.set('x-internal-key', this.options.internalKey);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
try {
|
|
166
|
-
const values = await this.breaker!.fire(request, metadata);
|
|
167
|
-
if (values) {
|
|
168
|
-
// Remote config fills in the gaps
|
|
169
|
-
for (const [key, value] of Object.entries(values)) {
|
|
170
|
-
if (!this.configMap[key]) {
|
|
171
|
-
this.configMap[key] = value as string;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
this.logger.log(`Remote config loaded from config-service`);
|
|
175
|
-
}
|
|
176
|
-
} catch (error: unknown) {
|
|
177
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
178
|
-
this.logger.warn(`Remote config (gRPC) failure: ${errorMsg}. Using local/vault fallback.`);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private async startWatching(): Promise<void> {
|
|
183
|
-
if (this.consumer) return;
|
|
184
|
-
try {
|
|
185
|
-
const kafka = new Kafka({
|
|
186
|
-
clientId: `config-sdk-${this.options.serviceName}`,
|
|
187
|
-
brokers: this.options.kafkaBrokers!,
|
|
188
|
-
retry: {
|
|
189
|
-
retries: 5, // Retry up to 5 times
|
|
190
|
-
maxRetryTime: 30000 // Max time spent retrying
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
const groupId = `config-watch-${this.options.serviceName}-${this.options.environment}`;
|
|
194
|
-
this.consumer = kafka.consumer({ groupId });
|
|
195
|
-
await this.consumer.connect();
|
|
196
|
-
await this.consumer.subscribe({ topic: 'config.updated', fromBeginning: false });
|
|
197
|
-
|
|
198
|
-
await this.consumer.run({
|
|
199
|
-
eachMessage: async ({ message }: EachMessagePayload) => {
|
|
200
|
-
if (!message.value) return;
|
|
201
|
-
try {
|
|
202
|
-
const event: ConfigUpdatedEvent = JSON.parse(message.value.toString());
|
|
203
|
-
if (event.service === 'global' || event.service === this.options.serviceName) {
|
|
204
|
-
if (event.environment === this.options.environment) {
|
|
205
|
-
this.logger.log(`Hot-reload: ${event.key} updated`);
|
|
206
|
-
this.configMap[event.key] = event.value;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
} catch (err: unknown) {
|
|
210
|
-
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
211
|
-
this.logger.error(`Failed to parse config update: ${errorMsg}`);
|
|
212
|
-
}
|
|
213
|
-
},
|
|
214
|
-
});
|
|
215
|
-
} catch (error: unknown) {
|
|
216
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
217
|
-
this.logger.warn(`Config watch mode (Kafka) failed: ${errorMsg}`);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
public get<T = string>(key: string, defaultValue?: T): T {
|
|
222
|
-
const val = this.configMap[key];
|
|
223
|
-
|
|
224
|
-
if (val === undefined || val === null) {
|
|
225
|
-
if (defaultValue !== undefined) {
|
|
226
|
-
return defaultValue;
|
|
227
|
-
}
|
|
228
|
-
// Strict mode: warn if key is missing and no default value
|
|
229
|
-
this.logger.warn(`Configuration key '${key}' not found and no default value provided.`);
|
|
230
|
-
return undefined as unknown as T;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return val as unknown as T;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
public getInt(key: string, defaultValue: number = 0): number {
|
|
237
|
-
const val = this.get<string>(key);
|
|
238
|
-
if (!val) return defaultValue;
|
|
239
|
-
const parsed = parseInt(val, 10);
|
|
240
|
-
return isNaN(parsed) ? defaultValue : parsed;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
public getBool(key: string, defaultValue: boolean = false): boolean {
|
|
244
|
-
const val = this.get<string>(key);
|
|
245
|
-
if (!val) return defaultValue;
|
|
246
|
-
return val.toLowerCase() === 'true' || val === '1';
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
public getAll(): Record<string, string> {
|
|
250
|
-
return { ...this.configMap };
|
|
251
|
-
}
|
|
252
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
|
2
|
-
import { Logger } from '@nestjs/common';
|
|
3
|
-
import { VaultOptions, VaultCerts } from '../types/config.types';
|
|
4
|
-
|
|
5
|
-
export class VaultClient {
|
|
6
|
-
private readonly logger: Logger = new Logger('VaultSDK');
|
|
7
|
-
private http: AxiosInstance;
|
|
8
|
-
private options: VaultOptions;
|
|
9
|
-
private token: string | null = null;
|
|
10
|
-
private tokenExpiry: number = 0;
|
|
11
|
-
|
|
12
|
-
constructor(options: VaultOptions) {
|
|
13
|
-
this.options = options;
|
|
14
|
-
this.http = axios.create({
|
|
15
|
-
baseURL: `${options.address}/v1`,
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
private async login(): Promise<void> {
|
|
20
|
-
if (this.token && Date.now() < this.tokenExpiry) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
try {
|
|
25
|
-
const response: AxiosResponse = await this.http.post('/auth/approle/login', {
|
|
26
|
-
role_id: this.options.roleId,
|
|
27
|
-
secret_id: this.options.secretId,
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const { client_token, lease_duration } = response.data.auth;
|
|
31
|
-
this.token = client_token;
|
|
32
|
-
this.tokenExpiry = Date.now() + (lease_duration - 60) * 1000;
|
|
33
|
-
|
|
34
|
-
this.http.defaults.headers.common['X-Vault-Token'] = this.token;
|
|
35
|
-
this.logger.log('Successfully authenticated with Vault AppRole');
|
|
36
|
-
} catch (error: unknown) {
|
|
37
|
-
const errorMsg = error instanceof Error ? (error as any).response?.data?.errors?.[0] || error.message : String(error);
|
|
38
|
-
this.logger.error(`Vault login failed: ${errorMsg}`);
|
|
39
|
-
throw new Error(`Vault login failed: ${errorMsg}`);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
public async getKVSecrets(path: string): Promise<Record<string, string>> {
|
|
44
|
-
await this.login();
|
|
45
|
-
try {
|
|
46
|
-
const response: AxiosResponse = await this.http.get(`/secret/data/${path}`);
|
|
47
|
-
return response.data.data.data;
|
|
48
|
-
} catch (error: unknown) {
|
|
49
|
-
if (error && typeof error === 'object' && 'response' in error) {
|
|
50
|
-
const axiosError = error as { response?: { status?: number } };
|
|
51
|
-
if (axiosError.response?.status === 404) {
|
|
52
|
-
this.logger.warn(`Secret not found at path: ${path}`);
|
|
53
|
-
return {};
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
57
|
-
this.logger.error(`Failed to fetch secrets from path ${path}: ${errorMsg}`);
|
|
58
|
-
throw new Error(`Failed to fetch secrets: ${errorMsg}`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
public async issueCertificate(commonName: string): Promise<VaultCerts> {
|
|
63
|
-
await this.login();
|
|
64
|
-
const pkiPath = this.options.pkiPath || 'pki/issue/config-service';
|
|
65
|
-
try {
|
|
66
|
-
const response: AxiosResponse = await this.http.post(pkiPath, {
|
|
67
|
-
common_name: commonName,
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
const { ca_chain, certificate, private_key } = response.data.data;
|
|
71
|
-
return {
|
|
72
|
-
ca: (ca_chain as string[])[0] || '',
|
|
73
|
-
certificate: certificate as string,
|
|
74
|
-
privateKey: private_key as string,
|
|
75
|
-
};
|
|
76
|
-
} catch (error: unknown) {
|
|
77
|
-
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
78
|
-
this.logger.error(`Failed to issue certificate for ${commonName}: ${errorMsg}`);
|
|
79
|
-
throw new Error(`Failed to issue certificate: ${errorMsg}`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
package/src/constants/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
|