@gravito/launchpad 0.1.0 → 1.0.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/CHANGELOG.md +11 -0
- package/debug-launch.ts +7 -4
- package/dist/index.d.mts +188 -0
- package/dist/index.d.ts +188 -0
- package/dist/index.js +818 -0
- package/dist/index.mjs +785 -0
- package/package.json +13 -4
- package/server.ts +3 -1
- package/src/Application/PayloadInjector.ts +24 -3
- package/src/Infrastructure/Docker/DockerAdapter.ts +29 -23
- package/src/Infrastructure/Git/ShellGitAdapter.ts +14 -3
- package/src/Infrastructure/Persistence/CachedRocketRepository.ts +1 -1
- package/src/Infrastructure/Router/BunProxyAdapter.ts +3 -1
- package/src/index.ts +1 -1
- package/tests/Integration.test.ts +1 -1
- package/tests/Rocket.test.ts +35 -0
- package/tests/docker-adapter.test.ts +100 -2
- package/tests/payload-injector.test.ts +73 -0
- package/tsconfig.json +26 -7
- package/.turbo/turbo-test.log +0 -82
- package/.turbo/turbo-typecheck.log +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/launchpad",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Container lifecycle management system for flash deployments",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -10,17 +10,26 @@
|
|
|
10
10
|
"test": "bun test",
|
|
11
11
|
"test:coverage": "bun test --coverage --coverage-threshold=80",
|
|
12
12
|
"test:ci": "bun test --coverage --coverage-threshold=80",
|
|
13
|
-
"typecheck": "tsc --noEmit"
|
|
13
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@gravito/enterprise": "workspace:*",
|
|
17
17
|
"@gravito/ripple": "workspace:*",
|
|
18
18
|
"@gravito/stasis": "workspace:*",
|
|
19
19
|
"@octokit/rest": "^22.0.1",
|
|
20
|
-
"gravito
|
|
20
|
+
"@gravito/core": "workspace:*"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
+
"bun-types": "^1.3.5",
|
|
23
24
|
"tsup": "^8.0.0",
|
|
24
|
-
"typescript": "^5.
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
33
|
+
"directory": "packages/launchpad"
|
|
25
34
|
}
|
|
26
35
|
}
|
package/server.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { getRuntimeAdapter } from '@gravito/core'
|
|
1
2
|
import { bootstrapLaunchpad } from './src/index'
|
|
2
3
|
|
|
3
4
|
const config = await bootstrapLaunchpad()
|
|
4
|
-
|
|
5
|
+
const runtime = getRuntimeAdapter()
|
|
6
|
+
runtime.serve(config)
|
|
5
7
|
|
|
6
8
|
console.log(`🚀 Launchpad Command Center active at: http://localhost:${config.port}`)
|
|
7
9
|
console.log(`📡 Telemetry WebSocket channel: ws://localhost:${config.port}/ws`)
|
|
@@ -23,8 +23,11 @@ export class PayloadInjector {
|
|
|
23
23
|
|
|
24
24
|
console.log(`[PayloadInjector] 正在安裝依賴...`)
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
const
|
|
26
|
+
const registry = process.env.LAUNCHPAD_NPM_REGISTRY || process.env.NPM_CONFIG_REGISTRY || ''
|
|
27
|
+
const baseInstallConfig = ['[install]', 'frozenLockfile = false']
|
|
28
|
+
const bunfigContent = registry
|
|
29
|
+
? `${baseInstallConfig.join('\n')}\nregistry = "${registry}"\n`
|
|
30
|
+
: `${baseInstallConfig.join('\n')}\n`
|
|
28
31
|
await this.docker.executeCommand(containerId, [
|
|
29
32
|
'sh',
|
|
30
33
|
'-c',
|
|
@@ -35,7 +38,7 @@ export class PayloadInjector {
|
|
|
35
38
|
await this.docker.executeCommand(containerId, ['rm', '-f', '/app/bun.lockb'])
|
|
36
39
|
|
|
37
40
|
// 安裝 (跳過腳本以避免編譯原生模組失敗)
|
|
38
|
-
|
|
41
|
+
let installRes = await this.docker.executeCommand(containerId, [
|
|
39
42
|
'bun',
|
|
40
43
|
'install',
|
|
41
44
|
'--cwd',
|
|
@@ -44,6 +47,24 @@ export class PayloadInjector {
|
|
|
44
47
|
'--ignore-scripts',
|
|
45
48
|
])
|
|
46
49
|
|
|
50
|
+
if (installRes.exitCode !== 0 && !registry) {
|
|
51
|
+
const fallbackRegistry = 'https://registry.npmmirror.com'
|
|
52
|
+
const fallbackBunfig = `${baseInstallConfig.join('\n')}\nregistry = "${fallbackRegistry}"\n`
|
|
53
|
+
await this.docker.executeCommand(containerId, [
|
|
54
|
+
'sh',
|
|
55
|
+
'-c',
|
|
56
|
+
`echo "${fallbackBunfig}" > /app/bunfig.toml`,
|
|
57
|
+
])
|
|
58
|
+
installRes = await this.docker.executeCommand(containerId, [
|
|
59
|
+
'bun',
|
|
60
|
+
'install',
|
|
61
|
+
'--cwd',
|
|
62
|
+
'/app',
|
|
63
|
+
'--no-save',
|
|
64
|
+
'--ignore-scripts',
|
|
65
|
+
])
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
if (installRes.exitCode !== 0) {
|
|
48
69
|
throw new Error(`安裝依賴失敗: ${installRes.stderr}`)
|
|
49
70
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { getRuntimeAdapter } from '@gravito/core'
|
|
1
2
|
import type { IDockerAdapter } from '../../Domain/Interfaces'
|
|
2
3
|
|
|
3
4
|
export class DockerAdapter implements IDockerAdapter {
|
|
4
5
|
private baseImage = 'oven/bun:1.0-slim'
|
|
6
|
+
private runtime = getRuntimeAdapter()
|
|
5
7
|
|
|
6
8
|
async createBaseContainer(): Promise<string> {
|
|
7
9
|
const rocketId = `rocket-${Math.random().toString(36).substring(2, 9)}`
|
|
8
10
|
|
|
9
|
-
const proc =
|
|
11
|
+
const proc = this.runtime.spawn([
|
|
10
12
|
'docker',
|
|
11
13
|
'run',
|
|
12
14
|
'-d',
|
|
@@ -26,15 +28,16 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
26
28
|
'/dev/null',
|
|
27
29
|
])
|
|
28
30
|
|
|
29
|
-
const stdout = await new Response(proc.stdout).text()
|
|
31
|
+
const stdout = await new Response(proc.stdout ?? null).text()
|
|
30
32
|
const containerId = stdout.trim()
|
|
33
|
+
const exitCode = await proc.exited
|
|
31
34
|
|
|
32
35
|
if (containerId.length === 64 && /^[0-9a-f]+$/.test(containerId)) {
|
|
33
36
|
return containerId
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
if (
|
|
37
|
-
const stderr = await new Response(proc.stderr).text()
|
|
39
|
+
if (exitCode !== 0) {
|
|
40
|
+
const stderr = await new Response(proc.stderr ?? null).text()
|
|
38
41
|
throw new Error(`Docker 容器建立失敗: ${stderr || stdout}`)
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -42,8 +45,8 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
async getExposedPort(containerId: string, containerPort = 3000): Promise<number> {
|
|
45
|
-
const proc =
|
|
46
|
-
const stdout = await new Response(proc.stdout).text()
|
|
48
|
+
const proc = this.runtime.spawn(['docker', 'port', containerId, containerPort.toString()])
|
|
49
|
+
const stdout = await new Response(proc.stdout ?? null).text()
|
|
47
50
|
// 輸出格式可能包含多行,如 0.0.0.0:32768 和 [::]:32768
|
|
48
51
|
// 我們取第一行並提取端口
|
|
49
52
|
const firstLine = stdout.split('\n')[0] ?? ''
|
|
@@ -58,10 +61,10 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
async copyFiles(containerId: string, sourcePath: string, targetPath: string): Promise<void> {
|
|
61
|
-
const proc =
|
|
62
|
-
await proc.exited
|
|
63
|
-
if (
|
|
64
|
-
const stderr = await new Response(proc.stderr).text()
|
|
64
|
+
const proc = this.runtime.spawn(['docker', 'cp', sourcePath, `${containerId}:${targetPath}`])
|
|
65
|
+
const exitCode = await proc.exited
|
|
66
|
+
if (exitCode && exitCode !== 0) {
|
|
67
|
+
const stderr = await new Response(proc.stderr ?? null).text()
|
|
65
68
|
throw new Error(stderr || 'Docker copy failed')
|
|
66
69
|
}
|
|
67
70
|
}
|
|
@@ -70,33 +73,36 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
70
73
|
containerId: string,
|
|
71
74
|
command: string[]
|
|
72
75
|
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
73
|
-
const proc =
|
|
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
|
|
76
|
+
const proc = this.runtime.spawn(['docker', 'exec', '-u', '0', containerId, ...command])
|
|
77
|
+
const stdout = await new Response(proc.stdout ?? null).text()
|
|
78
|
+
const stderr = await new Response(proc.stderr ?? null).text()
|
|
79
|
+
const exitCode = await proc.exited
|
|
80
|
+
return { stdout, stderr, exitCode }
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
async removeContainer(containerId: string): Promise<void> {
|
|
81
|
-
await
|
|
84
|
+
await this.runtime.spawn(['docker', 'rm', '-f', containerId]).exited
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
async removeContainerByLabel(label: string): Promise<void> {
|
|
85
|
-
const listProc =
|
|
86
|
-
const ids = await new Response(listProc.stdout).text()
|
|
88
|
+
const listProc = this.runtime.spawn(['docker', 'ps', '-aq', '--filter', `label=${label}`])
|
|
89
|
+
const ids = await new Response(listProc.stdout ?? null).text()
|
|
87
90
|
|
|
88
91
|
if (ids.trim()) {
|
|
89
92
|
const idList = ids.trim().split('\n')
|
|
90
|
-
await
|
|
93
|
+
await this.runtime.spawn(['docker', 'rm', '-f', ...idList]).exited
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
streamLogs(containerId: string, onData: (data: string) => void): void {
|
|
95
|
-
const proc =
|
|
98
|
+
const proc = this.runtime.spawn(['docker', 'logs', '-f', containerId], {
|
|
96
99
|
stdout: 'pipe',
|
|
97
100
|
stderr: 'pipe',
|
|
98
101
|
})
|
|
99
|
-
const read = async (stream
|
|
102
|
+
const read = async (stream?: ReadableStream | null) => {
|
|
103
|
+
if (!stream) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
100
106
|
const reader = stream.getReader()
|
|
101
107
|
const decoder = new TextDecoder()
|
|
102
108
|
while (true) {
|
|
@@ -112,7 +118,7 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
112
118
|
}
|
|
113
119
|
|
|
114
120
|
async getStats(containerId: string): Promise<{ cpu: string; memory: string }> {
|
|
115
|
-
const proc =
|
|
121
|
+
const proc = this.runtime.spawn([
|
|
116
122
|
'docker',
|
|
117
123
|
'stats',
|
|
118
124
|
containerId,
|
|
@@ -120,7 +126,7 @@ export class DockerAdapter implements IDockerAdapter {
|
|
|
120
126
|
'--format',
|
|
121
127
|
'{{.CPUPerc}},{{.MemUsage}}',
|
|
122
128
|
])
|
|
123
|
-
const stdout = await new Response(proc.stdout).text()
|
|
129
|
+
const stdout = await new Response(proc.stdout ?? null).text()
|
|
124
130
|
const [cpu, memory] = stdout.trim().split(',')
|
|
125
131
|
return { cpu: cpu || '0%', memory: memory || '0B / 0B' }
|
|
126
132
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
|
+
import { getRuntimeAdapter } from '@gravito/core'
|
|
2
3
|
import type { IGitAdapter } from '../../Domain/Interfaces'
|
|
3
4
|
|
|
4
5
|
export class ShellGitAdapter implements IGitAdapter {
|
|
@@ -10,10 +11,20 @@ export class ShellGitAdapter implements IGitAdapter {
|
|
|
10
11
|
|
|
11
12
|
await mkdir(this.baseDir, { recursive: true })
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
+
const runtime = getRuntimeAdapter()
|
|
15
|
+
const proc = runtime.spawn([
|
|
16
|
+
'git',
|
|
17
|
+
'clone',
|
|
18
|
+
'--depth',
|
|
19
|
+
'1',
|
|
20
|
+
'--branch',
|
|
21
|
+
branch,
|
|
22
|
+
repoUrl,
|
|
23
|
+
targetDir,
|
|
24
|
+
])
|
|
14
25
|
|
|
15
|
-
await proc.exited
|
|
16
|
-
if (
|
|
26
|
+
const exitCode = await proc.exited
|
|
27
|
+
if (exitCode !== 0) {
|
|
17
28
|
throw new Error('Git Clone Failed')
|
|
18
29
|
}
|
|
19
30
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getRuntimeAdapter } from '@gravito/core'
|
|
1
2
|
import type { IRouterAdapter } from '../../Domain/Interfaces'
|
|
2
3
|
|
|
3
4
|
export class BunProxyAdapter implements IRouterAdapter {
|
|
@@ -15,8 +16,9 @@ export class BunProxyAdapter implements IRouterAdapter {
|
|
|
15
16
|
|
|
16
17
|
start(port: number): void {
|
|
17
18
|
const self = this
|
|
19
|
+
const runtime = getRuntimeAdapter()
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
runtime.serve({
|
|
20
22
|
port,
|
|
21
23
|
async fetch(request) {
|
|
22
24
|
const host = request.headers.get('host')?.split(':')[0]?.toLowerCase()
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { type Container, type GravitoOrbit, PlanetCore, ServiceProvider } from '@gravito/core'
|
|
1
2
|
import { OrbitRipple } from '@gravito/ripple'
|
|
2
3
|
import { OrbitCache } from '@gravito/stasis'
|
|
3
|
-
import { type Container, type GravitoOrbit, PlanetCore, ServiceProvider } from 'gravito-core'
|
|
4
4
|
import { MissionControl } from './Application/MissionControl'
|
|
5
5
|
import { PayloadInjector } from './Application/PayloadInjector'
|
|
6
6
|
import { PoolManager } from './Application/PoolManager'
|
package/tests/Rocket.test.ts
CHANGED
|
@@ -46,4 +46,39 @@ describe('Rocket', () => {
|
|
|
46
46
|
rocket.decommission()
|
|
47
47
|
expect(rocket.status).toBe(RocketStatus.DECOMMISSIONED)
|
|
48
48
|
})
|
|
49
|
+
|
|
50
|
+
test('assigns domain and serializes/deserializes', () => {
|
|
51
|
+
const mission = Mission.create({
|
|
52
|
+
id: 'mission-2',
|
|
53
|
+
repoUrl: 'https://example.com/repo.git',
|
|
54
|
+
branch: 'main',
|
|
55
|
+
commitSha: 'def456',
|
|
56
|
+
})
|
|
57
|
+
const rocket = new Rocket('rocket-3', 'container-3')
|
|
58
|
+
|
|
59
|
+
rocket.assignDomain('rocket-3.dev.local')
|
|
60
|
+
rocket.assignMission(mission)
|
|
61
|
+
|
|
62
|
+
const snapshot = rocket.toJSON()
|
|
63
|
+
const restored = Rocket.fromJSON(snapshot)
|
|
64
|
+
|
|
65
|
+
expect(rocket.containerId).toBe('container-3')
|
|
66
|
+
expect(rocket.assignedDomain).toBe('rocket-3.dev.local')
|
|
67
|
+
expect(restored.status).toBe(RocketStatus.PREPARING)
|
|
68
|
+
expect(restored.currentMission).toBe(mission)
|
|
69
|
+
expect(restored.assignedDomain).toBe('rocket-3.dev.local')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('rejects assigning mission when not idle', () => {
|
|
73
|
+
const mission = Mission.create({
|
|
74
|
+
id: 'mission-3',
|
|
75
|
+
repoUrl: 'https://example.com/repo.git',
|
|
76
|
+
branch: 'main',
|
|
77
|
+
commitSha: 'ghi789',
|
|
78
|
+
})
|
|
79
|
+
const rocket = new Rocket('rocket-4', 'container-4')
|
|
80
|
+
|
|
81
|
+
rocket.assignMission(mission)
|
|
82
|
+
expect(() => rocket.assignMission(mission)).toThrow('非 IDLE')
|
|
83
|
+
})
|
|
49
84
|
})
|
|
@@ -8,7 +8,7 @@ function makeProcess(stdoutText: string, stderrText: string, exitCode = 0) {
|
|
|
8
8
|
stdout: makeStream(stdoutText),
|
|
9
9
|
stderr: makeStream(stderrText),
|
|
10
10
|
exitCode,
|
|
11
|
-
exited: Promise.resolve(),
|
|
11
|
+
exited: Promise.resolve(exitCode),
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -28,6 +28,20 @@ describe('DockerAdapter', () => {
|
|
|
28
28
|
}
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
+
test('returns container id even when exit code is zero but id is invalid', async () => {
|
|
32
|
+
const adapter = new DockerAdapter()
|
|
33
|
+
const originalSpawn = Bun.spawn
|
|
34
|
+
|
|
35
|
+
Bun.spawn = () => makeProcess('short-id', '', 0) as any
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await adapter.createBaseContainer()
|
|
39
|
+
expect(result).toBe('short-id')
|
|
40
|
+
} finally {
|
|
41
|
+
Bun.spawn = originalSpawn
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
31
45
|
test('throws when container creation fails', async () => {
|
|
32
46
|
const adapter = new DockerAdapter()
|
|
33
47
|
const originalSpawn = Bun.spawn
|
|
@@ -41,6 +55,46 @@ describe('DockerAdapter', () => {
|
|
|
41
55
|
}
|
|
42
56
|
})
|
|
43
57
|
|
|
58
|
+
test('getExposedPort parses the first line', async () => {
|
|
59
|
+
const adapter = new DockerAdapter()
|
|
60
|
+
const originalSpawn = Bun.spawn
|
|
61
|
+
|
|
62
|
+
Bun.spawn = () => makeProcess('0.0.0.0:32768\n[::]:32768\n', '', 0) as any
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const port = await adapter.getExposedPort('cid')
|
|
66
|
+
expect(port).toBe(32768)
|
|
67
|
+
} finally {
|
|
68
|
+
Bun.spawn = originalSpawn
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('getExposedPort throws on empty output', async () => {
|
|
73
|
+
const adapter = new DockerAdapter()
|
|
74
|
+
const originalSpawn = Bun.spawn
|
|
75
|
+
|
|
76
|
+
Bun.spawn = () => makeProcess('', '', 0) as any
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await expect(adapter.getExposedPort('cid')).rejects.toThrow('無法獲取容器映射端口')
|
|
80
|
+
} finally {
|
|
81
|
+
Bun.spawn = originalSpawn
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('getExposedPort throws on invalid output', async () => {
|
|
86
|
+
const adapter = new DockerAdapter()
|
|
87
|
+
const originalSpawn = Bun.spawn
|
|
88
|
+
|
|
89
|
+
Bun.spawn = () => makeProcess('nonsense', '', 0) as any
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await expect(adapter.getExposedPort('cid')).rejects.toThrow('無法獲取容器映射端口')
|
|
93
|
+
} finally {
|
|
94
|
+
Bun.spawn = originalSpawn
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
44
98
|
test('copyFiles throws on non-zero exit code', async () => {
|
|
45
99
|
const adapter = new DockerAdapter()
|
|
46
100
|
const originalSpawn = Bun.spawn
|
|
@@ -54,13 +108,37 @@ describe('DockerAdapter', () => {
|
|
|
54
108
|
}
|
|
55
109
|
})
|
|
56
110
|
|
|
57
|
-
test('
|
|
111
|
+
test('removeContainerByLabel removes containers when ids exist', async () => {
|
|
58
112
|
const adapter = new DockerAdapter()
|
|
59
113
|
const originalSpawn = Bun.spawn
|
|
114
|
+
const calls: string[][] = []
|
|
115
|
+
|
|
116
|
+
Bun.spawn = ((args: string[]) => {
|
|
117
|
+
calls.push(args)
|
|
118
|
+
if (args[1] === 'ps') {
|
|
119
|
+
return makeProcess('id-1\nid-2\n', '', 0) as any
|
|
120
|
+
}
|
|
121
|
+
return makeProcess('', '', 0) as any
|
|
122
|
+
}) as any
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await adapter.removeContainerByLabel('gravito-origin=launchpad')
|
|
126
|
+
const rmCall = calls.find((call) => call[1] === 'rm')
|
|
127
|
+
expect(rmCall).toBeTruthy()
|
|
128
|
+
expect(rmCall).toContain('id-1')
|
|
129
|
+
expect(rmCall).toContain('id-2')
|
|
130
|
+
} finally {
|
|
131
|
+
Bun.spawn = originalSpawn
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('executeCommand returns stdout and stderr', async () => {
|
|
136
|
+
const originalSpawn = Bun.spawn
|
|
60
137
|
|
|
61
138
|
Bun.spawn = () => makeProcess('ok', 'warn', 0) as any
|
|
62
139
|
|
|
63
140
|
try {
|
|
141
|
+
const adapter = new DockerAdapter()
|
|
64
142
|
const result = await adapter.executeCommand('cid', ['echo', 'ok'])
|
|
65
143
|
expect(result.stdout).toBe('ok')
|
|
66
144
|
expect(result.stderr).toBe('warn')
|
|
@@ -70,6 +148,26 @@ describe('DockerAdapter', () => {
|
|
|
70
148
|
}
|
|
71
149
|
})
|
|
72
150
|
|
|
151
|
+
test('removeContainer executes docker rm', async () => {
|
|
152
|
+
const originalSpawn = Bun.spawn
|
|
153
|
+
const calls: string[][] = []
|
|
154
|
+
|
|
155
|
+
Bun.spawn = ((args: string[]) => {
|
|
156
|
+
calls.push(args)
|
|
157
|
+
return makeProcess('', '', 0) as any
|
|
158
|
+
}) as any
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const adapter = new DockerAdapter()
|
|
162
|
+
await adapter.removeContainer('cid-1')
|
|
163
|
+
const rmCall = calls.find((call) => call[1] === 'rm')
|
|
164
|
+
expect(rmCall).toBeTruthy()
|
|
165
|
+
expect(rmCall).toContain('cid-1')
|
|
166
|
+
} finally {
|
|
167
|
+
Bun.spawn = originalSpawn
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
73
171
|
test('getStats parses cpu and memory output', async () => {
|
|
74
172
|
const adapter = new DockerAdapter()
|
|
75
173
|
const originalSpawn = Bun.spawn
|
|
@@ -61,4 +61,77 @@ describe('PayloadInjector', () => {
|
|
|
61
61
|
expect(docker.commands.length).toBeGreaterThanOrEqual(4)
|
|
62
62
|
expect(rocket.status).toBe(RocketStatus.ORBITING)
|
|
63
63
|
})
|
|
64
|
+
|
|
65
|
+
test('throws when install fails', async () => {
|
|
66
|
+
const git = new FakeGit()
|
|
67
|
+
const docker = {
|
|
68
|
+
copyCalls: [] as Array<{ containerId: string; source: string; target: string }>,
|
|
69
|
+
commands: [] as string[][],
|
|
70
|
+
async copyFiles(containerId: string, source: string, target: string) {
|
|
71
|
+
this.copyCalls.push({ containerId, source, target })
|
|
72
|
+
},
|
|
73
|
+
async executeCommand(containerId: string, command: string[]) {
|
|
74
|
+
this.commands.push([containerId, ...command])
|
|
75
|
+
if (command[0] === 'bun' && command[1] === 'install') {
|
|
76
|
+
return { stdout: '', stderr: 'boom', exitCode: 1 }
|
|
77
|
+
}
|
|
78
|
+
return { stdout: '', stderr: '', exitCode: 0 }
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const injector = new PayloadInjector(docker as any, git as any)
|
|
83
|
+
const rocket = new Rocket('rocket-11', 'container-11')
|
|
84
|
+
const mission = Mission.create({
|
|
85
|
+
id: 'mission-11',
|
|
86
|
+
repoUrl: 'https://example.com/repo.git',
|
|
87
|
+
branch: 'main',
|
|
88
|
+
commitSha: 'abc999',
|
|
89
|
+
})
|
|
90
|
+
rocket.assignMission(mission)
|
|
91
|
+
|
|
92
|
+
await expect(injector.deploy(rocket)).rejects.toThrow('安裝依賴失敗')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('logs when run command fails but still ignites', async () => {
|
|
96
|
+
const docker = {
|
|
97
|
+
copyCalls: [] as Array<{ containerId: string; source: string; target: string }>,
|
|
98
|
+
commands: [] as string[][],
|
|
99
|
+
async copyFiles(containerId: string, source: string, target: string) {
|
|
100
|
+
this.copyCalls.push({ containerId, source, target })
|
|
101
|
+
},
|
|
102
|
+
async executeCommand(containerId: string, command: string[]) {
|
|
103
|
+
this.commands.push([containerId, ...command])
|
|
104
|
+
if (command[0] === 'bun' && command[1] === 'run') {
|
|
105
|
+
throw new Error('boom')
|
|
106
|
+
}
|
|
107
|
+
return { stdout: '', stderr: '', exitCode: 0 }
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
const git = new FakeGit()
|
|
111
|
+
const injector = new PayloadInjector(docker as any, git as any)
|
|
112
|
+
const rocket = new Rocket('rocket-12', 'container-12')
|
|
113
|
+
const mission = Mission.create({
|
|
114
|
+
id: 'mission-12',
|
|
115
|
+
repoUrl: 'https://example.com/repo.git',
|
|
116
|
+
branch: 'main',
|
|
117
|
+
commitSha: 'abc888',
|
|
118
|
+
})
|
|
119
|
+
rocket.assignMission(mission)
|
|
120
|
+
|
|
121
|
+
const originalError = console.error
|
|
122
|
+
const errorCalls: string[] = []
|
|
123
|
+
console.error = (...args: any[]) => {
|
|
124
|
+
errorCalls.push(args.join(' '))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await injector.deploy(rocket)
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
130
|
+
} finally {
|
|
131
|
+
console.error = originalError
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
expect(errorCalls.join(' ')).toContain('運行異常')
|
|
135
|
+
expect(rocket.status).toBe(RocketStatus.ORBITING)
|
|
136
|
+
})
|
|
64
137
|
})
|
package/tsconfig.json
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|
package/.turbo/turbo-test.log
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
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]
|