@gravito/launchpad 0.1.0
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/.turbo/turbo-test.log +82 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/Dockerfile +23 -0
- package/README.md +44 -0
- package/debug-launch.ts +66 -0
- package/package.json +26 -0
- package/server.ts +7 -0
- package/simulate-github.ts +55 -0
- package/src/Application/MissionControl.ts +59 -0
- package/src/Application/PayloadInjector.ts +60 -0
- package/src/Application/PoolManager.ts +81 -0
- package/src/Application/RefurbishUnit.ts +40 -0
- package/src/Domain/Events.ts +28 -0
- package/src/Domain/Interfaces.ts +62 -0
- package/src/Domain/Mission.ts +27 -0
- package/src/Domain/Rocket.ts +116 -0
- package/src/Domain/RocketStatus.ts +10 -0
- package/src/Infrastructure/Docker/DockerAdapter.ts +127 -0
- package/src/Infrastructure/Git/ShellGitAdapter.ts +22 -0
- package/src/Infrastructure/GitHub/OctokitGitHubAdapter.ts +42 -0
- package/src/Infrastructure/Persistence/CachedRocketRepository.ts +42 -0
- package/src/Infrastructure/Persistence/InMemoryRocketRepository.ts +32 -0
- package/src/Infrastructure/Router/BunProxyAdapter.ts +54 -0
- package/src/index.ts +223 -0
- package/tests/Deployment.test.ts +50 -0
- package/tests/Integration.test.ts +42 -0
- package/tests/PoolManager.test.ts +60 -0
- package/tests/Refurbishment.test.ts +43 -0
- package/tests/Rocket.test.ts +49 -0
- package/tests/Telemetry.test.ts +50 -0
- package/tests/docker-adapter.test.ts +110 -0
- package/tests/enterprise-coverage.test.ts +62 -0
- package/tests/mission-control.test.ts +79 -0
- package/tests/mission.test.ts +18 -0
- package/tests/payload-injector.test.ts +64 -0
- package/tests/pool-manager.test.ts +93 -0
- package/tests/repository.test.ts +31 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
$ bun test
|
|
2
|
+
bun test v1.3.4 (5eb2145b)
|
|
3
|
+
|
|
4
|
+
tests/mission.test.ts:
|
|
5
|
+
(pass) Mission > exposes mission properties [0.13ms]
|
|
6
|
+
|
|
7
|
+
tests/Telemetry.test.ts:
|
|
8
|
+
[MissionControl] 準備發射任務: pr-1
|
|
9
|
+
[MissionControl] 任務 pr-1 映射端口: 3000
|
|
10
|
+
(pass) MissionControl Telemetry > 發射時應該能正確啟動日誌串流與效能監控 [0.60ms]
|
|
11
|
+
|
|
12
|
+
tests/mission-control.test.ts:
|
|
13
|
+
[MissionControl] 準備發射任務: mission-telemetry
|
|
14
|
+
[MissionControl] 任務 mission-telemetry 映射端口: 3000
|
|
15
|
+
[MissionControl] 任務 mission-telemetry TTL 已到期,執行自動回收...
|
|
16
|
+
(pass) MissionControl timers > emits stats and schedules recycle [0.80ms]
|
|
17
|
+
|
|
18
|
+
tests/pool-manager.test.ts:
|
|
19
|
+
[LaunchPad] 正在熱機,準備發射 2 架新火箭...
|
|
20
|
+
(pass) PoolManager > warms up the pool with base containers [0.93ms]
|
|
21
|
+
[LaunchPad] 正在熱機,準備發射 1 架新火箭...
|
|
22
|
+
[LaunchPad] 資源吃緊,正在緊急呼叫後援火箭...
|
|
23
|
+
(pass) PoolManager > assigns missions to idle rockets or creates new ones [0.70ms]
|
|
24
|
+
[LaunchPad] 正在熱機,準備發射 1 架新火箭...
|
|
25
|
+
(pass) PoolManager > recycles rockets through refurbish flow [0.68ms]
|
|
26
|
+
|
|
27
|
+
tests/enterprise-coverage.test.ts:
|
|
28
|
+
(pass) Launchpad enterprise coverage > covers core enterprise primitives [0.36ms]
|
|
29
|
+
|
|
30
|
+
tests/repository.test.ts:
|
|
31
|
+
(pass) InMemoryRocketRepository > stores and retrieves rockets [0.23ms]
|
|
32
|
+
(pass) InMemoryRocketRepository > finds idle rockets [0.02ms]
|
|
33
|
+
|
|
34
|
+
tests/Rocket.test.ts:
|
|
35
|
+
(pass) Rocket > transitions through mission lifecycle and emits events [0.26ms]
|
|
36
|
+
(pass) Rocket > guards invalid transitions [0.11ms]
|
|
37
|
+
|
|
38
|
+
tests/Refurbishment.test.ts:
|
|
39
|
+
[RefurbishUnit] 正在翻新火箭: r1 (容器: c1)
|
|
40
|
+
[RefurbishUnit] 火箭 r1 翻新完成,已進入 IDLE 狀態。
|
|
41
|
+
(pass) RefurbishUnit (Rocket Recovery) > 應該能執行深度清理並將火箭重置為 IDLE [0.45ms]
|
|
42
|
+
[RefurbishUnit] 正在翻新火箭: r2 (容器: c2)
|
|
43
|
+
[RefurbishUnit] 清理失敗: Disk Full
|
|
44
|
+
(pass) RefurbishUnit (Rocket Recovery) > 如果清理失敗應該將火箭除役 [0.17ms]
|
|
45
|
+
|
|
46
|
+
tests/payload-injector.test.ts:
|
|
47
|
+
(pass) PayloadInjector > throws when rocket has no mission [0.62ms]
|
|
48
|
+
[PayloadInjector] 正在拉取代碼: https://example.com/repo.git (main)
|
|
49
|
+
[PayloadInjector] 正在注入載荷至容器: container-10
|
|
50
|
+
[PayloadInjector] 正在安裝依賴...
|
|
51
|
+
[PayloadInjector] 點火!
|
|
52
|
+
(pass) PayloadInjector > deploys payload and ignites rocket [0.45ms]
|
|
53
|
+
|
|
54
|
+
tests/docker-adapter.test.ts:
|
|
55
|
+
(pass) DockerAdapter > creates base container when stdout has container id [1.38ms]
|
|
56
|
+
(pass) DockerAdapter > throws when container creation fails [0.09ms]
|
|
57
|
+
(pass) DockerAdapter > copyFiles throws on non-zero exit code [0.25ms]
|
|
58
|
+
(pass) DockerAdapter > executeCommand returns stdout and stderr [0.26ms]
|
|
59
|
+
(pass) DockerAdapter > getStats parses cpu and memory output [0.74ms]
|
|
60
|
+
(pass) DockerAdapter > streamLogs forwards stdout and stderr [2.73ms]
|
|
61
|
+
|
|
62
|
+
tests/Integration.test.ts:
|
|
63
|
+
[LaunchPad] 正在熱機,準備發射 1 架新火箭...
|
|
64
|
+
[Test] 容器內 Bun 版本: 1.0.36
|
|
65
|
+
(pass) LaunchPad 集成測試 (真實 Docker) > 應該能成功熱機並在容器內執行指令 [613.64ms]
|
|
66
|
+
|
|
67
|
+
tests/Deployment.test.ts:
|
|
68
|
+
[LaunchPad] 資源吃緊,正在緊急呼叫後援火箭...
|
|
69
|
+
[PayloadInjector] 正在拉取代碼: http://git (dev)
|
|
70
|
+
[PayloadInjector] 正在注入載荷至容器: cid-1
|
|
71
|
+
[PayloadInjector] 正在安裝依賴...
|
|
72
|
+
[PayloadInjector] 點火!
|
|
73
|
+
(pass) Payload Injection Flow > 應該能從指派任務到成功點火 [0.39ms]
|
|
74
|
+
|
|
75
|
+
tests/PoolManager.test.ts:
|
|
76
|
+
[LaunchPad] 正在熱機,準備發射 2 架新火箭...
|
|
77
|
+
(pass) PoolManager (Application Service) > 應該能正確熱機並指派任務 [0.53ms]
|
|
78
|
+
|
|
79
|
+
24 pass
|
|
80
|
+
0 fail
|
|
81
|
+
81 expect() calls
|
|
82
|
+
Ran 24 tests across 13 files. [833.00ms]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
$ tsc --noEmit
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
FROM oven/bun:1.0 AS base
|
|
2
|
+
WORKDIR /usr/src/app
|
|
3
|
+
|
|
4
|
+
# 安裝 Docker CLI
|
|
5
|
+
RUN apt-get update && apt-get install -y docker.io
|
|
6
|
+
|
|
7
|
+
# 複製基礎設定
|
|
8
|
+
COPY package.json bun.lock turbo.json tsconfig.json ./
|
|
9
|
+
COPY packages ./packages
|
|
10
|
+
|
|
11
|
+
# 建立空的工作區目錄以滿足 Bun 檢查
|
|
12
|
+
RUN mkdir -p templates examples
|
|
13
|
+
|
|
14
|
+
# 安裝依賴
|
|
15
|
+
RUN bun install
|
|
16
|
+
|
|
17
|
+
# 設定工作目錄到 launchpad
|
|
18
|
+
WORKDIR /usr/src/app/packages/launchpad
|
|
19
|
+
|
|
20
|
+
EXPOSE 4000
|
|
21
|
+
EXPOSE 8080
|
|
22
|
+
|
|
23
|
+
ENTRYPOINT [ "bun", "run", "server.ts" ]
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @gravito/launchpad
|
|
2
|
+
|
|
3
|
+
> 🚀 Bun 火箭回收系統:專為 Bun 打造的秒級容器部署與生命週期管理系統。
|
|
4
|
+
|
|
5
|
+
## 核心特性
|
|
6
|
+
|
|
7
|
+
- **Rocket Pool**: 預熱容器池,消除啟動冷啟動。
|
|
8
|
+
- **Payload Injection**: 跳過 Docker Build,透過 `docker cp` 秒級注入代碼。
|
|
9
|
+
- **DDD 架構**: 基於 `@gravito/enterprise` 實作,具備嚴謹的狀態機管理。
|
|
10
|
+
- **可回收性**: 任務結束後自動翻新容器,資源零浪費。
|
|
11
|
+
|
|
12
|
+
## 架構概覽
|
|
13
|
+
|
|
14
|
+
本套件遵循 **Clean Architecture** 與 **DDD**:
|
|
15
|
+
|
|
16
|
+
- **Domain**: 定義 `Rocket` 狀態機與 `Mission` 邏輯。
|
|
17
|
+
- **Application**: `PoolManager` (調度) 與 `PayloadInjector` (部署)。
|
|
18
|
+
- **Infrastructure**: 底層 Docker 與 Git 操作實作。
|
|
19
|
+
|
|
20
|
+
## 快速開始
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { PoolManager, PayloadInjector } from '@gravito/launchpad'
|
|
24
|
+
import { DockerAdapter, ShellGitAdapter, InMemoryRocketRepository } from '@gravito/launchpad/infra'
|
|
25
|
+
|
|
26
|
+
const manager = new PoolManager(new DockerAdapter(), new InMemoryRocketRepository())
|
|
27
|
+
const injector = new PayloadInjector(new DockerAdapter(), new ShellGitAdapter())
|
|
28
|
+
|
|
29
|
+
// 1. 預熱池子
|
|
30
|
+
await manager.warmup(3)
|
|
31
|
+
|
|
32
|
+
// 2. 指派任務
|
|
33
|
+
const mission = Mission.create({ ... })
|
|
34
|
+
const rocket = await manager.assignMission(mission)
|
|
35
|
+
|
|
36
|
+
// 3. 秒級部署
|
|
37
|
+
await injector.deploy(rocket)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 測試
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
bun test
|
|
44
|
+
```
|
package/debug-launch.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { DockerAdapter } from './src/Infrastructure/Docker/DockerAdapter'
|
|
2
|
+
import { bootstrapLaunchpad } from './src/index'
|
|
3
|
+
|
|
4
|
+
async function run() {
|
|
5
|
+
console.log('🤖 Starting Auto-Debug Sequence...')
|
|
6
|
+
|
|
7
|
+
// 1. Cleanup
|
|
8
|
+
const _docker = new DockerAdapter()
|
|
9
|
+
try {
|
|
10
|
+
const containers = await Bun.spawn([
|
|
11
|
+
'docker',
|
|
12
|
+
'ps',
|
|
13
|
+
'-aq',
|
|
14
|
+
'--filter',
|
|
15
|
+
'label=gravito-origin=launchpad',
|
|
16
|
+
]).text()
|
|
17
|
+
if (containers.trim()) {
|
|
18
|
+
console.log('🧹 Cleaning up old containers...')
|
|
19
|
+
await Bun.spawn(['docker', 'rm', '-f', ...containers.trim().split('\n')]).exited
|
|
20
|
+
}
|
|
21
|
+
} catch (_e) {}
|
|
22
|
+
|
|
23
|
+
// 2. Start Server
|
|
24
|
+
const config = await bootstrapLaunchpad()
|
|
25
|
+
const server = Bun.serve(config)
|
|
26
|
+
console.log(`🚀 Server started at http://localhost:${config.port}`)
|
|
27
|
+
|
|
28
|
+
// 3. Launch Mission
|
|
29
|
+
console.log('🔫 Firing Mission...')
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`http://localhost:${config.port}/launch`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
'Content-Type': 'application/json',
|
|
35
|
+
'X-GitHub-Event': 'pull_request', // 模擬 GitHub 事件
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify({
|
|
38
|
+
action: 'opened',
|
|
39
|
+
pull_request: {
|
|
40
|
+
number: 77,
|
|
41
|
+
head: { ref: 'feat/launchpad-dashboard', sha: 'latest' },
|
|
42
|
+
base: {
|
|
43
|
+
repo: {
|
|
44
|
+
owner: { login: 'gravito' },
|
|
45
|
+
name: 'core',
|
|
46
|
+
clone_url: 'https://github.com/gravito-framework/gravito.git',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const data = await res.json()
|
|
54
|
+
console.log('Response:', data)
|
|
55
|
+
|
|
56
|
+
console.log('✅ Mission active. Monitoring for 30s...')
|
|
57
|
+
await new Promise((r) => setTimeout(r, 30000))
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.error('❌ Error:', e)
|
|
60
|
+
} finally {
|
|
61
|
+
server.stop()
|
|
62
|
+
process.exit(0)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
run()
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gravito/launchpad",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Container lifecycle management system for flash deployments",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
10
|
+
"test": "bun test",
|
|
11
|
+
"test:coverage": "bun test --coverage --coverage-threshold=80",
|
|
12
|
+
"test:ci": "bun test --coverage --coverage-threshold=80",
|
|
13
|
+
"typecheck": "tsc --noEmit"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@gravito/enterprise": "workspace:*",
|
|
17
|
+
"@gravito/ripple": "workspace:*",
|
|
18
|
+
"@gravito/stasis": "workspace:*",
|
|
19
|
+
"@octokit/rest": "^22.0.1",
|
|
20
|
+
"gravito-core": "workspace:*"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"tsup": "^8.0.0",
|
|
24
|
+
"typescript": "^5.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/server.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { bootstrapLaunchpad } from './src/index'
|
|
2
|
+
|
|
3
|
+
const config = await bootstrapLaunchpad()
|
|
4
|
+
Bun.serve(config)
|
|
5
|
+
|
|
6
|
+
console.log(`🚀 Launchpad Command Center active at: http://localhost:${config.port}`)
|
|
7
|
+
console.log(`📡 Telemetry WebSocket channel: ws://localhost:${config.port}/ws`)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Webhook 模擬器
|
|
3
|
+
* 模擬 GitHub 發送 pull_request 事件給 Launchpad
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const LAUNCHPAD_URL = 'http://localhost:4000/launch'
|
|
7
|
+
|
|
8
|
+
async function simulateWebhook(action: 'opened' | 'synchronize' | 'closed') {
|
|
9
|
+
console.log(`\n🚀 正在模擬 GitHub Action: ${action.toUpperCase()}...`)
|
|
10
|
+
|
|
11
|
+
const payload = {
|
|
12
|
+
action: action,
|
|
13
|
+
number: 19,
|
|
14
|
+
pull_request: {
|
|
15
|
+
number: 19,
|
|
16
|
+
state: action === 'closed' ? 'closed' : 'open',
|
|
17
|
+
head: {
|
|
18
|
+
ref: 'feat/launchpad-github-bot',
|
|
19
|
+
sha: '25837ad8225837ad8225837ad8225837ad825837',
|
|
20
|
+
},
|
|
21
|
+
base: {
|
|
22
|
+
repo: {
|
|
23
|
+
name: 'gravito',
|
|
24
|
+
owner: { login: 'gravito-framework' },
|
|
25
|
+
clone_url: 'https://github.com/gravito-framework/gravito.git',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(LAUNCHPAD_URL, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/json',
|
|
36
|
+
'X-GitHub-Event': 'pull_request',
|
|
37
|
+
'X-Hub-Signature-256': 'sha256=MOCK_SIGNATURE', // 模擬簽名
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify(payload),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const result = await response.json()
|
|
43
|
+
console.log('✅ Launchpad 回應:', JSON.stringify(result, null, 2))
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('❌ 模擬失敗:', error)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 執行模擬流程
|
|
50
|
+
async function runTest() {
|
|
51
|
+
// 1. 模擬開啟 PR (觸發部署)
|
|
52
|
+
await simulateWebhook('opened')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
runTest()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { IDockerAdapter, IRouterAdapter } from '../Domain/Interfaces'
|
|
2
|
+
import type { Mission } from '../Domain/Mission'
|
|
3
|
+
import type { PayloadInjector } from './PayloadInjector'
|
|
4
|
+
import type { PoolManager } from './PoolManager'
|
|
5
|
+
|
|
6
|
+
export class MissionControl {
|
|
7
|
+
constructor(
|
|
8
|
+
private poolManager: PoolManager,
|
|
9
|
+
private injector: PayloadInjector,
|
|
10
|
+
private docker: IDockerAdapter,
|
|
11
|
+
private router?: IRouterAdapter
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
async launch(mission: Mission, onTelemetry: (type: string, data: any) => void): Promise<string> {
|
|
15
|
+
console.log(`[MissionControl] 準備發射任務: ${mission.id}`)
|
|
16
|
+
|
|
17
|
+
// 1. 從池子抓火箭
|
|
18
|
+
const rocket = await this.poolManager.assignMission(mission)
|
|
19
|
+
|
|
20
|
+
// 2. 注入代碼並點火
|
|
21
|
+
await this.injector.deploy(rocket)
|
|
22
|
+
|
|
23
|
+
// 3. 獲取真實映射端口
|
|
24
|
+
const publicPort = await this.docker.getExposedPort(rocket.containerId, 3000)
|
|
25
|
+
console.log(`[MissionControl] 任務 ${mission.id} 映射端口: ${publicPort}`)
|
|
26
|
+
|
|
27
|
+
// 4. 分配域名
|
|
28
|
+
const domain = `${mission.id}.dev.local`.toLowerCase()
|
|
29
|
+
rocket.assignDomain(domain)
|
|
30
|
+
|
|
31
|
+
if (this.router) {
|
|
32
|
+
// 關鍵修復:Proxy 指向真實的 Host Port
|
|
33
|
+
this.router.register(domain, `http://localhost:${publicPort}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.docker.streamLogs(rocket.containerId, (log) => {
|
|
37
|
+
onTelemetry('log', { rocketId: rocket.id, text: log })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const statsTimer = setInterval(async () => {
|
|
41
|
+
if (rocket.status === 'DECOMMISSIONED' || rocket.status === 'IDLE') {
|
|
42
|
+
clearInterval(statsTimer)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const stats = await this.docker.getStats(rocket.containerId)
|
|
46
|
+
onTelemetry('stats', { rocketId: rocket.id, ...stats })
|
|
47
|
+
}, 5000)
|
|
48
|
+
|
|
49
|
+
const ttl = 10 * 60 * 1000
|
|
50
|
+
setTimeout(async () => {
|
|
51
|
+
console.log(`[MissionControl] 任務 ${mission.id} TTL 已到期,執行自動回收...`)
|
|
52
|
+
clearInterval(statsTimer)
|
|
53
|
+
await this.poolManager.recycle(mission.id)
|
|
54
|
+
onTelemetry('log', { rocketId: rocket.id, text: '--- MISSION EXPIRED (TTL) ---' })
|
|
55
|
+
}, ttl)
|
|
56
|
+
|
|
57
|
+
return rocket.id
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IDockerAdapter, IGitAdapter } from '../Domain/Interfaces'
|
|
2
|
+
import type { Rocket } from '../Domain/Rocket'
|
|
3
|
+
|
|
4
|
+
export class PayloadInjector {
|
|
5
|
+
constructor(
|
|
6
|
+
private docker: IDockerAdapter,
|
|
7
|
+
private git: IGitAdapter
|
|
8
|
+
) {}
|
|
9
|
+
|
|
10
|
+
async deploy(rocket: Rocket): Promise<void> {
|
|
11
|
+
if (!rocket.currentMission) {
|
|
12
|
+
throw new Error(`Rocket ${rocket.id} 沒有指派任務,無法部署`)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { repoUrl, branch } = rocket.currentMission
|
|
16
|
+
const containerId = rocket.containerId
|
|
17
|
+
|
|
18
|
+
console.log(`[PayloadInjector] 正在拉取代碼: ${repoUrl} (${branch})`)
|
|
19
|
+
const codePath = await this.git.clone(repoUrl, branch)
|
|
20
|
+
|
|
21
|
+
console.log(`[PayloadInjector] 正在注入載荷至容器: ${containerId}`)
|
|
22
|
+
await this.docker.copyFiles(containerId, codePath, '/app')
|
|
23
|
+
|
|
24
|
+
console.log(`[PayloadInjector] 正在安裝依賴...`)
|
|
25
|
+
|
|
26
|
+
// 寫入一個強制覆蓋的 bunfig.toml
|
|
27
|
+
const bunfigContent = `[install]\nfrozenLockfile = false\n`
|
|
28
|
+
await this.docker.executeCommand(containerId, [
|
|
29
|
+
'sh',
|
|
30
|
+
'-c',
|
|
31
|
+
`echo "${bunfigContent}" > /app/bunfig.toml`,
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
// 刪除舊的 lockfile
|
|
35
|
+
await this.docker.executeCommand(containerId, ['rm', '-f', '/app/bun.lockb'])
|
|
36
|
+
|
|
37
|
+
// 安裝 (跳過腳本以避免編譯原生模組失敗)
|
|
38
|
+
const installRes = await this.docker.executeCommand(containerId, [
|
|
39
|
+
'bun',
|
|
40
|
+
'install',
|
|
41
|
+
'--cwd',
|
|
42
|
+
'/app',
|
|
43
|
+
'--no-save',
|
|
44
|
+
'--ignore-scripts',
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
if (installRes.exitCode !== 0) {
|
|
48
|
+
throw new Error(`安裝依賴失敗: ${installRes.stderr}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.log(`[PayloadInjector] 點火!`)
|
|
52
|
+
|
|
53
|
+
// 真正啟動應用程式
|
|
54
|
+
this.docker.executeCommand(containerId, ['bun', 'run', '/app/examples/demo.ts']).catch((e) => {
|
|
55
|
+
console.error(`[PayloadInjector] 運行異常:`, e)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
rocket.ignite()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { IDockerAdapter, IRocketRepository, IRouterAdapter } from '../Domain/Interfaces'
|
|
2
|
+
import type { Mission } from '../Domain/Mission'
|
|
3
|
+
import { Rocket } from '../Domain/Rocket'
|
|
4
|
+
import type { RefurbishUnit } from './RefurbishUnit'
|
|
5
|
+
|
|
6
|
+
export class PoolManager {
|
|
7
|
+
constructor(
|
|
8
|
+
private dockerAdapter: IDockerAdapter,
|
|
9
|
+
private rocketRepository: IRocketRepository,
|
|
10
|
+
private refurbishUnit?: RefurbishUnit,
|
|
11
|
+
private router?: IRouterAdapter
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 初始化發射場:預先準備指定數量的火箭
|
|
16
|
+
*/
|
|
17
|
+
async warmup(count: number): Promise<void> {
|
|
18
|
+
const currentRockets = await this.rocketRepository.findAll()
|
|
19
|
+
const needed = count - currentRockets.length
|
|
20
|
+
|
|
21
|
+
if (needed <= 0) {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log(`[LaunchPad] 正在熱機,準備發射 ${needed} 架新火箭...`)
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < needed; i++) {
|
|
28
|
+
const containerId = await this.dockerAdapter.createBaseContainer()
|
|
29
|
+
const rocketId = `rocket-${Math.random().toString(36).substring(2, 9)}`
|
|
30
|
+
const rocket = new Rocket(rocketId, containerId)
|
|
31
|
+
await this.rocketRepository.save(rocket)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 獲取一架可用的火箭並分配任務
|
|
37
|
+
*/
|
|
38
|
+
async assignMission(mission: Mission): Promise<Rocket> {
|
|
39
|
+
let rocket = await this.rocketRepository.findIdle()
|
|
40
|
+
|
|
41
|
+
// 如果池子空了,動態建立一架(實務上應該報警或等待,這裡先簡化為動態建立)
|
|
42
|
+
if (!rocket) {
|
|
43
|
+
console.log(`[LaunchPad] 資源吃緊,正在緊急呼叫後援火箭...`)
|
|
44
|
+
const containerId = await this.dockerAdapter.createBaseContainer()
|
|
45
|
+
rocket = new Rocket(`rocket-dynamic-${Date.now()}`, containerId)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
rocket.assignMission(mission)
|
|
49
|
+
await this.rocketRepository.save(rocket)
|
|
50
|
+
|
|
51
|
+
return rocket
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 回收指定任務的火箭
|
|
56
|
+
*/
|
|
57
|
+
async recycle(missionId: string): Promise<void> {
|
|
58
|
+
const allRockets = await this.rocketRepository.findAll()
|
|
59
|
+
const rocket = allRockets.find((r) => r.currentMission?.id === missionId)
|
|
60
|
+
|
|
61
|
+
if (!rocket) {
|
|
62
|
+
console.warn(`[LaunchPad] 找不到屬於任務 ${missionId} 的火箭`)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// [New] 註銷域名映射
|
|
67
|
+
if (this.router && rocket.assignedDomain) {
|
|
68
|
+
this.router.unregister(rocket.assignedDomain)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (this.refurbishUnit) {
|
|
72
|
+
await this.refurbishUnit.refurbish(rocket)
|
|
73
|
+
} else {
|
|
74
|
+
// 回退邏輯:如果沒有提供回收單元,則直接標記完成
|
|
75
|
+
rocket.splashDown()
|
|
76
|
+
rocket.finishRefurbishment()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await this.rocketRepository.save(rocket)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { IDockerAdapter } from '../Domain/Interfaces'
|
|
2
|
+
import type { Rocket } from '../Domain/Rocket'
|
|
3
|
+
|
|
4
|
+
export class RefurbishUnit {
|
|
5
|
+
constructor(private docker: IDockerAdapter) {}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 執行火箭翻新邏輯
|
|
9
|
+
*/
|
|
10
|
+
async refurbish(rocket: Rocket): Promise<void> {
|
|
11
|
+
console.log(`[RefurbishUnit] 正在翻新火箭: ${rocket.id} (容器: ${rocket.containerId})`)
|
|
12
|
+
|
|
13
|
+
// 1. 進入狀態機的回收階段
|
|
14
|
+
rocket.splashDown()
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// 2. 執行深度清理指令
|
|
18
|
+
// - 刪除 /app 目錄 (注入的代碼)
|
|
19
|
+
// - 殺掉所有除了 tail 以外的 bun 進程
|
|
20
|
+
// - 清理暫存檔
|
|
21
|
+
const cleanupCommands = ['sh', '-c', 'rm -rf /app/* && pkill -f bun || true && rm -rf /tmp/*']
|
|
22
|
+
|
|
23
|
+
const result = await this.docker.executeCommand(rocket.containerId, cleanupCommands)
|
|
24
|
+
|
|
25
|
+
if (result.exitCode !== 0) {
|
|
26
|
+
console.error(`[RefurbishUnit] 清理失敗: ${result.stderr}`)
|
|
27
|
+
// 這裡可以選擇將火箭標記為 DECOMMISSIONED
|
|
28
|
+
rocket.decommission()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 3. 翻新完成,回歸池中
|
|
33
|
+
rocket.finishRefurbishment()
|
|
34
|
+
console.log(`[RefurbishUnit] 火箭 ${rocket.id} 翻新完成,已進入 IDLE 狀態。`)
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.error(`[RefurbishUnit] 回收過程發生異常:`, error)
|
|
37
|
+
rocket.decommission()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DomainEvent } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export class MissionAssigned extends DomainEvent {
|
|
4
|
+
constructor(
|
|
5
|
+
public readonly rocketId: string,
|
|
6
|
+
public readonly missionId: string
|
|
7
|
+
) {
|
|
8
|
+
super()
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class RocketIgnited extends DomainEvent {
|
|
13
|
+
constructor(public readonly rocketId: string) {
|
|
14
|
+
super()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class RocketSplashedDown extends DomainEvent {
|
|
19
|
+
constructor(public readonly rocketId: string) {
|
|
20
|
+
super()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class RefurbishmentCompleted extends DomainEvent {
|
|
25
|
+
constructor(public readonly rocketId: string) {
|
|
26
|
+
super()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Rocket } from './Rocket'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 負責與底層容器引擎(如 Docker)溝通的轉接器介面
|
|
5
|
+
*/
|
|
6
|
+
export interface IDockerAdapter {
|
|
7
|
+
createBaseContainer(): Promise<string>
|
|
8
|
+
copyFiles(containerId: string, sourcePath: string, targetPath: string): Promise<void>
|
|
9
|
+
executeCommand(
|
|
10
|
+
containerId: string,
|
|
11
|
+
command: string[]
|
|
12
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 獲取容器映射到宿主機的真實端口
|
|
16
|
+
*/
|
|
17
|
+
getExposedPort(containerId: string, containerPort?: number): Promise<number>
|
|
18
|
+
|
|
19
|
+
removeContainer(containerId: string): Promise<void>
|
|
20
|
+
/**
|
|
21
|
+
* 根據 Label 批量移除容器
|
|
22
|
+
*/
|
|
23
|
+
removeContainerByLabel(label: string): Promise<void>
|
|
24
|
+
|
|
25
|
+
streamLogs(containerId: string, onData: (data: string) => void): void
|
|
26
|
+
getStats(containerId: string): Promise<{ cpu: string; memory: string }>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 負責代碼獲取
|
|
31
|
+
*/
|
|
32
|
+
export interface IGitAdapter {
|
|
33
|
+
clone(repoUrl: string, branch: string): Promise<string>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 負責動態反向代理與域名路由的轉接器
|
|
38
|
+
*/
|
|
39
|
+
export interface IRouterAdapter {
|
|
40
|
+
register(domain: string, targetUrl: string): void
|
|
41
|
+
unregister(domain: string): void
|
|
42
|
+
start(port: number): void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 負責與 GitHub API 互動的轉接器
|
|
47
|
+
*/
|
|
48
|
+
export interface IGitHubAdapter {
|
|
49
|
+
verifySignature(payload: string, signature: string, secret: string): boolean
|
|
50
|
+
postComment(repoOwner: string, repoName: string, prNumber: number, comment: string): Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 負責持久化火箭狀態的儲存庫介面
|
|
55
|
+
*/
|
|
56
|
+
export interface IRocketRepository {
|
|
57
|
+
save(rocket: Rocket): Promise<void>
|
|
58
|
+
findById(id: string): Promise<Rocket | null>
|
|
59
|
+
findIdle(): Promise<Rocket | null>
|
|
60
|
+
findAll(): Promise<Rocket[]>
|
|
61
|
+
delete(id: string): Promise<void>
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ValueObject } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
interface MissionProps {
|
|
4
|
+
id: string // PR ID
|
|
5
|
+
repoUrl: string // Git Repo URL
|
|
6
|
+
branch: string // Branch Name
|
|
7
|
+
commitSha: string // Commit Hash
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class Mission extends ValueObject<MissionProps> {
|
|
11
|
+
get id() {
|
|
12
|
+
return this.props.id
|
|
13
|
+
}
|
|
14
|
+
get repoUrl() {
|
|
15
|
+
return this.props.repoUrl
|
|
16
|
+
}
|
|
17
|
+
get branch() {
|
|
18
|
+
return this.props.branch
|
|
19
|
+
}
|
|
20
|
+
get commitSha() {
|
|
21
|
+
return this.props.commitSha
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static create(props: MissionProps): Mission {
|
|
25
|
+
return new Mission(props)
|
|
26
|
+
}
|
|
27
|
+
}
|