@gravito/launchpad 1.0.0-beta.1 → 1.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/CHANGELOG.md +27 -0
- package/README.md +79 -21
- package/debug-launch.ts +7 -4
- package/dist/index.d.mts +205 -0
- package/dist/index.d.ts +205 -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/MissionControl.ts +1 -0
- package/src/Application/PayloadInjector.ts +24 -3
- package/src/Domain/Interfaces.ts +20 -0
- 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": "1.
|
|
3
|
+
"version": "1.1.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
|
}
|
package/src/Domain/Interfaces.ts
CHANGED
|
@@ -26,6 +26,26 @@ export interface IDockerAdapter {
|
|
|
26
26
|
getStats(containerId: string): Promise<{ cpu: string; memory: string }>
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 負責動態反向代理與域名路由的轉接器
|
|
31
|
+
*/
|
|
32
|
+
export interface IRouterAdapter {
|
|
33
|
+
/**
|
|
34
|
+
* 註冊一個域名映射到指定的目標 (URL)
|
|
35
|
+
*/
|
|
36
|
+
register(domain: string, targetUrl: string): void
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 註銷域名
|
|
40
|
+
*/
|
|
41
|
+
unregister(domain: string): void
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 啟動代理伺服器
|
|
45
|
+
*/
|
|
46
|
+
start(port: number): void
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
/**
|
|
30
50
|
* 負責代碼獲取
|
|
31
51
|
*/
|
|
@@ -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
|
+
}
|