Skip to content

Commit aa0283d

Browse files
committed
feat: implements config loader to enable remote or external configs
fix: config loader clone command issue fix: adds input validation, uses array arguments, prevented shell spawn
1 parent dac9c01 commit aa0283d

File tree

12 files changed

+808
-238
lines changed

12 files changed

+808
-238
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ dist
117117
# Stores VSCode versions used for testing VSCode extensions
118118
.vscode-test
119119

120+
# Jetbrains
121+
.idea
122+
120123
# yarn v2
121124

122125
.yarn/cache
@@ -212,6 +215,9 @@ dist
212215
# https://nextjs.org/blog/next-9-1#public-directory-support
213216
# public
214217

218+
# git-config-cache
219+
.git-config-cache
220+
215221
# vuepress build output
216222
.vuepress/dist
217223

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" },

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: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const axios = require('axios');
4+
const { execFile } = require('child_process');
5+
const { promisify } = require('util');
6+
const execFileAsync = promisify(execFile);
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+
// Validate inputs
111+
if (!source.repository || typeof source.repository !== 'string') {
112+
throw new Error('Invalid repository URL');
113+
}
114+
if (source.branch && typeof source.branch !== 'string') {
115+
throw new Error('Invalid branch name');
116+
}
117+
118+
const tempDir = path.join(process.cwd(), '.git-config-cache');
119+
await fs.promises.mkdir(tempDir, { recursive: true });
120+
121+
const repoDir = path.join(tempDir, Buffer.from(source.repository).toString('base64'));
122+
123+
// Clone or pull repository
124+
if (!fs.existsSync(repoDir)) {
125+
if (source.auth?.type === 'ssh') {
126+
process.env.GIT_SSH_COMMAND = `ssh -i ${source.auth.privateKeyPath}`;
127+
}
128+
await execFileAsync('git', ['clone', source.repository, repoDir]);
129+
} else {
130+
await execFileAsync('git', ['pull'], { cwd: repoDir });
131+
}
132+
133+
// Checkout specific branch if specified
134+
if (source.branch) {
135+
await execFileAsync('git', ['checkout', source.branch], { cwd: repoDir });
136+
}
137+
138+
// Read and parse config file
139+
const configPath = path.join(repoDir, source.path);
140+
const content = await fs.promises.readFile(configPath, 'utf8');
141+
return JSON.parse(content);
142+
}
143+
144+
deepMerge(target, source) {
145+
const output = { ...target };
146+
if (isObject(target) && isObject(source)) {
147+
Object.keys(source).forEach((key) => {
148+
if (isObject(source[key])) {
149+
if (!(key in target)) {
150+
Object.assign(output, { [key]: source[key] });
151+
} else {
152+
output[key] = this.deepMerge(target[key], source[key]);
153+
}
154+
} else {
155+
Object.assign(output, { [key]: source[key] });
156+
}
157+
});
158+
}
159+
return output;
160+
}
161+
}
162+
163+
function isObject(item) {
164+
return item && typeof item === 'object' && !Array.isArray(item);
165+
}
166+
167+
module.exports = ConfigLoader;

0 commit comments

Comments
 (0)