Skip to content

Commit 832992d

Browse files
committed
feat: implement config loader to enable remote or external configs
feat: implements config reload and cli command feat: adds test for config loader
1 parent 9811ac3 commit 832992d

File tree

10 files changed

+538
-168
lines changed

10 files changed

+538
-168
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ dist
212212
# https://nextjs.org/blog/next-9-1#public-directory-support
213213
# public
214214

215+
# git-config-cache
216+
.git-config-cache
217+
215218
# vuepress build output
216219
.vuepress/dist
217220

config.schema.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
"description": "Configuration for customizing git-proxy",
66
"type": "object",
77
"properties": {
8+
"configurationSources": {
9+
"enabled": { "type": "boolean" },
10+
"reloadIntervalSeconds": { "type": "number" },
11+
"merge": { "type": "boolean" },
12+
"sources": {
13+
"type": "array",
14+
"items": {
15+
"type": "object",
16+
"description": "Configuration source"
17+
}
18+
}
19+
},
820
"proxyUrl": { "type": "string" },
921
"cookieSecret": { "type": "string" },
1022
"sessionMaxAgeHours": { "type": "number" },

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"@material-ui/icons": "4.11.3",
3838
"@primer/octicons-react": "^19.8.0",
3939
"@seald-io/nedb": "^4.0.2",
40-
"axios": "^1.6.0",
40+
"axios": "^1.6.7",
4141
"bcryptjs": "^2.4.3",
4242
"bit-mask": "^1.0.2",
4343
"body-parser": "^1.20.1",

packages/git-proxy-cli/index.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,29 @@ async function logout() {
306306
console.log('Logout: OK');
307307
}
308308

309+
/**
310+
* Reloads the GitProxy configuration without restarting the process
311+
*/
312+
async function reloadConfig() {
313+
if (!fs.existsSync(GIT_PROXY_COOKIE_FILE)) {
314+
console.error('Error: Reload config: Authentication required');
315+
process.exitCode = 1;
316+
return;
317+
}
318+
319+
try {
320+
const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8'));
321+
322+
await axios.post(`${baseUrl}/api/v1/admin/reload-config`, {}, { headers: { Cookie: cookies } });
323+
324+
console.log('Configuration reloaded successfully');
325+
} catch (error) {
326+
const errorMessage = `Error: Reload config: '${error.message}'`;
327+
process.exitCode = 2;
328+
console.error(errorMessage);
329+
}
330+
}
331+
309332
// Parsing command line arguments
310333
yargs(hideBin(process.argv))
311334
.command({
@@ -436,6 +459,11 @@ yargs(hideBin(process.argv))
436459
rejectGitPush(argv.id);
437460
},
438461
})
462+
.command({
463+
command: 'reload-config',
464+
description: 'Reload GitProxy configuration without restarting',
465+
action: reloadConfig,
466+
})
439467
.demandCommand(1, 'You need at least one command before moving on')
440468
.strict()
441469
.help().argv;

