@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.
@@ -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
- `${process.env.HOME}/.bun/install/cache:/root/.bun/install/cache`,
42
+ `${this.cacheConfig.hostCachePath}:${this.cacheConfig.containerCachePathRoot}:rw`,
32
43
  '-v',
33
- `${process.env.HOME}/.bun/install/cache:/home/bun/.bun/install/cache`,
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'
@@ -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
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "outDir": "./dist",
5
- "skipLibCheck": true,
6
- "types": [
7
- "bun-types"
8
- ],
9
- "baseUrl": ".",
10
- "paths": {
11
- "@gravito/core": [
12
- "../../packages/core/src/index.ts"
13
- ],
14
- "@gravito/*": [
15
- "../../packages/*/src/index.ts"
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
+ }