@gravito/launchpad 1.2.2 → 1.3.2
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/CHANGELOG.md +66 -0
- package/README.md +51 -43
- package/README.zh-TW.md +110 -0
- package/dist/index.d.mts +248 -5
- package/dist/index.d.ts +248 -5
- package/dist/index.js +392 -20
- package/dist/index.mjs +380 -20
- package/package.json +11 -9
- package/scripts/check-coverage.ts +64 -0
- package/src/Application/MissionQueue.ts +143 -0
- package/src/Application/PoolManager.ts +167 -17
- package/src/Application/RefurbishUnit.ts +24 -12
- package/src/Domain/Events.ts +46 -0
- package/src/Domain/Interfaces.ts +82 -0
- package/src/Domain/Rocket.ts +18 -0
- package/src/Infrastructure/Docker/DockerAdapter.ts +79 -2
- package/src/index.ts +3 -0
- package/tests/Deployment.test.ts +3 -1
- package/tests/pool-manager.test.ts +3 -1
- package/tests/resource-limits.test.ts +241 -0
- package/tsconfig.json +14 -26
|
@@ -14,9 +14,19 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
14
14
|
private baseImage = 'oven/bun:1.0-slim'
|
|
15
15
|
private runtime = getRuntimeAdapter()
|
|
16
16
|
|
|
17
|
+
// 快取目錄配置
|
|
18
|
+
private readonly cacheConfig = {
|
|
19
|
+
hostCachePath: process.env.BUN_CACHE_PATH || `${process.env.HOME}/.bun/install/cache`,
|
|
20
|
+
containerCachePathRoot: '/root/.bun/install/cache',
|
|
21
|
+
containerCachePathBun: '/home/bun/.bun/install/cache',
|
|
22
|
+
}
|
|
23
|
+
|
|
17
24
|
async createBaseContainer(): Promise<string> {
|
|
18
25
|
const rocketId = `rocket-${crypto.randomUUID()}`
|
|
19
26
|
|
|
27
|
+
// 確保宿主機快取目錄存在
|
|
28
|
+
await this.ensureCacheDirectory()
|
|
29
|
+
|
|
20
30
|
const proc = this.runtime.spawn([
|
|
21
31
|
'docker',
|
|
22
32
|
'run',
|
|
@@ -27,10 +37,39 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
27
37
|
'gravito-origin=launchpad',
|
|
28
38
|
'-p',
|
|
29
39
|
'3000', // 讓 Docker 分配隨機宿主機埠
|
|
40
|
+
// === 快取掛載(關鍵) ===
|
|
30
41
|
'-v',
|
|
31
|
-
`${
|
|
42
|
+
`${this.cacheConfig.hostCachePath}:${this.cacheConfig.containerCachePathRoot}:rw`,
|
|
32
43
|
'-v',
|
|
33
|
-
`${
|
|
44
|
+
`${this.cacheConfig.hostCachePath}:${this.cacheConfig.containerCachePathBun}:rw`,
|
|
45
|
+
// === 環境變數配置(確保 bun 使用快取) ===
|
|
46
|
+
'-e',
|
|
47
|
+
`BUN_INSTALL_CACHE_DIR=${this.cacheConfig.containerCachePathBun}`,
|
|
48
|
+
'-e',
|
|
49
|
+
'BUN_INSTALL_CACHE=shared',
|
|
50
|
+
'-e',
|
|
51
|
+
'BUN_INSTALL_PREFER_OFFLINE=true',
|
|
52
|
+
// === 效能相關環境變數 ===
|
|
53
|
+
'-e',
|
|
54
|
+
'NODE_ENV=development',
|
|
55
|
+
// === 資源限制(防止單一容器占用過多資源) ===
|
|
56
|
+
'--memory',
|
|
57
|
+
'1g',
|
|
58
|
+
'--memory-swap',
|
|
59
|
+
'1g',
|
|
60
|
+
'--cpus',
|
|
61
|
+
'1.0',
|
|
62
|
+
// === 安全設定 ===
|
|
63
|
+
'--security-opt',
|
|
64
|
+
'no-new-privileges:true',
|
|
65
|
+
'--cap-drop',
|
|
66
|
+
'ALL',
|
|
67
|
+
'--cap-add',
|
|
68
|
+
'CHOWN',
|
|
69
|
+
'--cap-add',
|
|
70
|
+
'SETUID',
|
|
71
|
+
'--cap-add',
|
|
72
|
+
'SETGID',
|
|
34
73
|
this.baseImage,
|
|
35
74
|
'tail',
|
|
36
75
|
'-f',
|
|
@@ -42,6 +81,8 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
42
81
|
const exitCode = await proc.exited
|
|
43
82
|
|
|
44
83
|
if (containerId.length === 64 && /^[0-9a-f]+$/.test(containerId)) {
|
|
84
|
+
// 驗證快取是否正確掛載
|
|
85
|
+
await this.verifyCacheMount(containerId)
|
|
45
86
|
return containerId
|
|
46
87
|
}
|
|
47
88
|
|
|
@@ -53,6 +94,42 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
53
94
|
return containerId
|
|
54
95
|
}
|
|
55
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 確保快取目錄存在
|
|
99
|
+
*
|
|
100
|
+
* @private
|
|
101
|
+
* @since 1.3.0
|
|
102
|
+
*/
|
|
103
|
+
private async ensureCacheDirectory(): Promise<void> {
|
|
104
|
+
const cachePath = this.cacheConfig.hostCachePath
|
|
105
|
+
const proc = this.runtime.spawn(['mkdir', '-p', cachePath])
|
|
106
|
+
await proc.exited
|
|
107
|
+
|
|
108
|
+
// 設定適當的權限
|
|
109
|
+
const chmodProc = this.runtime.spawn(['chmod', '777', cachePath])
|
|
110
|
+
await chmodProc.exited
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 驗證快取是否正確掛載
|
|
115
|
+
*
|
|
116
|
+
* @private
|
|
117
|
+
* @since 1.3.0
|
|
118
|
+
*/
|
|
119
|
+
private async verifyCacheMount(containerId: string): Promise<void> {
|
|
120
|
+
const result = await this.executeCommand(containerId, [
|
|
121
|
+
'sh',
|
|
122
|
+
'-c',
|
|
123
|
+
`ls -la ${this.cacheConfig.containerCachePathBun} 2>/dev/null || echo 'NOT_MOUNTED'`,
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
if (result.stdout.includes('NOT_MOUNTED')) {
|
|
127
|
+
console.warn(`[DockerAdapter] 警告: 容器 ${containerId} 的快取目錄可能未正確掛載`)
|
|
128
|
+
} else {
|
|
129
|
+
console.log(`[DockerAdapter] 快取目錄已確認掛載: ${containerId}`)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
56
133
|
async getExposedPort(containerId: string, containerPort = 3000): Promise<number> {
|
|
57
134
|
const proc = this.runtime.spawn(['docker', 'port', containerId, containerPort.toString()])
|
|
58
135
|
const stdout = await new Response(proc.stdout ?? null).text()
|
package/src/index.ts
CHANGED
|
@@ -13,9 +13,12 @@ import { CachedRocketRepository } from './Infrastructure/Persistence/CachedRocke
|
|
|
13
13
|
import { BunProxyAdapter } from './Infrastructure/Router/BunProxyAdapter'
|
|
14
14
|
|
|
15
15
|
export * from './Application/MissionControl'
|
|
16
|
+
export * from './Application/MissionQueue'
|
|
16
17
|
export * from './Application/PayloadInjector'
|
|
17
18
|
export * from './Application/PoolManager'
|
|
18
19
|
export * from './Application/RefurbishUnit'
|
|
20
|
+
export * from './Domain/Events'
|
|
21
|
+
export * from './Domain/Interfaces'
|
|
19
22
|
export * from './Domain/Mission'
|
|
20
23
|
export * from './Domain/Rocket'
|
|
21
24
|
export * from './Domain/RocketStatus'
|
package/tests/Deployment.test.ts
CHANGED
|
@@ -23,7 +23,9 @@ describe('Payload Injection Flow', () => {
|
|
|
23
23
|
findAll: mock(() => Promise.resolve([])),
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const pool = new PoolManager(mockDocker, mockRepo
|
|
26
|
+
const pool = new PoolManager(mockDocker, mockRepo, undefined, undefined, {
|
|
27
|
+
exhaustionStrategy: 'dynamic',
|
|
28
|
+
})
|
|
27
29
|
const injector = new PayloadInjector(mockDocker, mockGit)
|
|
28
30
|
|
|
29
31
|
it('應該能從指派任務到成功點火', async () => {
|
|
@@ -39,7 +39,9 @@ describe('PoolManager', () => {
|
|
|
39
39
|
test('assigns missions to idle rockets or creates new ones', async () => {
|
|
40
40
|
const docker = new FakeDocker()
|
|
41
41
|
const repo = new InMemoryRocketRepository()
|
|
42
|
-
const pool = new PoolManager(docker as any, repo
|
|
42
|
+
const pool = new PoolManager(docker as any, repo, undefined, undefined, {
|
|
43
|
+
exhaustionStrategy: 'dynamic',
|
|
44
|
+
})
|
|
43
45
|
|
|
44
46
|
const mission = Mission.create({
|
|
45
47
|
id: 'mission-2',
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import { MissionQueue, PoolExhaustedException } from '../src/Application/MissionQueue'
|
|
3
|
+
import { PoolManager } from '../src/Application/PoolManager'
|
|
4
|
+
import { Mission } from '../src/Domain/Mission'
|
|
5
|
+
import type { Rocket } from '../src/Domain/Rocket'
|
|
6
|
+
import { RocketStatus } from '../src/Domain/RocketStatus'
|
|
7
|
+
|
|
8
|
+
describe('MissionQueue', () => {
|
|
9
|
+
let queue: MissionQueue
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
queue = new MissionQueue(3, 1000)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('should enqueue missions and track length', () => {
|
|
16
|
+
const mission = Mission.create({
|
|
17
|
+
id: 'pr-1',
|
|
18
|
+
repoUrl: 'https://github.com/test/repo',
|
|
19
|
+
branch: 'main',
|
|
20
|
+
commitSha: 'abc123',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const promise = queue.enqueue(mission)
|
|
24
|
+
expect(queue.length).toBe(1)
|
|
25
|
+
expect(promise).toBeInstanceOf(Promise)
|
|
26
|
+
|
|
27
|
+
// 清理:dequeue 或 catch 錯誤以避免 unhandled rejection
|
|
28
|
+
promise.catch(() => {})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('should reject when queue is full', async () => {
|
|
32
|
+
const promises = []
|
|
33
|
+
for (let i = 0; i < 3; i++) {
|
|
34
|
+
const promise = queue.enqueue(
|
|
35
|
+
Mission.create({
|
|
36
|
+
id: `pr-${i}`,
|
|
37
|
+
repoUrl: 'https://github.com/test/repo',
|
|
38
|
+
branch: 'main',
|
|
39
|
+
commitSha: 'abc123',
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
// 捕獲超時錯誤以避免 unhandled rejection
|
|
43
|
+
promise.catch(() => {})
|
|
44
|
+
promises.push(promise)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const mission = Mission.create({
|
|
48
|
+
id: 'pr-4',
|
|
49
|
+
repoUrl: 'https://github.com/test/repo',
|
|
50
|
+
branch: 'main',
|
|
51
|
+
commitSha: 'abc123',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await expect(queue.enqueue(mission)).rejects.toThrow(PoolExhaustedException)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('should dequeue in FIFO order', () => {
|
|
58
|
+
const m1 = Mission.create({
|
|
59
|
+
id: 'pr-1',
|
|
60
|
+
repoUrl: 'https://github.com/test/repo',
|
|
61
|
+
branch: 'main',
|
|
62
|
+
commitSha: 'abc123',
|
|
63
|
+
})
|
|
64
|
+
const m2 = Mission.create({
|
|
65
|
+
id: 'pr-2',
|
|
66
|
+
repoUrl: 'https://github.com/test/repo',
|
|
67
|
+
branch: 'main',
|
|
68
|
+
commitSha: 'abc123',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const p1 = queue.enqueue(m1)
|
|
72
|
+
const p2 = queue.enqueue(m2)
|
|
73
|
+
|
|
74
|
+
const first = queue.dequeue()
|
|
75
|
+
expect(first?.mission.id).toBe('pr-1')
|
|
76
|
+
|
|
77
|
+
const second = queue.dequeue()
|
|
78
|
+
expect(second?.mission.id).toBe('pr-2')
|
|
79
|
+
|
|
80
|
+
const empty = queue.dequeue()
|
|
81
|
+
expect(empty).toBeNull()
|
|
82
|
+
|
|
83
|
+
// 清理未使用的 promises
|
|
84
|
+
p1.catch(() => {})
|
|
85
|
+
p2.catch(() => {})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('should provide stats', async () => {
|
|
89
|
+
const promise = queue.enqueue(
|
|
90
|
+
Mission.create({
|
|
91
|
+
id: 'pr-1',
|
|
92
|
+
repoUrl: 'https://github.com/test/repo',
|
|
93
|
+
branch: 'main',
|
|
94
|
+
commitSha: 'abc123',
|
|
95
|
+
})
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// 等待一點時間讓時間戳有差異
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
100
|
+
|
|
101
|
+
const stats = queue.getStats()
|
|
102
|
+
expect(stats.length).toBe(1)
|
|
103
|
+
expect(stats.maxSize).toBe(3)
|
|
104
|
+
expect(stats.oldestWaitMs).toBeGreaterThanOrEqual(0)
|
|
105
|
+
|
|
106
|
+
// 清理
|
|
107
|
+
promise.catch(() => {})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('PoolManager Resource Limits', () => {
|
|
112
|
+
let mockDocker: any
|
|
113
|
+
let mockRepo: any
|
|
114
|
+
let rockets: Rocket[]
|
|
115
|
+
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
rockets = []
|
|
118
|
+
mockDocker = {
|
|
119
|
+
createBaseContainer: mock(() => Promise.resolve(`container-${Date.now()}`)),
|
|
120
|
+
removeContainer: mock(() => Promise.resolve()),
|
|
121
|
+
executeCommand: mock(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })),
|
|
122
|
+
getExposedPort: mock(() => Promise.resolve(3000)),
|
|
123
|
+
removeContainerByLabel: mock(() => Promise.resolve()),
|
|
124
|
+
streamLogs: mock(() => {}),
|
|
125
|
+
getStats: mock(() => Promise.resolve({ cpu: '0%', memory: '0MB' })),
|
|
126
|
+
}
|
|
127
|
+
mockRepo = {
|
|
128
|
+
save: mock((r: Rocket) => {
|
|
129
|
+
const index = rockets.findIndex((rocket) => rocket.id === r.id)
|
|
130
|
+
if (index >= 0) {
|
|
131
|
+
rockets[index] = r
|
|
132
|
+
} else {
|
|
133
|
+
rockets.push(r)
|
|
134
|
+
}
|
|
135
|
+
return Promise.resolve()
|
|
136
|
+
}),
|
|
137
|
+
findAll: mock(() => Promise.resolve(rockets)),
|
|
138
|
+
findIdle: mock(() =>
|
|
139
|
+
Promise.resolve(rockets.find((r) => r.status === RocketStatus.IDLE) || null)
|
|
140
|
+
),
|
|
141
|
+
findById: mock((id: string) => Promise.resolve(rockets.find((r) => r.id === id) || null)),
|
|
142
|
+
delete: mock(() => Promise.resolve()),
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test('should reject when maxRockets reached with reject strategy', async () => {
|
|
147
|
+
const manager = new PoolManager(mockDocker, mockRepo, undefined, undefined, {
|
|
148
|
+
maxRockets: 2,
|
|
149
|
+
exhaustionStrategy: 'reject',
|
|
150
|
+
warmupCount: 2,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await manager.warmup()
|
|
154
|
+
expect(rockets.length).toBe(2)
|
|
155
|
+
|
|
156
|
+
// 使用全部火箭
|
|
157
|
+
const m1 = Mission.create({
|
|
158
|
+
id: 'pr-1',
|
|
159
|
+
repoUrl: 'https://github.com/test/repo',
|
|
160
|
+
branch: 'main',
|
|
161
|
+
commitSha: 'abc1',
|
|
162
|
+
})
|
|
163
|
+
const m2 = Mission.create({
|
|
164
|
+
id: 'pr-2',
|
|
165
|
+
repoUrl: 'https://github.com/test/repo',
|
|
166
|
+
branch: 'main',
|
|
167
|
+
commitSha: 'abc2',
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
await manager.assignMission(m1)
|
|
171
|
+
await manager.assignMission(m2)
|
|
172
|
+
|
|
173
|
+
// 第三個請求應該被拒絕
|
|
174
|
+
const m3 = Mission.create({
|
|
175
|
+
id: 'pr-3',
|
|
176
|
+
repoUrl: 'https://github.com/test/repo',
|
|
177
|
+
branch: 'main',
|
|
178
|
+
commitSha: 'abc3',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
await expect(manager.assignMission(m3)).rejects.toThrow(PoolExhaustedException)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('should respect maxRockets limit', async () => {
|
|
185
|
+
const manager = new PoolManager(mockDocker, mockRepo, undefined, undefined, {
|
|
186
|
+
maxRockets: 5,
|
|
187
|
+
warmupCount: 3,
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
await manager.warmup()
|
|
191
|
+
|
|
192
|
+
const status = await manager.getPoolStatus()
|
|
193
|
+
expect(status.total).toBe(3)
|
|
194
|
+
expect(status.idle).toBe(3)
|
|
195
|
+
expect(status.maxRockets).toBe(5)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('should queue missions when pool is exhausted', async () => {
|
|
199
|
+
const manager = new PoolManager(mockDocker, mockRepo, undefined, undefined, {
|
|
200
|
+
maxRockets: 1,
|
|
201
|
+
exhaustionStrategy: 'queue',
|
|
202
|
+
queueTimeoutMs: 5000,
|
|
203
|
+
warmupCount: 1,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
await manager.warmup()
|
|
207
|
+
|
|
208
|
+
const m1 = Mission.create({
|
|
209
|
+
id: 'pr-1',
|
|
210
|
+
repoUrl: 'https://github.com/test/repo',
|
|
211
|
+
branch: 'main',
|
|
212
|
+
commitSha: 'abc1',
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const rocket1 = await manager.assignMission(m1)
|
|
216
|
+
expect(rocket1.status).toBe(RocketStatus.PREPARING)
|
|
217
|
+
|
|
218
|
+
// 第二個任務應該進入隊列
|
|
219
|
+
const m2 = Mission.create({
|
|
220
|
+
id: 'pr-2',
|
|
221
|
+
repoUrl: 'https://github.com/test/repo',
|
|
222
|
+
branch: 'main',
|
|
223
|
+
commitSha: 'abc2',
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const promise = manager.assignMission(m2)
|
|
227
|
+
|
|
228
|
+
// 等待 promise 開始處理
|
|
229
|
+
await new Promise((resolve) => setTimeout(resolve, 10))
|
|
230
|
+
|
|
231
|
+
const stats = manager.getQueueStats()
|
|
232
|
+
expect(stats.length).toBe(1)
|
|
233
|
+
|
|
234
|
+
// 模擬回收
|
|
235
|
+
rocket1.ignite()
|
|
236
|
+
await manager.recycle('pr-1')
|
|
237
|
+
|
|
238
|
+
const rocket2 = await promise
|
|
239
|
+
expect(rocket2.currentMission?.id).toBe('pr-2')
|
|
240
|
+
})
|
|
241
|
+
})
|
package/tsconfig.json
CHANGED
|
@@ -1,27 +1,15 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
]
|
|
17
|
-
}
|
|
18
|
-
},
|
|
19
|
-
"include": [
|
|
20
|
-
"src/**/*"
|
|
21
|
-
],
|
|
22
|
-
"exclude": [
|
|
23
|
-
"node_modules",
|
|
24
|
-
"dist",
|
|
25
|
-
"**/*.test.ts"
|
|
26
|
-
]
|
|
27
|
-
}
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"skipLibCheck": true,
|
|
6
|
+
"types": ["bun-types"],
|
|
7
|
+
"baseUrl": ".",
|
|
8
|
+
"paths": {
|
|
9
|
+
"@gravito/core": ["../../packages/core/src/index.ts"],
|
|
10
|
+
"@gravito/*": ["../../packages/*/src/index.ts"]
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
15
|
+
}
|