port-arranger 0.0.1
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/.vite/build/index.cjs +2 -0
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/cli/commands/list.d.ts +1 -0
- package/dist/cli/commands/list.js +87 -0
- package/dist/cli/commands/run.d.ts +7 -0
- package/dist/cli/commands/run.js +151 -0
- package/dist/cli/commands/stop.d.ts +5 -0
- package/dist/cli/commands/stop.js +105 -0
- package/dist/cli/commands/ui.d.ts +1 -0
- package/dist/cli/commands/ui.js +29 -0
- package/dist/cli/core/compose-parser.d.ts +56 -0
- package/dist/cli/core/compose-parser.js +184 -0
- package/dist/cli/core/compose-parser.test.d.ts +1 -0
- package/dist/cli/core/compose-parser.test.js +262 -0
- package/dist/cli/core/port-finder.d.ts +2 -0
- package/dist/cli/core/port-finder.js +52 -0
- package/dist/cli/core/port-finder.test.d.ts +1 -0
- package/dist/cli/core/port-finder.test.js +106 -0
- package/dist/cli/core/port-injector.d.ts +3 -0
- package/dist/cli/core/port-injector.js +191 -0
- package/dist/cli/core/port-injector.test.d.ts +1 -0
- package/dist/cli/core/port-injector.test.js +264 -0
- package/dist/cli/core/process-manager.d.ts +8 -0
- package/dist/cli/core/process-manager.js +84 -0
- package/dist/cli/core/process-manager.test.d.ts +1 -0
- package/dist/cli/core/process-manager.test.js +50 -0
- package/dist/cli/core/state.d.ts +10 -0
- package/dist/cli/core/state.js +65 -0
- package/dist/cli/core/state.test.d.ts +1 -0
- package/dist/cli/core/state.test.js +72 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +67 -0
- package/dist/gui/main/index.d.ts +1 -0
- package/dist/gui/main/index.js +60 -0
- package/dist/gui/main/ipc-handlers.d.ts +2 -0
- package/dist/gui/main/ipc-handlers.js +66 -0
- package/dist/gui/main/state-watcher.d.ts +7 -0
- package/dist/gui/main/state-watcher.js +56 -0
- package/dist/gui/preload/index.d.ts +1 -0
- package/dist/gui/preload/index.js +20 -0
- package/dist/gui/renderer/App.d.ts +2 -0
- package/dist/gui/renderer/App.js +44 -0
- package/dist/gui/renderer/components/ProcessItem.d.ts +10 -0
- package/dist/gui/renderer/components/ProcessItem.js +115 -0
- package/dist/gui/renderer/components/ProcessList.d.ts +9 -0
- package/dist/gui/renderer/components/ProcessList.js +64 -0
- package/dist/gui/renderer/components/TitleBar.d.ts +2 -0
- package/dist/gui/renderer/components/TitleBar.js +92 -0
- package/dist/gui/renderer/hooks/useProcesses.d.ts +10 -0
- package/dist/gui/renderer/hooks/useProcesses.js +44 -0
- package/dist/gui/renderer/main.d.ts +1 -0
- package/dist/gui/renderer/main.js +11 -0
- package/dist/shared/types.d.ts +62 -0
- package/dist/shared/types.js +1 -0
- package/package.json +76 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
const OVERRIDE_FILENAME = '.pa-compose-override.yml';
|
|
5
|
+
/**
|
|
6
|
+
* 포트 문자열을 파싱하여 호스트/컨테이너 포트 추출
|
|
7
|
+
* 지원 형식:
|
|
8
|
+
* - "3000" → 3000:3000
|
|
9
|
+
* - "3000:8000" → 3000:8000
|
|
10
|
+
* - "127.0.0.1:3000:8000" → 3000:8000 (IP 무시)
|
|
11
|
+
* - "3000:8000/tcp" → 3000:8000 (tcp)
|
|
12
|
+
*/
|
|
13
|
+
export function parsePortString(portStr) {
|
|
14
|
+
if (typeof portStr === 'number') {
|
|
15
|
+
return { hostPort: portStr, containerPort: portStr };
|
|
16
|
+
}
|
|
17
|
+
// 프로토콜 분리
|
|
18
|
+
let protocol;
|
|
19
|
+
let portPart = portStr;
|
|
20
|
+
if (portStr.endsWith('/tcp')) {
|
|
21
|
+
protocol = 'tcp';
|
|
22
|
+
portPart = portStr.slice(0, -4);
|
|
23
|
+
}
|
|
24
|
+
else if (portStr.endsWith('/udp')) {
|
|
25
|
+
protocol = 'udp';
|
|
26
|
+
portPart = portStr.slice(0, -4);
|
|
27
|
+
}
|
|
28
|
+
const parts = portPart.split(':');
|
|
29
|
+
if (parts.length === 1) {
|
|
30
|
+
// "3000" 형태
|
|
31
|
+
const port = parseInt(parts[0], 10);
|
|
32
|
+
return { hostPort: port, containerPort: port, protocol };
|
|
33
|
+
}
|
|
34
|
+
if (parts.length === 2) {
|
|
35
|
+
// "3000:8000" 형태
|
|
36
|
+
return {
|
|
37
|
+
hostPort: parseInt(parts[0], 10),
|
|
38
|
+
containerPort: parseInt(parts[1], 10),
|
|
39
|
+
protocol,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (parts.length === 3) {
|
|
43
|
+
// "127.0.0.1:3000:8000" 형태 (IP:hostPort:containerPort)
|
|
44
|
+
return {
|
|
45
|
+
hostPort: parseInt(parts[1], 10),
|
|
46
|
+
containerPort: parseInt(parts[2], 10),
|
|
47
|
+
protocol,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Unsupported port format: ${portStr}`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 포트 객체(long syntax)를 파싱
|
|
54
|
+
*/
|
|
55
|
+
function parsePortObject(portObj) {
|
|
56
|
+
return {
|
|
57
|
+
hostPort: portObj.published ? parseInt(String(portObj.published), 10) : portObj.target,
|
|
58
|
+
containerPort: portObj.target,
|
|
59
|
+
protocol: portObj.protocol,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* docker-compose.yml 파일을 파싱
|
|
64
|
+
*/
|
|
65
|
+
export function parseComposeFile(cwd, filename = 'docker-compose.yml') {
|
|
66
|
+
const composePath = join(cwd, filename);
|
|
67
|
+
if (!existsSync(composePath)) {
|
|
68
|
+
throw new Error(`Docker Compose 파일을 찾을 수 없습니다: ${composePath}`);
|
|
69
|
+
}
|
|
70
|
+
const content = readFileSync(composePath, 'utf-8');
|
|
71
|
+
const config = yaml.load(content);
|
|
72
|
+
if (!config || typeof config !== 'object') {
|
|
73
|
+
throw new Error('유효하지 않은 Docker Compose 파일입니다');
|
|
74
|
+
}
|
|
75
|
+
return config;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* 서비스 이름 목록 추출 (명령어에서 특정 서비스가 지정된 경우)
|
|
79
|
+
*/
|
|
80
|
+
export function extractServiceNames(command) {
|
|
81
|
+
// "docker compose up service1 service2" 형태에서 서비스명 추출
|
|
82
|
+
const match = command.match(/docker\s+compose\s+(up|start|run)\s+(.+)/);
|
|
83
|
+
if (!match) {
|
|
84
|
+
return []; // 서비스 지정 없음 = 모든 서비스
|
|
85
|
+
}
|
|
86
|
+
const servicesPart = match[2];
|
|
87
|
+
// 옵션 플래그 제거 (-d, --detach 등)
|
|
88
|
+
const services = servicesPart
|
|
89
|
+
.split(/\s+/)
|
|
90
|
+
.filter(s => !s.startsWith('-') && s.length > 0);
|
|
91
|
+
return services;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 특정 서비스의 포트 매핑 추출
|
|
95
|
+
*/
|
|
96
|
+
export function extractServicePorts(config, serviceName) {
|
|
97
|
+
const service = config.services?.[serviceName];
|
|
98
|
+
if (!service) {
|
|
99
|
+
throw new Error(`서비스를 찾을 수 없습니다: ${serviceName}`);
|
|
100
|
+
}
|
|
101
|
+
if (!service.ports || service.ports.length === 0) {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
return service.ports.map(port => {
|
|
105
|
+
if (typeof port === 'object' && 'target' in port) {
|
|
106
|
+
return parsePortObject(port);
|
|
107
|
+
}
|
|
108
|
+
return parsePortString(port);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 모든 서비스의 포트 매핑 추출
|
|
113
|
+
*/
|
|
114
|
+
export function getAllServicePorts(config, serviceNames) {
|
|
115
|
+
const services = config.services || {};
|
|
116
|
+
const targetServices = serviceNames && serviceNames.length > 0
|
|
117
|
+
? serviceNames
|
|
118
|
+
: Object.keys(services);
|
|
119
|
+
const result = [];
|
|
120
|
+
for (const serviceName of targetServices) {
|
|
121
|
+
try {
|
|
122
|
+
const ports = extractServicePorts(config, serviceName);
|
|
123
|
+
if (ports.length > 0) {
|
|
124
|
+
result.push({ serviceName, ports });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// 서비스가 없으면 무시
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Override YAML 파일 생성
|
|
135
|
+
*/
|
|
136
|
+
export function generateOverrideYaml(allocatedPorts) {
|
|
137
|
+
const services = {};
|
|
138
|
+
for (const [serviceName, ports] of allocatedPorts) {
|
|
139
|
+
const portStrings = ports.map(p => {
|
|
140
|
+
const protocol = p.protocol ? `/${p.protocol}` : '';
|
|
141
|
+
return `"${p.newHostPort}:${p.containerPort}${protocol}"`;
|
|
142
|
+
});
|
|
143
|
+
services[serviceName] = {
|
|
144
|
+
ports: portStrings,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// YAML 직접 생성 (js-yaml의 dump는 커스텀 태그를 지원하지 않음)
|
|
148
|
+
// Docker Compose v2.24+에서 !override는 이전 파일의 배열을 완전히 대체함
|
|
149
|
+
let yamlContent = 'services:\n';
|
|
150
|
+
for (const [serviceName, config] of Object.entries(services)) {
|
|
151
|
+
yamlContent += ` ${serviceName}:\n`;
|
|
152
|
+
yamlContent += ` ports: !override\n`;
|
|
153
|
+
for (const port of config.ports) {
|
|
154
|
+
yamlContent += ` - ${port}\n`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return yamlContent;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Override 파일 작성
|
|
161
|
+
*/
|
|
162
|
+
export function writeOverrideFile(cwd, content) {
|
|
163
|
+
const overridePath = join(cwd, OVERRIDE_FILENAME);
|
|
164
|
+
writeFileSync(overridePath, content, 'utf-8');
|
|
165
|
+
return overridePath;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* docker compose 명령어를 override 파일을 포함하도록 변환
|
|
169
|
+
*/
|
|
170
|
+
export function transformComposeCommand(originalCommand, overridePath) {
|
|
171
|
+
// "docker compose up" → "docker compose -f docker-compose.yml -f .pa-compose-override.yml up"
|
|
172
|
+
const match = originalCommand.match(/^(docker\s+compose)\s+(up|start|run)(.*)$/);
|
|
173
|
+
if (!match) {
|
|
174
|
+
return originalCommand;
|
|
175
|
+
}
|
|
176
|
+
const [, dockerCompose, subcommand, rest] = match;
|
|
177
|
+
return `${dockerCompose} -f docker-compose.yml -f ${OVERRIDE_FILENAME} ${subcommand}${rest}`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Override 파일 경로 반환
|
|
181
|
+
*/
|
|
182
|
+
export function getOverrideFilePath(cwd) {
|
|
183
|
+
return join(cwd, OVERRIDE_FILENAME);
|
|
184
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { parsePortString, parseComposeFile, extractServiceNames, extractServicePorts, getAllServicePorts, generateOverrideYaml, writeOverrideFile, transformComposeCommand, } from './compose-parser.js';
|
|
6
|
+
describe('compose-parser', () => {
|
|
7
|
+
describe('parsePortString', () => {
|
|
8
|
+
it('숫자만 있는 경우 호스트와 컨테이너 포트가 같다', () => {
|
|
9
|
+
expect(parsePortString('3000')).toEqual({
|
|
10
|
+
hostPort: 3000,
|
|
11
|
+
containerPort: 3000,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
it('숫자 타입도 처리한다', () => {
|
|
15
|
+
expect(parsePortString(8080)).toEqual({
|
|
16
|
+
hostPort: 8080,
|
|
17
|
+
containerPort: 8080,
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
it('host:container 형태를 파싱한다', () => {
|
|
21
|
+
expect(parsePortString('3000:8000')).toEqual({
|
|
22
|
+
hostPort: 3000,
|
|
23
|
+
containerPort: 8000,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
it('ip:host:container 형태를 파싱한다 (IP 무시)', () => {
|
|
27
|
+
expect(parsePortString('127.0.0.1:3000:8000')).toEqual({
|
|
28
|
+
hostPort: 3000,
|
|
29
|
+
containerPort: 8000,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
it('프로토콜을 파싱한다', () => {
|
|
33
|
+
expect(parsePortString('3000:8000/tcp')).toEqual({
|
|
34
|
+
hostPort: 3000,
|
|
35
|
+
containerPort: 8000,
|
|
36
|
+
protocol: 'tcp',
|
|
37
|
+
});
|
|
38
|
+
expect(parsePortString('5432:5432/udp')).toEqual({
|
|
39
|
+
hostPort: 5432,
|
|
40
|
+
containerPort: 5432,
|
|
41
|
+
protocol: 'udp',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it('지원하지 않는 형식은 에러를 던진다', () => {
|
|
45
|
+
expect(() => parsePortString('a:b:c:d')).toThrow('지원하지 않는 포트 형식');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('extractServiceNames', () => {
|
|
49
|
+
it('서비스 이름을 추출한다', () => {
|
|
50
|
+
expect(extractServiceNames('docker compose up web')).toEqual(['web']);
|
|
51
|
+
expect(extractServiceNames('docker compose up web api')).toEqual(['web', 'api']);
|
|
52
|
+
});
|
|
53
|
+
it('옵션 플래그를 제외한다', () => {
|
|
54
|
+
expect(extractServiceNames('docker compose up -d web')).toEqual(['web']);
|
|
55
|
+
expect(extractServiceNames('docker compose up --detach web api')).toEqual(['web', 'api']);
|
|
56
|
+
});
|
|
57
|
+
it('서비스 지정 없으면 빈 배열 반환', () => {
|
|
58
|
+
expect(extractServiceNames('docker compose up')).toEqual([]);
|
|
59
|
+
expect(extractServiceNames('docker compose up -d')).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
it('start/run 명령도 인식한다', () => {
|
|
62
|
+
expect(extractServiceNames('docker compose start web')).toEqual(['web']);
|
|
63
|
+
expect(extractServiceNames('docker compose run api')).toEqual(['api']);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
describe('transformComposeCommand', () => {
|
|
67
|
+
it('up 명령어에 override 파일을 추가한다', () => {
|
|
68
|
+
const result = transformComposeCommand('docker compose up llm', '.pa-compose-override.yml');
|
|
69
|
+
expect(result).toBe('docker compose -f docker-compose.yml -f .pa-compose-override.yml up llm');
|
|
70
|
+
});
|
|
71
|
+
it('start 명령어도 변환한다', () => {
|
|
72
|
+
const result = transformComposeCommand('docker compose start web api', '.pa-compose-override.yml');
|
|
73
|
+
expect(result).toBe('docker compose -f docker-compose.yml -f .pa-compose-override.yml start web api');
|
|
74
|
+
});
|
|
75
|
+
it('인식되지 않는 명령어는 원본 반환', () => {
|
|
76
|
+
const result = transformComposeCommand('docker compose logs', '.pa-compose-override.yml');
|
|
77
|
+
expect(result).toBe('docker compose logs');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('generateOverrideYaml', () => {
|
|
81
|
+
it('단일 서비스의 override YAML을 생성한다', () => {
|
|
82
|
+
const allocatedPorts = new Map();
|
|
83
|
+
allocatedPorts.set('web', [
|
|
84
|
+
{
|
|
85
|
+
hostPort: 3001,
|
|
86
|
+
containerPort: 8000,
|
|
87
|
+
originalHostPort: 3000,
|
|
88
|
+
newHostPort: 3001,
|
|
89
|
+
},
|
|
90
|
+
]);
|
|
91
|
+
const yaml = generateOverrideYaml(allocatedPorts);
|
|
92
|
+
expect(yaml).toContain('services:');
|
|
93
|
+
expect(yaml).toContain('web:');
|
|
94
|
+
expect(yaml).toContain('ports: !override');
|
|
95
|
+
expect(yaml).toContain('"3001:8000"');
|
|
96
|
+
});
|
|
97
|
+
it('다중 서비스의 override YAML을 생성한다', () => {
|
|
98
|
+
const allocatedPorts = new Map();
|
|
99
|
+
allocatedPorts.set('web', [
|
|
100
|
+
{
|
|
101
|
+
hostPort: 3001,
|
|
102
|
+
containerPort: 8000,
|
|
103
|
+
originalHostPort: 3000,
|
|
104
|
+
newHostPort: 3001,
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
allocatedPorts.set('api', [
|
|
108
|
+
{
|
|
109
|
+
hostPort: 3002,
|
|
110
|
+
containerPort: 8001,
|
|
111
|
+
originalHostPort: 3001,
|
|
112
|
+
newHostPort: 3002,
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
const yaml = generateOverrideYaml(allocatedPorts);
|
|
116
|
+
expect(yaml).toContain('web:');
|
|
117
|
+
expect(yaml).toContain('api:');
|
|
118
|
+
expect(yaml).toContain('"3001:8000"');
|
|
119
|
+
expect(yaml).toContain('"3002:8001"');
|
|
120
|
+
});
|
|
121
|
+
it('프로토콜을 포함한다', () => {
|
|
122
|
+
const allocatedPorts = new Map();
|
|
123
|
+
allocatedPorts.set('db', [
|
|
124
|
+
{
|
|
125
|
+
hostPort: 5433,
|
|
126
|
+
containerPort: 5432,
|
|
127
|
+
protocol: 'tcp',
|
|
128
|
+
originalHostPort: 5432,
|
|
129
|
+
newHostPort: 5433,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
const yaml = generateOverrideYaml(allocatedPorts);
|
|
133
|
+
expect(yaml).toContain('"5433:5432/tcp"');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('parseComposeFile / extractServicePorts / getAllServicePorts', () => {
|
|
137
|
+
let tmpDir;
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
tmpDir = join(tmpdir(), `pa-test-${Date.now()}`);
|
|
140
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
141
|
+
});
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
144
|
+
});
|
|
145
|
+
it('docker-compose.yml 파일을 파싱한다', () => {
|
|
146
|
+
const composeContent = `
|
|
147
|
+
services:
|
|
148
|
+
web:
|
|
149
|
+
image: nginx
|
|
150
|
+
ports:
|
|
151
|
+
- "3000:80"
|
|
152
|
+
api:
|
|
153
|
+
image: node
|
|
154
|
+
ports:
|
|
155
|
+
- "3001:8000"
|
|
156
|
+
`;
|
|
157
|
+
writeFileSync(join(tmpDir, 'docker-compose.yml'), composeContent);
|
|
158
|
+
const config = parseComposeFile(tmpDir);
|
|
159
|
+
expect(config.services).toBeDefined();
|
|
160
|
+
expect(config.services?.web).toBeDefined();
|
|
161
|
+
expect(config.services?.api).toBeDefined();
|
|
162
|
+
});
|
|
163
|
+
it('없는 파일은 에러를 던진다', () => {
|
|
164
|
+
expect(() => parseComposeFile(tmpDir)).toThrow('Docker Compose 파일을 찾을 수 없습니다');
|
|
165
|
+
});
|
|
166
|
+
it('서비스의 포트를 추출한다', () => {
|
|
167
|
+
const composeContent = `
|
|
168
|
+
services:
|
|
169
|
+
web:
|
|
170
|
+
ports:
|
|
171
|
+
- "3000:80"
|
|
172
|
+
- "3001:443"
|
|
173
|
+
`;
|
|
174
|
+
writeFileSync(join(tmpDir, 'docker-compose.yml'), composeContent);
|
|
175
|
+
const config = parseComposeFile(tmpDir);
|
|
176
|
+
const ports = extractServicePorts(config, 'web');
|
|
177
|
+
expect(ports).toHaveLength(2);
|
|
178
|
+
expect(ports[0]).toEqual({ hostPort: 3000, containerPort: 80 });
|
|
179
|
+
expect(ports[1]).toEqual({ hostPort: 3001, containerPort: 443 });
|
|
180
|
+
});
|
|
181
|
+
it('없는 서비스는 에러를 던진다', () => {
|
|
182
|
+
const composeContent = `
|
|
183
|
+
services:
|
|
184
|
+
web:
|
|
185
|
+
ports:
|
|
186
|
+
- "3000:80"
|
|
187
|
+
`;
|
|
188
|
+
writeFileSync(join(tmpDir, 'docker-compose.yml'), composeContent);
|
|
189
|
+
const config = parseComposeFile(tmpDir);
|
|
190
|
+
expect(() => extractServicePorts(config, 'nonexistent')).toThrow('서비스를 찾을 수 없습니다');
|
|
191
|
+
});
|
|
192
|
+
it('모든 서비스의 포트를 가져온다', () => {
|
|
193
|
+
const composeContent = `
|
|
194
|
+
services:
|
|
195
|
+
web:
|
|
196
|
+
ports:
|
|
197
|
+
- "3000:80"
|
|
198
|
+
api:
|
|
199
|
+
ports:
|
|
200
|
+
- "3001:8000"
|
|
201
|
+
db:
|
|
202
|
+
image: postgres
|
|
203
|
+
`;
|
|
204
|
+
writeFileSync(join(tmpDir, 'docker-compose.yml'), composeContent);
|
|
205
|
+
const config = parseComposeFile(tmpDir);
|
|
206
|
+
const allPorts = getAllServicePorts(config);
|
|
207
|
+
expect(allPorts).toHaveLength(2); // db는 포트 없음
|
|
208
|
+
expect(allPorts.find(s => s.serviceName === 'web')).toBeDefined();
|
|
209
|
+
expect(allPorts.find(s => s.serviceName === 'api')).toBeDefined();
|
|
210
|
+
});
|
|
211
|
+
it('특정 서비스만 필터링한다', () => {
|
|
212
|
+
const composeContent = `
|
|
213
|
+
services:
|
|
214
|
+
web:
|
|
215
|
+
ports:
|
|
216
|
+
- "3000:80"
|
|
217
|
+
api:
|
|
218
|
+
ports:
|
|
219
|
+
- "3001:8000"
|
|
220
|
+
`;
|
|
221
|
+
writeFileSync(join(tmpDir, 'docker-compose.yml'), composeContent);
|
|
222
|
+
const config = parseComposeFile(tmpDir);
|
|
223
|
+
const filtered = getAllServicePorts(config, ['web']);
|
|
224
|
+
expect(filtered).toHaveLength(1);
|
|
225
|
+
expect(filtered[0].serviceName).toBe('web');
|
|
226
|
+
});
|
|
227
|
+
it('long syntax 포트를 파싱한다', () => {
|
|
228
|
+
const composeContent = `
|
|
229
|
+
services:
|
|
230
|
+
web:
|
|
231
|
+
ports:
|
|
232
|
+
- target: 80
|
|
233
|
+
published: 3000
|
|
234
|
+
protocol: tcp
|
|
235
|
+
`;
|
|
236
|
+
writeFileSync(join(tmpDir, 'docker-compose.yml'), composeContent);
|
|
237
|
+
const config = parseComposeFile(tmpDir);
|
|
238
|
+
const ports = extractServicePorts(config, 'web');
|
|
239
|
+
expect(ports).toHaveLength(1);
|
|
240
|
+
expect(ports[0]).toEqual({
|
|
241
|
+
hostPort: 3000,
|
|
242
|
+
containerPort: 80,
|
|
243
|
+
protocol: 'tcp',
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe('writeOverrideFile', () => {
|
|
248
|
+
let tmpDir;
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
tmpDir = join(tmpdir(), `pa-test-${Date.now()}`);
|
|
251
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
252
|
+
});
|
|
253
|
+
afterEach(() => {
|
|
254
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
255
|
+
});
|
|
256
|
+
it('override 파일을 작성한다', () => {
|
|
257
|
+
const content = 'test content';
|
|
258
|
+
const path = writeOverrideFile(tmpDir, content);
|
|
259
|
+
expect(path).toContain('.pa-compose-override.yml');
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import detectPort from 'detect-port';
|
|
2
|
+
import { getAllProcesses } from './state.js';
|
|
3
|
+
import { isProcessRunning } from './process-manager.js';
|
|
4
|
+
const DEFAULT_PORT = 3000;
|
|
5
|
+
// 세션 내에서 이미 할당된 포트 추적 (compose 등에서 여러 포트를 순차 할당 시 사용)
|
|
6
|
+
const sessionAllocatedPorts = new Set();
|
|
7
|
+
async function getUsedPorts() {
|
|
8
|
+
const processes = await getAllProcesses();
|
|
9
|
+
const usedPorts = new Set();
|
|
10
|
+
for (const [, mapping] of Object.entries(processes)) {
|
|
11
|
+
// 실행 중인 프로세스의 포트만 추가
|
|
12
|
+
if (mapping.status === 'running' && isProcessRunning(mapping.pid)) {
|
|
13
|
+
usedPorts.add(mapping.port);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return usedPorts;
|
|
17
|
+
}
|
|
18
|
+
export async function findAvailablePort(preferredPort = DEFAULT_PORT, excludePorts) {
|
|
19
|
+
const usedPorts = await getUsedPorts();
|
|
20
|
+
// 세션 내 할당된 포트와 excludePorts 병합
|
|
21
|
+
const allExcluded = new Set([
|
|
22
|
+
...usedPorts,
|
|
23
|
+
...sessionAllocatedPorts,
|
|
24
|
+
...(excludePorts || []),
|
|
25
|
+
]);
|
|
26
|
+
let port = preferredPort;
|
|
27
|
+
const maxAttempts = 100;
|
|
28
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
29
|
+
// 1. 제외 목록에서 이미 사용 중인지 확인
|
|
30
|
+
if (allExcluded.has(port)) {
|
|
31
|
+
port++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// 2. 시스템에서 실제로 사용 가능한지 확인
|
|
35
|
+
const availablePort = await detectPort(port);
|
|
36
|
+
if (availablePort === port) {
|
|
37
|
+
sessionAllocatedPorts.add(port);
|
|
38
|
+
return port;
|
|
39
|
+
}
|
|
40
|
+
// detect-port가 다른 포트를 제안하면 해당 포트도 확인
|
|
41
|
+
if (!allExcluded.has(availablePort)) {
|
|
42
|
+
sessionAllocatedPorts.add(availablePort);
|
|
43
|
+
return availablePort;
|
|
44
|
+
}
|
|
45
|
+
port = availablePort + 1;
|
|
46
|
+
}
|
|
47
|
+
throw new Error('Could not find an available port');
|
|
48
|
+
}
|
|
49
|
+
// 세션 종료 시 할당된 포트 초기화 (테스트용)
|
|
50
|
+
export function clearSessionAllocatedPorts() {
|
|
51
|
+
sessionAllocatedPorts.clear();
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createServer } from 'net';
|
|
3
|
+
import { findAvailablePort, clearSessionAllocatedPorts } from './port-finder.js';
|
|
4
|
+
import { setStatePath, addProcess, saveState } from './state.js';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { tmpdir } from 'os';
|
|
7
|
+
import { rm } from 'fs/promises';
|
|
8
|
+
describe('findAvailablePort', () => {
|
|
9
|
+
let server = null;
|
|
10
|
+
// 테스트용으로 높은 포트 번호 사용 (충돌 방지)
|
|
11
|
+
const TEST_PORT = 19876;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// 각 테스트 전에 세션 상태 초기화
|
|
14
|
+
clearSessionAllocatedPorts();
|
|
15
|
+
});
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
if (server) {
|
|
18
|
+
await new Promise((resolve) => {
|
|
19
|
+
server.close(() => resolve());
|
|
20
|
+
});
|
|
21
|
+
server = null;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
const occupyPort = (port) => {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const s = createServer();
|
|
27
|
+
s.listen(port, () => {
|
|
28
|
+
resolve(s);
|
|
29
|
+
});
|
|
30
|
+
s.on('error', reject);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
it('지정한 포트가 사용 가능하면 그대로 반환해야 한다', async () => {
|
|
34
|
+
const port = await findAvailablePort(TEST_PORT);
|
|
35
|
+
expect(port).toBe(TEST_PORT);
|
|
36
|
+
});
|
|
37
|
+
it('지정한 포트가 사용 중이면 다른 포트를 반환해야 한다', async () => {
|
|
38
|
+
server = await occupyPort(TEST_PORT);
|
|
39
|
+
const port = await findAvailablePort(TEST_PORT);
|
|
40
|
+
expect(port).not.toBe(TEST_PORT);
|
|
41
|
+
expect(port).toBeGreaterThan(0);
|
|
42
|
+
});
|
|
43
|
+
it('포트 번호는 유효한 범위 내에 있어야 한다', async () => {
|
|
44
|
+
const port = await findAvailablePort(TEST_PORT);
|
|
45
|
+
expect(port).toBeGreaterThanOrEqual(1);
|
|
46
|
+
expect(port).toBeLessThanOrEqual(65535);
|
|
47
|
+
});
|
|
48
|
+
it('기본 포트(3000)를 사용할 때 유효한 포트를 반환해야 한다', async () => {
|
|
49
|
+
const port = await findAvailablePort();
|
|
50
|
+
expect(port).toBeGreaterThanOrEqual(1);
|
|
51
|
+
expect(port).toBeLessThanOrEqual(65535);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('findAvailablePort - 상태 파일 참조', () => {
|
|
55
|
+
const TEST_PORT = 19877;
|
|
56
|
+
let testStatePath;
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
// 세션 상태 초기화
|
|
59
|
+
clearSessionAllocatedPorts();
|
|
60
|
+
// 테스트용 임시 상태 파일 설정
|
|
61
|
+
testStatePath = join(tmpdir(), `pa-test-${Date.now()}.json`);
|
|
62
|
+
setStatePath(testStatePath);
|
|
63
|
+
// 빈 상태로 초기화
|
|
64
|
+
await saveState({ mappings: {} });
|
|
65
|
+
});
|
|
66
|
+
afterEach(async () => {
|
|
67
|
+
// 테스트 상태 파일 정리
|
|
68
|
+
try {
|
|
69
|
+
await rm(testStatePath);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// 파일이 없어도 무시
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
it('상태 파일에 있는 실행 중인 프로세스의 포트를 피해야 한다', async () => {
|
|
76
|
+
// 상태 파일에 TEST_PORT 사용 중 기록 (현재 프로세스 PID로 실행 중 상태)
|
|
77
|
+
await addProcess('test-app', {
|
|
78
|
+
port: TEST_PORT,
|
|
79
|
+
pid: process.pid, // 현재 프로세스 PID (실행 중으로 판단)
|
|
80
|
+
command: 'node test.js',
|
|
81
|
+
originalCommand: 'node test.js',
|
|
82
|
+
injectionType: 'env',
|
|
83
|
+
cwd: '/tmp',
|
|
84
|
+
startedAt: new Date().toISOString(),
|
|
85
|
+
status: 'running',
|
|
86
|
+
});
|
|
87
|
+
const port = await findAvailablePort(TEST_PORT);
|
|
88
|
+
expect(port).not.toBe(TEST_PORT); // TEST_PORT가 아닌 다른 포트 반환
|
|
89
|
+
});
|
|
90
|
+
it('상태 파일에 있지만 종료된 프로세스의 포트는 사용 가능해야 한다', async () => {
|
|
91
|
+
// 상태 파일에 TEST_PORT 기록하지만 존재하지 않는 PID
|
|
92
|
+
await addProcess('dead-app', {
|
|
93
|
+
port: TEST_PORT,
|
|
94
|
+
pid: 99999999, // 존재하지 않는 PID
|
|
95
|
+
command: 'node test.js',
|
|
96
|
+
originalCommand: 'node test.js',
|
|
97
|
+
injectionType: 'env',
|
|
98
|
+
cwd: '/tmp',
|
|
99
|
+
startedAt: new Date().toISOString(),
|
|
100
|
+
status: 'running',
|
|
101
|
+
});
|
|
102
|
+
const port = await findAvailablePort(TEST_PORT);
|
|
103
|
+
// 실제 TEST_PORT가 사용 가능하면 TEST_PORT 반환
|
|
104
|
+
expect(port).toBe(TEST_PORT);
|
|
105
|
+
});
|
|
106
|
+
});
|