proxy.config.json

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,44 @@
22
"proxyUrl": "https://github.com",
33
"cookieSecret": "cookie secret",
44
"sessionMaxAgeHours": 12,
5+
"configurationSources": {
6+
"enabled": false,
7+
"reloadIntervalSeconds": 60,
8+
"merge": false,
9+
"sources": [
10+
{
11+
"type": "file",
12+
"enabled": false,
13+
"path": "./external-config.json"
14+
},
15+
{
16+
"type": "http",
17+
"enabled": false,
18+
"url": "http://config-service/git-proxy-config",
19+
"headers": {},
20+
"auth": {
21+
"type": "bearer",
22+
"token": ""
23+
}
24+
},
25+
{
26+
"type": "git",
27+
"enabled": false,
28+
"repository": "https://git-server.com/project/git-proxy-config",
29+
"branch": "main",
30+
"path": "git-proxy/config.json",
31+
"auth": {
32+
"type": "ssh",
33+
"privateKeyPath": "/path/to/.ssh/id_rsa"
34+
}
35+
}
36+
]
37+
},
538
"tempPassword": {
639
"sendEmail": false,
740
"emailConfig": {}
841
},
9-
"authorisedList": [
10-
{
11-
"project": "finos",
12-
"name": "git-proxy",
13-
"url": "https://github.com/finos/git-proxy.git"
14-
}
15-
],
42+
"authorisedList": [],
1643
"sink": [
1744
{
1845
"type": "fs",

src/config/ConfigLoader.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const axios = require('axios');
4+
const { exec } = require('child_process');
5+
const { promisify } = require('util');
6+
const execAsync = promisify(exec);
7+
const EventEmitter = require('events');
8+
9+
class ConfigLoader extends EventEmitter {
10+
constructor(initialConfig) {
11+
super();
12+
this.config = initialConfig;
13+
this.reloadTimer = null;
14+
this.isReloading = false;
15+
}
16+
17+
async start() {
18+
const { configurationSources } = this.config;
19+
if (!configurationSources?.enabled) {
20+
return;
21+
}
22+
23+
// Start periodic reload if interval is set
24+
if (configurationSources.reloadIntervalSeconds > 0) {
25+
this.reloadTimer = setInterval(
26+
() => this.reloadConfiguration(),
27+
configurationSources.reloadIntervalSeconds * 1000,
28+
);
29+
}
30+
31+
// Do initial load
32+
await this.reloadConfiguration();
33+
}
34+
35+
stop() {
36+
if (this.reloadTimer) {
37+
clearInterval(this.reloadTimer);
38+
this.reloadTimer = null;
39+
}
40+
}
41+
42+
async reloadConfiguration() {
43+
if (this.isReloading) return;
44+
this.isReloading = true;
45+
46+
try {
47+
const { configurationSources } = this.config;
48+
if (!configurationSources?.enabled) return;
49+
50+
const configs = await Promise.all(
51+
configurationSources.sources
52+
.filter((source) => source.enabled)
53+
.map((source) => this.loadFromSource(source)),
54+
);
55+
56+
// Use merge strategy based on configuration
57+
const shouldMerge = configurationSources.merge ?? true; // Default to true for backward compatibility
58+
const newConfig = shouldMerge
59+
? configs.reduce(
60+
(acc, curr) => {
61+
return this.deepMerge(acc, curr);
62+
},
63+
{ ...this.config },
64+
)
65+
: { ...this.config, ...configs[configs.length - 1] }; // Use last config for override
66+
67+
// Emit change event if config changed
68+
if (JSON.stringify(newConfig) !== JSON.stringify(this.config)) {
69+
this.config = newConfig;
70+
this.emit('configurationChanged', this.config);
71+
}
72+
} catch (error) {
73+
console.error('Error reloading configuration:', error);
74+
this.emit('configurationError', error);
75+
} finally {
76+
this.isReloading = false;
77+
}
78+
}
79+
80+
async loadFromSource(source) {
81+
switch (source.type) {
82+
case 'file':
83+
return this.loadFromFile(source);
84+
case 'http':
85+
return this.loadFromHttp(source);
86+
case 'git':
87+
return this.loadFromGit(source);
88+
default:
89+
throw new Error(`Unsupported configuration source type: ${source.type}`);
90+
}
91+
}
92+
93+
async loadFromFile(source) {
94+
const configPath = path.resolve(process.cwd(), source.path);
95+
const content = await fs.promises.readFile(configPath, 'utf8');
96+
return JSON.parse(content);
97+
}
98+
99+
async loadFromHttp(source) {
100+
const headers = {
101+
...source.headers,
102+
...(source.auth?.type === 'bearer' ? { Authorization: `Bearer ${source.auth.token}` } : {}),
103+
};
104+
105+
const response = await axios.get(source.url, { headers });
106+
return response.data;
107+
}
108+
109+
async loadFromGit(source) {
110+
const tempDir = path.join(process.cwd(), '.git-config-cache');
111+
await fs.promises.mkdir(tempDir, { recursive: true });
112+
113+
const repoDir = path.join(tempDir, Buffer.from(source.repository).toString('base64'));
114+
115+
// Clone or pull repository
116+
if (!fs.existsSync(repoDir)) {
117+
const cloneCmd = `git clone ${source.repository} ${repoDir}`;
118+
if (source.auth?.type === 'ssh') {
119+
process.env.GIT_SSH_COMMAND = `ssh -i ${source.auth.privateKeyPath}`;
120+
}
121+
await execAsync(cloneCmd);
122+
} else {
123+
await execAsync('git pull', { cwd: repoDir });
124+
}
125+
126+
// Checkout specific branch if specified
127+
if (source.branch) {
128+
await execAsync(`git checkout ${source.branch}`, { cwd: repoDir });
129+
}
130+
131+
// Read and parse config file
132+
const configPath = path.join(repoDir, source.path);
133+
const content = await fs.promises.readFile(configPath, 'utf8');
134+
return JSON.parse(content);
135+
}
136+
137+
deepMerge(target, source) {
138+
const output = { ...target };
139+
if (isObject(target) && isObject(source)) {
140+
Object.keys(source).forEach((key) => {
141+
if (isObject(source[key])) {
142+
if (!(key in target)) {
143+
Object.assign(output, { [key]: source[key] });
144+
} else {
145+
output[key] = this.deepMerge(target[key], source[key]);
146+
}
147+
} else {
148+
Object.assign(output, { [key]: source[key] });
149+
}
150+
});
151+
}
152+
return output;
153+
}
154+
}
155+
156+
function isObject(item) {
157+
return item && typeof item === 'object' && !Array.isArray(item);
158+
}
159+
160+
module.exports = ConfigLoader;

0 commit comments

Comments
 (0)