@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,116 @@
|
|
|
1
|
+
import { AggregateRoot } from '@gravito/enterprise'
|
|
2
|
+
import {
|
|
3
|
+
MissionAssigned,
|
|
4
|
+
RefurbishmentCompleted,
|
|
5
|
+
RocketIgnited,
|
|
6
|
+
RocketSplashedDown,
|
|
7
|
+
} from './Events'
|
|
8
|
+
import type { Mission } from './Mission'
|
|
9
|
+
import { RocketStatus } from './RocketStatus'
|
|
10
|
+
|
|
11
|
+
export class Rocket extends AggregateRoot<string> {
|
|
12
|
+
private _status: RocketStatus = RocketStatus.IDLE
|
|
13
|
+
private _currentMission: Mission | null = null
|
|
14
|
+
private _containerId: string
|
|
15
|
+
private _assignedDomain: string | null = null
|
|
16
|
+
|
|
17
|
+
constructor(id: string, containerId: string) {
|
|
18
|
+
super(id)
|
|
19
|
+
this._containerId = containerId
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get status() {
|
|
23
|
+
return this._status
|
|
24
|
+
}
|
|
25
|
+
get currentMission() {
|
|
26
|
+
return this._currentMission
|
|
27
|
+
}
|
|
28
|
+
get containerId() {
|
|
29
|
+
return this._containerId
|
|
30
|
+
}
|
|
31
|
+
get assignedDomain() {
|
|
32
|
+
return this._assignedDomain
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 分配域名
|
|
37
|
+
*/
|
|
38
|
+
public assignDomain(domain: string): void {
|
|
39
|
+
this._assignedDomain = domain
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 分配任務 (指派任務給 IDLE 的火箭)
|
|
44
|
+
*/
|
|
45
|
+
public assignMission(mission: Mission): void {
|
|
46
|
+
if (this._status !== RocketStatus.IDLE) {
|
|
47
|
+
throw new Error(`無法指派任務:火箭 ${this.id} 狀態為 ${this._status},非 IDLE`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this._status = RocketStatus.PREPARING
|
|
51
|
+
this._currentMission = mission
|
|
52
|
+
this.addDomainEvent(new MissionAssigned(this.id, mission.id))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 點火啟動 (應用程式啟動成功)
|
|
57
|
+
*/
|
|
58
|
+
public ignite(): void {
|
|
59
|
+
if (this._status !== RocketStatus.PREPARING) {
|
|
60
|
+
throw new Error(`無法點火:火箭 ${this.id} 尚未進入 PREPARING 狀態`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this._status = RocketStatus.ORBITING
|
|
64
|
+
this.addDomainEvent(new RocketIgnited(this.id))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 任務降落 (PR 合併或關閉,暫停服務)
|
|
69
|
+
*/
|
|
70
|
+
public splashDown(): void {
|
|
71
|
+
if (this._status !== RocketStatus.ORBITING) {
|
|
72
|
+
throw new Error(`無法降落:火箭 ${this.id} 不在運行軌道上`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this._status = RocketStatus.REFURBISHING
|
|
76
|
+
this.addDomainEvent(new RocketSplashedDown(this.id))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 翻新完成 (清理完畢,回歸池中)
|
|
81
|
+
*/
|
|
82
|
+
public finishRefurbishment(): void {
|
|
83
|
+
if (this._status !== RocketStatus.REFURBISHING) {
|
|
84
|
+
throw new Error(`無法完成翻新:火箭 ${this.id} 不在 REFURBISHING 狀態`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this._status = RocketStatus.IDLE
|
|
88
|
+
this._currentMission = null
|
|
89
|
+
this.addDomainEvent(new RefurbishmentCompleted(this.id))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 火箭除役 (移除容器)
|
|
94
|
+
*/
|
|
95
|
+
public decommission(): void {
|
|
96
|
+
this._status = RocketStatus.DECOMMISSIONED
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
public toJSON() {
|
|
100
|
+
return {
|
|
101
|
+
id: this.id,
|
|
102
|
+
containerId: this.containerId,
|
|
103
|
+
status: this._status,
|
|
104
|
+
currentMission: this._currentMission,
|
|
105
|
+
assignedDomain: this._assignedDomain,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public static fromJSON(data: any): Rocket {
|
|
110
|
+
const rocket = new Rocket(data.id, data.containerId)
|
|
111
|
+
rocket._status = data.status
|
|
112
|
+
rocket._currentMission = data.currentMission
|
|
113
|
+
rocket._assignedDomain = data.assignedDomain
|
|
114
|
+
return rocket
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 火箭生命週期狀態
|
|
3
|
+
*/
|
|
4
|
+
export enum RocketStatus {
|
|
5
|
+
IDLE = 'IDLE', // 待命:容器運行中,無任務
|
|
6
|
+
PREPARING = 'PREPARING', // 裝填:正在注入代碼 (Locked)
|
|
7
|
+
ORBITING = 'ORBITING', // 運行:應用服務中
|
|
8
|
+
REFURBISHING = 'REFURBISHING', // 翻新:正在清理環境
|
|
9
|
+
DECOMMISSIONED = 'DECOMMISSIONED', // 除役:容器已移除
|
|
10
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { IDockerAdapter } from '../../Domain/Interfaces'
|
|
2
|
+
|
|
3
|
+
export class DockerAdapter implements IDockerAdapter {
|
|
4
|
+
private baseImage = 'oven/bun:1.0-slim'
|
|
5
|
+
|
|
6
|
+
async createBaseContainer(): Promise<string> {
|
|
7
|
+
const rocketId = `rocket-${Math.random().toString(36).substring(2, 9)}`
|
|
8
|
+
|
|
9
|
+
const proc = Bun.spawn([
|
|
10
|
+
'docker',
|
|
11
|
+
'run',
|
|
12
|
+
'-d',
|
|
13
|
+
'--name',
|
|
14
|
+
rocketId,
|
|
15
|
+
'--label',
|
|
16
|
+
'gravito-origin=launchpad',
|
|
17
|
+
'-p',
|
|
18
|
+
'3000', // 讓 Docker 分配隨機宿主機埠
|
|
19
|
+
'-v',
|
|
20
|
+
`${process.env.HOME}/.bun/install/cache:/root/.bun/install/cache`,
|
|
21
|
+
'-v',
|
|
22
|
+
`${process.env.HOME}/.bun/install/cache:/home/bun/.bun/install/cache`,
|
|
23
|
+
this.baseImage,
|
|
24
|
+
'tail',
|
|
25
|
+
'-f',
|
|
26
|
+
'/dev/null',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const stdout = await new Response(proc.stdout).text()
|
|
30
|
+
const containerId = stdout.trim()
|
|
31
|
+
|
|
32
|
+
if (containerId.length === 64 && /^[0-9a-f]+$/.test(containerId)) {
|
|
33
|
+
return containerId
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (proc.exitCode !== 0) {
|
|
37
|
+
const stderr = await new Response(proc.stderr).text()
|
|
38
|
+
throw new Error(`Docker 容器建立失敗: ${stderr || stdout}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return containerId
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async getExposedPort(containerId: string, containerPort = 3000): Promise<number> {
|
|
45
|
+
const proc = Bun.spawn(['docker', 'port', containerId, containerPort.toString()])
|
|
46
|
+
const stdout = await new Response(proc.stdout).text()
|
|
47
|
+
// 輸出格式可能包含多行,如 0.0.0.0:32768 和 [::]:32768
|
|
48
|
+
// 我們取第一行並提取端口
|
|
49
|
+
const firstLine = stdout.split('\n')[0] ?? ''
|
|
50
|
+
if (!firstLine) {
|
|
51
|
+
throw new Error(`無法獲取容器映射端口: ${stdout}`)
|
|
52
|
+
}
|
|
53
|
+
const match = firstLine.match(/:(\d+)$/)
|
|
54
|
+
if (!match?.[1]) {
|
|
55
|
+
throw new Error(`無法獲取容器映射端口: ${stdout}`)
|
|
56
|
+
}
|
|
57
|
+
return Number.parseInt(match[1], 10)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async copyFiles(containerId: string, sourcePath: string, targetPath: string): Promise<void> {
|
|
61
|
+
const proc = Bun.spawn(['docker', 'cp', sourcePath, `${containerId}:${targetPath}`])
|
|
62
|
+
await proc.exited
|
|
63
|
+
if (proc.exitCode && proc.exitCode !== 0) {
|
|
64
|
+
const stderr = await new Response(proc.stderr).text()
|
|
65
|
+
throw new Error(stderr || 'Docker copy failed')
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async executeCommand(
|
|
70
|
+
containerId: string,
|
|
71
|
+
command: string[]
|
|
72
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
73
|
+
const proc = Bun.spawn(['docker', 'exec', '-u', '0', containerId, ...command])
|
|
74
|
+
const stdout = await new Response(proc.stdout).text()
|
|
75
|
+
const stderr = await new Response(proc.stderr).text()
|
|
76
|
+
await proc.exited
|
|
77
|
+
return { stdout, stderr, exitCode: proc.exitCode ?? -1 }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async removeContainer(containerId: string): Promise<void> {
|
|
81
|
+
await Bun.spawn(['docker', 'rm', '-f', containerId]).exited
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async removeContainerByLabel(label: string): Promise<void> {
|
|
85
|
+
const listProc = Bun.spawn(['docker', 'ps', '-aq', '--filter', `label=${label}`])
|
|
86
|
+
const ids = await new Response(listProc.stdout).text()
|
|
87
|
+
|
|
88
|
+
if (ids.trim()) {
|
|
89
|
+
const idList = ids.trim().split('\n')
|
|
90
|
+
await Bun.spawn(['docker', 'rm', '-f', ...idList]).exited
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
streamLogs(containerId: string, onData: (data: string) => void): void {
|
|
95
|
+
const proc = Bun.spawn(['docker', 'logs', '-f', containerId], {
|
|
96
|
+
stdout: 'pipe',
|
|
97
|
+
stderr: 'pipe',
|
|
98
|
+
})
|
|
99
|
+
const read = async (stream: ReadableStream) => {
|
|
100
|
+
const reader = stream.getReader()
|
|
101
|
+
const decoder = new TextDecoder()
|
|
102
|
+
while (true) {
|
|
103
|
+
const { done, value } = await reader.read()
|
|
104
|
+
if (done) {
|
|
105
|
+
break
|
|
106
|
+
}
|
|
107
|
+
onData(decoder.decode(value))
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
read(proc.stdout)
|
|
111
|
+
read(proc.stderr)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async getStats(containerId: string): Promise<{ cpu: string; memory: string }> {
|
|
115
|
+
const proc = Bun.spawn([
|
|
116
|
+
'docker',
|
|
117
|
+
'stats',
|
|
118
|
+
containerId,
|
|
119
|
+
'--no-stream',
|
|
120
|
+
'--format',
|
|
121
|
+
'{{.CPUPerc}},{{.MemUsage}}',
|
|
122
|
+
])
|
|
123
|
+
const stdout = await new Response(proc.stdout).text()
|
|
124
|
+
const [cpu, memory] = stdout.trim().split(',')
|
|
125
|
+
return { cpu: cpu || '0%', memory: memory || '0B / 0B' }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import type { IGitAdapter } from '../../Domain/Interfaces'
|
|
3
|
+
|
|
4
|
+
export class ShellGitAdapter implements IGitAdapter {
|
|
5
|
+
private baseDir = '/tmp/gravito-launchpad-git'
|
|
6
|
+
|
|
7
|
+
async clone(repoUrl: string, branch: string): Promise<string> {
|
|
8
|
+
const dirName = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
9
|
+
const targetDir = `${this.baseDir}/${dirName}`
|
|
10
|
+
|
|
11
|
+
await mkdir(this.baseDir, { recursive: true })
|
|
12
|
+
|
|
13
|
+
const proc = Bun.spawn(['git', 'clone', '--depth', '1', '--branch', branch, repoUrl, targetDir])
|
|
14
|
+
|
|
15
|
+
await proc.exited
|
|
16
|
+
if (proc.exitCode !== 0) {
|
|
17
|
+
throw new Error('Git Clone Failed')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return targetDir
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { Octokit } from '@octokit/rest'
|
|
3
|
+
import type { IGitHubAdapter } from '../../Domain/Interfaces'
|
|
4
|
+
|
|
5
|
+
export class OctokitGitHubAdapter implements IGitHubAdapter {
|
|
6
|
+
private octokit: Octokit
|
|
7
|
+
|
|
8
|
+
constructor(token?: string) {
|
|
9
|
+
this.octokit = new Octokit({ auth: token })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
verifySignature(payload: string, signature: string, secret: string): boolean {
|
|
13
|
+
if (!signature) {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hmac = createHmac('sha256', secret)
|
|
18
|
+
const digest = Buffer.from(`sha256=${hmac.update(payload).digest('hex')}`, 'utf8')
|
|
19
|
+
const checksum = Buffer.from(signature, 'utf8')
|
|
20
|
+
|
|
21
|
+
return digest.length === checksum.length && timingSafeEqual(digest, checksum)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async postComment(
|
|
25
|
+
repoOwner: string,
|
|
26
|
+
repoName: string,
|
|
27
|
+
prNumber: number,
|
|
28
|
+
comment: string
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
try {
|
|
31
|
+
await this.octokit.issues.createComment({
|
|
32
|
+
owner: repoOwner,
|
|
33
|
+
repo: repoName,
|
|
34
|
+
issue_number: prNumber,
|
|
35
|
+
body: comment,
|
|
36
|
+
})
|
|
37
|
+
console.log(`[GitHub] 已在 PR #${prNumber} 留下部署資訊`)
|
|
38
|
+
} catch (error: any) {
|
|
39
|
+
console.error(`[GitHub] 留言失敗: ${error.message}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CacheService } from 'gravito-core'
|
|
2
|
+
import type { IRocketRepository } from '../../Domain/Interfaces'
|
|
3
|
+
import { Rocket } from '../../Domain/Rocket'
|
|
4
|
+
|
|
5
|
+
export class CachedRocketRepository implements IRocketRepository {
|
|
6
|
+
private CACHE_KEY = 'launchpad:rockets'
|
|
7
|
+
|
|
8
|
+
constructor(private cache: CacheService) {}
|
|
9
|
+
|
|
10
|
+
private async getMap(): Promise<Map<string, any>> {
|
|
11
|
+
const data = await this.cache.get<Record<string, any>>(this.CACHE_KEY)
|
|
12
|
+
return new Map(Object.entries(data || {}))
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async save(rocket: Rocket): Promise<void> {
|
|
16
|
+
const map = await this.getMap()
|
|
17
|
+
map.set(rocket.id, rocket.toJSON())
|
|
18
|
+
await this.cache.set(this.CACHE_KEY, Object.fromEntries(map))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async findById(id: string): Promise<Rocket | null> {
|
|
22
|
+
const map = await this.getMap()
|
|
23
|
+
const data = map.get(id)
|
|
24
|
+
return data ? Rocket.fromJSON(data) : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async findIdle(): Promise<Rocket | null> {
|
|
28
|
+
const rockets = await this.findAll()
|
|
29
|
+
return rockets.find((r) => r.status === 'IDLE') || null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async findAll(): Promise<Rocket[]> {
|
|
33
|
+
const map = await this.getMap()
|
|
34
|
+
return Array.from(map.values()).map((data) => Rocket.fromJSON(data))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async delete(id: string): Promise<void> {
|
|
38
|
+
const map = await this.getMap()
|
|
39
|
+
map.delete(id)
|
|
40
|
+
await this.cache.set(this.CACHE_KEY, Object.fromEntries(map))
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { IRocketRepository } from '../../Domain/Interfaces'
|
|
2
|
+
import type { Rocket } from '../../Domain/Rocket'
|
|
3
|
+
import { RocketStatus } from '../../Domain/RocketStatus'
|
|
4
|
+
|
|
5
|
+
export class InMemoryRocketRepository implements IRocketRepository {
|
|
6
|
+
private rockets = new Map<string, Rocket>()
|
|
7
|
+
|
|
8
|
+
async save(rocket: Rocket): Promise<void> {
|
|
9
|
+
this.rockets.set(rocket.id, rocket)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async findById(id: string): Promise<Rocket | null> {
|
|
13
|
+
return this.rockets.get(id) || null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async findIdle(): Promise<Rocket | null> {
|
|
17
|
+
for (const rocket of this.rockets.values()) {
|
|
18
|
+
if (rocket.status === RocketStatus.IDLE) {
|
|
19
|
+
return rocket
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async findAll(): Promise<Rocket[]> {
|
|
26
|
+
return Array.from(this.rockets.values())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async delete(id: string): Promise<void> {
|
|
30
|
+
this.rockets.delete(id)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IRouterAdapter } from '../../Domain/Interfaces'
|
|
2
|
+
|
|
3
|
+
export class BunProxyAdapter implements IRouterAdapter {
|
|
4
|
+
private routes = new Map<string, string>() // domain -> targetUrl
|
|
5
|
+
|
|
6
|
+
register(domain: string, targetUrl: string): void {
|
|
7
|
+
console.log(`[Proxy] 註冊路由: ${domain} -> ${targetUrl}`)
|
|
8
|
+
this.routes.set(domain.toLowerCase(), targetUrl)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
unregister(domain: string): void {
|
|
12
|
+
console.log(`[Proxy] 註銷路由: ${domain}`)
|
|
13
|
+
this.routes.delete(domain.toLowerCase())
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
start(port: number): void {
|
|
17
|
+
const self = this
|
|
18
|
+
|
|
19
|
+
Bun.serve({
|
|
20
|
+
port,
|
|
21
|
+
async fetch(request) {
|
|
22
|
+
const host = request.headers.get('host')?.split(':')[0]?.toLowerCase()
|
|
23
|
+
|
|
24
|
+
if (!host || !self.routes.has(host)) {
|
|
25
|
+
return new Response('Rocket Not Found or Mission Not Started', { status: 404 })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const targetBase = self.routes.get(host)!
|
|
29
|
+
const url = new URL(request.url)
|
|
30
|
+
const targetUrl = `${targetBase}${url.pathname}${url.search}`
|
|
31
|
+
|
|
32
|
+
console.log(`[Proxy] 轉發: ${host}${url.pathname} -> ${targetUrl}`)
|
|
33
|
+
|
|
34
|
+
// 建立轉發請求,克隆標頭與方法
|
|
35
|
+
try {
|
|
36
|
+
const proxyReq = new Request(targetUrl, {
|
|
37
|
+
method: request.method,
|
|
38
|
+
headers: request.headers,
|
|
39
|
+
body: request.body,
|
|
40
|
+
// @ts-expect-error Bun specific
|
|
41
|
+
duplex: 'half',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
return await fetch(proxyReq)
|
|
45
|
+
} catch (error: any) {
|
|
46
|
+
console.error(`[Proxy] 轉發失敗: ${error.message}`)
|
|
47
|
+
return new Response('Proxy Error', { status: 502 })
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
console.log(`[Proxy] 動態路由伺服器已啟動於 Port: ${port}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { OrbitRipple } from '@gravito/ripple'
|
|
2
|
+
import { OrbitCache } from '@gravito/stasis'
|
|
3
|
+
import { type Container, type GravitoOrbit, PlanetCore, ServiceProvider } from 'gravito-core'
|
|
4
|
+
import { MissionControl } from './Application/MissionControl'
|
|
5
|
+
import { PayloadInjector } from './Application/PayloadInjector'
|
|
6
|
+
import { PoolManager } from './Application/PoolManager'
|
|
7
|
+
import { RefurbishUnit } from './Application/RefurbishUnit'
|
|
8
|
+
import { Mission } from './Domain/Mission'
|
|
9
|
+
import { DockerAdapter } from './Infrastructure/Docker/DockerAdapter'
|
|
10
|
+
import { ShellGitAdapter } from './Infrastructure/Git/ShellGitAdapter'
|
|
11
|
+
import { OctokitGitHubAdapter } from './Infrastructure/GitHub/OctokitGitHubAdapter'
|
|
12
|
+
import { CachedRocketRepository } from './Infrastructure/Persistence/CachedRocketRepository'
|
|
13
|
+
import { BunProxyAdapter } from './Infrastructure/Router/BunProxyAdapter'
|
|
14
|
+
|
|
15
|
+
export * from './Application/MissionControl'
|
|
16
|
+
export * from './Application/PayloadInjector'
|
|
17
|
+
export * from './Application/PoolManager'
|
|
18
|
+
export * from './Application/RefurbishUnit'
|
|
19
|
+
export * from './Domain/Mission'
|
|
20
|
+
export * from './Domain/Rocket'
|
|
21
|
+
export * from './Domain/RocketStatus'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Launchpad 內部的 Service Provider
|
|
25
|
+
*/
|
|
26
|
+
class LaunchpadServiceProvider extends ServiceProvider {
|
|
27
|
+
register(container: Container): void {
|
|
28
|
+
if (!container.has('cache')) {
|
|
29
|
+
const cacheFromServices = this.core?.services?.get('cache')
|
|
30
|
+
if (cacheFromServices) {
|
|
31
|
+
container.instance('cache', cacheFromServices)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
container.singleton('launchpad.docker', () => new DockerAdapter())
|
|
36
|
+
container.singleton('launchpad.git', () => new ShellGitAdapter())
|
|
37
|
+
container.singleton('launchpad.router', () => new BunProxyAdapter())
|
|
38
|
+
container.singleton(
|
|
39
|
+
'launchpad.github',
|
|
40
|
+
() => new OctokitGitHubAdapter(process.env.GITHUB_TOKEN)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
container.singleton('launchpad.repo', () => {
|
|
44
|
+
const cache = container.make<any>('cache')
|
|
45
|
+
return new CachedRocketRepository(cache)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
container.singleton('launchpad.refurbish', () => {
|
|
49
|
+
return new RefurbishUnit(container.make('launchpad.docker'))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
container.singleton('launchpad.pool', () => {
|
|
53
|
+
return new PoolManager(
|
|
54
|
+
container.make('launchpad.docker'),
|
|
55
|
+
container.make('launchpad.repo'),
|
|
56
|
+
container.make('launchpad.refurbish'),
|
|
57
|
+
container.make('launchpad.router')
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
container.singleton('launchpad.injector', () => {
|
|
62
|
+
return new PayloadInjector(
|
|
63
|
+
container.make('launchpad.docker'),
|
|
64
|
+
container.make('launchpad.git')
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
container.singleton('launchpad.ctrl', () => {
|
|
69
|
+
return new MissionControl(
|
|
70
|
+
container.make('launchpad.pool'),
|
|
71
|
+
container.make('launchpad.injector'),
|
|
72
|
+
container.make('launchpad.docker'),
|
|
73
|
+
container.make('launchpad.router')
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override boot(): void {
|
|
79
|
+
const core = this.core
|
|
80
|
+
if (!core) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const logger = core.logger
|
|
84
|
+
const router = core.container.make<BunProxyAdapter>('launchpad.router')
|
|
85
|
+
const docker = core.container.make<DockerAdapter>('launchpad.docker')
|
|
86
|
+
|
|
87
|
+
// 啟動路由代理
|
|
88
|
+
router.start(8080)
|
|
89
|
+
|
|
90
|
+
// [New] 啟動時自動清理殘留容器,維持系統純淨
|
|
91
|
+
logger.info('[Launchpad] 正在掃描並清理殘留資源...')
|
|
92
|
+
docker
|
|
93
|
+
.removeContainerByLabel('gravito-origin=launchpad')
|
|
94
|
+
.then(() => {
|
|
95
|
+
logger.info('[Launchpad] 資源清理完畢,系統就緒。')
|
|
96
|
+
})
|
|
97
|
+
.catch((err) => {
|
|
98
|
+
logger.warn('[Launchpad] 自動清理失敗 (可能是環境無容器):', err.message)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Gravito Launchpad Orbit
|
|
105
|
+
*/
|
|
106
|
+
export class LaunchpadOrbit implements GravitoOrbit {
|
|
107
|
+
constructor(private ripple: OrbitRipple) {}
|
|
108
|
+
|
|
109
|
+
async install(core: PlanetCore): Promise<void> {
|
|
110
|
+
core.register(new LaunchpadServiceProvider())
|
|
111
|
+
|
|
112
|
+
core.router.post('/launch', async (c) => {
|
|
113
|
+
const rawBody = await c.req.text()
|
|
114
|
+
const signature = c.req.header('x-hub-signature-256') || ''
|
|
115
|
+
const secret = process.env.GITHUB_WEBHOOK_SECRET
|
|
116
|
+
|
|
117
|
+
const github = core.container.make<OctokitGitHubAdapter>('launchpad.github')
|
|
118
|
+
|
|
119
|
+
if (secret && !github.verifySignature(rawBody, signature, secret)) {
|
|
120
|
+
core.logger.error('[GitHub] Webhook signature verification failed')
|
|
121
|
+
return c.json({ error: 'Invalid signature' }, 401)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const body = JSON.parse(rawBody)
|
|
125
|
+
const event = c.req.header('x-github-event')
|
|
126
|
+
|
|
127
|
+
if (event === 'pull_request') {
|
|
128
|
+
const action = body.action
|
|
129
|
+
const pr = body.pull_request
|
|
130
|
+
const missionId = `pr-${pr.number}`
|
|
131
|
+
|
|
132
|
+
if (['opened', 'synchronize', 'reopened'].includes(action)) {
|
|
133
|
+
const mission = Mission.create({
|
|
134
|
+
id: missionId,
|
|
135
|
+
repoUrl: pr.base.repo.clone_url,
|
|
136
|
+
branch: pr.head.ref,
|
|
137
|
+
commitSha: pr.head.sha,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const ctrl = core.container.make<MissionControl>('launchpad.ctrl')
|
|
141
|
+
const rocketId = await ctrl.launch(mission, (type, data) => {
|
|
142
|
+
// 修正:使用正確的 broadcast 方法
|
|
143
|
+
this.ripple.getServer().broadcast('telemetry', 'telemetry.data', { type, data })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
if (action === 'opened' || action === 'reopened') {
|
|
147
|
+
const previewUrl = `http://${missionId}.dev.local:8080`
|
|
148
|
+
const dashboardUrl = `http://${c.req.header('host')?.split(':')[0]}:5173`
|
|
149
|
+
|
|
150
|
+
const comment =
|
|
151
|
+
`🚀 **Gravito Launchpad: Deployment Ready!**\n\n` +
|
|
152
|
+
`- **Preview URL**: [${previewUrl}](${previewUrl})\n` +
|
|
153
|
+
`- **Mission Dashboard**: [${dashboardUrl}](${dashboardUrl})\n\n` +
|
|
154
|
+
`*Rocket ID: ${rocketId} | Commit: ${pr.head.sha.slice(0, 7)}*`
|
|
155
|
+
|
|
156
|
+
await github.postComment(
|
|
157
|
+
pr.base.repo.owner.login,
|
|
158
|
+
pr.base.repo.name,
|
|
159
|
+
pr.number,
|
|
160
|
+
comment
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return c.json({ success: true, missionId, rocketId })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (action === 'closed') {
|
|
168
|
+
const pool = core.container.make<PoolManager>('launchpad.pool')
|
|
169
|
+
await pool.recycle(missionId)
|
|
170
|
+
return c.json({ success: true, action: 'recycled' })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return c.json({ success: true, message: 'Event ignored' })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
core.router.post('/recycle', async (c) => {
|
|
178
|
+
const body = (await c.req.json()) as any
|
|
179
|
+
if (!body.missionId) {
|
|
180
|
+
return c.json({ error: 'missionId required' }, 400)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const pool = core.container.make<PoolManager>('launchpad.pool')
|
|
184
|
+
await pool.recycle(body.missionId)
|
|
185
|
+
return c.json({ success: true })
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 一鍵啟動 Launchpad 應用程式
|
|
192
|
+
*/
|
|
193
|
+
export async function bootstrapLaunchpad() {
|
|
194
|
+
const ripple = new OrbitRipple({ path: '/ws' })
|
|
195
|
+
|
|
196
|
+
const core = await PlanetCore.boot({
|
|
197
|
+
config: {
|
|
198
|
+
APP_NAME: 'Gravito Launchpad',
|
|
199
|
+
PORT: 4000,
|
|
200
|
+
CACHE_DRIVER: 'file',
|
|
201
|
+
},
|
|
202
|
+
orbits: [
|
|
203
|
+
new OrbitCache(),
|
|
204
|
+
ripple,
|
|
205
|
+
new LaunchpadOrbit(ripple), // 傳入實例
|
|
206
|
+
],
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
await core.bootstrap()
|
|
210
|
+
|
|
211
|
+
const liftoffConfig = core.liftoff()
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
port: liftoffConfig.port,
|
|
215
|
+
websocket: ripple.getHandler(),
|
|
216
|
+
fetch: (req: Request, server: any) => {
|
|
217
|
+
if (ripple.getServer().upgrade(req, server)) {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
return liftoffConfig.fetch(req, server)
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|