@gravito/launchpad 1.2.1 → 1.3.1
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 +55 -0
- package/README.md +51 -43
- package/README.zh-TW.md +110 -0
- package/dist/index.d.mts +324 -7
- package/dist/index.d.ts +324 -7
- package/dist/index.js +407 -29
- package/dist/index.mjs +395 -29
- package/package.json +7 -5
- package/scripts/check-coverage.ts +64 -0
- package/src/Application/MissionQueue.ts +143 -0
- package/src/Application/PoolManager.ts +170 -18
- package/src/Application/RefurbishUnit.ts +24 -12
- package/src/Domain/Events.ts +46 -0
- package/src/Domain/Interfaces.ts +82 -0
- package/src/Domain/Rocket.ts +18 -0
- package/src/Infrastructure/Docker/DockerAdapter.ts +80 -3
- package/src/Infrastructure/Git/ShellGitAdapter.ts +1 -1
- package/src/index.ts +3 -0
- package/tests/Deployment.test.ts +3 -1
- package/tests/pool-manager.test.ts +3 -1
- package/tests/resource-limits.test.ts +241 -0
- package/tsconfig.json +14 -26
- package/.turbo/turbo-build.log +0 -18
- package/.turbo/turbo-test$colon$coverage.log +0 -183
- package/.turbo/turbo-test.log +0 -100
- package/.turbo/turbo-typecheck.log +0 -1
package/dist/index.mjs
CHANGED
|
@@ -111,6 +111,36 @@ registry = "${fallbackRegistry}"
|
|
|
111
111
|
}
|
|
112
112
|
};
|
|
113
113
|
|
|
114
|
+
// src/Domain/Interfaces.ts
|
|
115
|
+
var DEFAULT_POOL_CONFIG = {
|
|
116
|
+
maxRockets: 20,
|
|
117
|
+
warmupCount: 3,
|
|
118
|
+
maxQueueSize: 50,
|
|
119
|
+
queueTimeoutMs: 3e4,
|
|
120
|
+
exhaustionStrategy: "queue",
|
|
121
|
+
dynamicLimit: 5
|
|
122
|
+
};
|
|
123
|
+
var DEFAULT_REFURBISH_CONFIG = {
|
|
124
|
+
strategy: "deep-clean",
|
|
125
|
+
cleanupCommands: [
|
|
126
|
+
// 1. 殺掉所有使用者進程
|
|
127
|
+
"pkill -9 -u bun || true",
|
|
128
|
+
'pkill -9 -u root -f "bun|node" || true',
|
|
129
|
+
// 2. 清理應用目錄
|
|
130
|
+
"rm -rf /app/* /app/.* 2>/dev/null || true",
|
|
131
|
+
// 3. 清理暫存檔案
|
|
132
|
+
"rm -rf /tmp/* /var/tmp/* 2>/dev/null || true",
|
|
133
|
+
// 4. 清理 bun 暫存
|
|
134
|
+
"rm -rf /root/.bun/install/cache/tmp/* 2>/dev/null || true",
|
|
135
|
+
"rm -rf /home/bun/.bun/install/cache/tmp/* 2>/dev/null || true",
|
|
136
|
+
// 5. 清理日誌
|
|
137
|
+
"rm -rf /var/log/*.log 2>/dev/null || true"
|
|
138
|
+
],
|
|
139
|
+
cleanupTimeoutMs: 3e4,
|
|
140
|
+
failureAction: "decommission",
|
|
141
|
+
maxRetries: 2
|
|
142
|
+
};
|
|
143
|
+
|
|
114
144
|
// src/Domain/Rocket.ts
|
|
115
145
|
import { AggregateRoot } from "@gravito/enterprise";
|
|
116
146
|
|
|
@@ -141,6 +171,28 @@ var RefurbishmentCompleted = class extends DomainEvent {
|
|
|
141
171
|
this.rocketId = rocketId;
|
|
142
172
|
}
|
|
143
173
|
};
|
|
174
|
+
var PoolExhausted = class extends DomainEvent {
|
|
175
|
+
constructor(totalRockets, maxRockets, queueLength) {
|
|
176
|
+
super();
|
|
177
|
+
this.totalRockets = totalRockets;
|
|
178
|
+
this.maxRockets = maxRockets;
|
|
179
|
+
this.queueLength = queueLength;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
var MissionQueued = class extends DomainEvent {
|
|
183
|
+
constructor(missionId, queuePosition) {
|
|
184
|
+
super();
|
|
185
|
+
this.missionId = missionId;
|
|
186
|
+
this.queuePosition = queuePosition;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
var MissionQueueTimeout = class extends DomainEvent {
|
|
190
|
+
constructor(missionId, waitTimeMs) {
|
|
191
|
+
super();
|
|
192
|
+
this.missionId = missionId;
|
|
193
|
+
this.waitTimeMs = waitTimeMs;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
144
196
|
|
|
145
197
|
// src/Domain/RocketStatus.ts
|
|
146
198
|
var RocketStatus = /* @__PURE__ */ ((RocketStatus2) => {
|
|
@@ -228,6 +280,23 @@ var Rocket = class _Rocket extends AggregateRoot {
|
|
|
228
280
|
decommission() {
|
|
229
281
|
this._status = "DECOMMISSIONED" /* DECOMMISSIONED */;
|
|
230
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* 替換底層容器(僅在 REFURBISHING 狀態時允許)
|
|
285
|
+
* 用於 destroy-recreate 策略
|
|
286
|
+
*
|
|
287
|
+
* @param newContainerId - 新容器 ID
|
|
288
|
+
* @throws {Error} 當火箭不在 REFURBISHING 狀態時
|
|
289
|
+
*
|
|
290
|
+
* @public
|
|
291
|
+
* @since 1.3.0
|
|
292
|
+
*/
|
|
293
|
+
replaceContainer(newContainerId) {
|
|
294
|
+
if (this._status !== "REFURBISHING" /* REFURBISHING */) {
|
|
295
|
+
throw new Error(`\u7121\u6CD5\u66FF\u63DB\u5BB9\u5668\uFF1A\u706B\u7BAD ${this.id} \u4E0D\u5728 REFURBISHING \u72C0\u614B`);
|
|
296
|
+
}
|
|
297
|
+
this._containerId = newContainerId;
|
|
298
|
+
this._assignedDomain = null;
|
|
299
|
+
}
|
|
231
300
|
toJSON() {
|
|
232
301
|
return {
|
|
233
302
|
id: this.id,
|
|
@@ -246,44 +315,219 @@ var Rocket = class _Rocket extends AggregateRoot {
|
|
|
246
315
|
}
|
|
247
316
|
};
|
|
248
317
|
|
|
318
|
+
// src/Application/MissionQueue.ts
|
|
319
|
+
var MissionQueue = class {
|
|
320
|
+
queue = [];
|
|
321
|
+
maxSize;
|
|
322
|
+
timeoutMs;
|
|
323
|
+
constructor(maxSize = 50, timeoutMs = 3e4) {
|
|
324
|
+
this.maxSize = maxSize;
|
|
325
|
+
this.timeoutMs = timeoutMs;
|
|
326
|
+
}
|
|
327
|
+
get length() {
|
|
328
|
+
return this.queue.length;
|
|
329
|
+
}
|
|
330
|
+
get isFull() {
|
|
331
|
+
return this.queue.length >= this.maxSize;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* 將任務加入隊列,返回 Promise 等待分配
|
|
335
|
+
*/
|
|
336
|
+
enqueue(mission) {
|
|
337
|
+
if (this.isFull) {
|
|
338
|
+
return Promise.reject(
|
|
339
|
+
new PoolExhaustedException(`\u4EFB\u52D9\u968A\u5217\u5DF2\u6EFF\uFF08${this.maxSize}\uFF09\uFF0C\u7121\u6CD5\u63A5\u53D7\u65B0\u4EFB\u52D9`)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
const timeoutId = setTimeout(() => {
|
|
344
|
+
this.removeFromQueue(mission.id);
|
|
345
|
+
reject(new QueueTimeoutException(`\u4EFB\u52D9 ${mission.id} \u7B49\u5F85\u8D85\u6642`));
|
|
346
|
+
}, this.timeoutMs);
|
|
347
|
+
this.queue.push({
|
|
348
|
+
mission,
|
|
349
|
+
resolve,
|
|
350
|
+
reject,
|
|
351
|
+
enqueuedAt: Date.now(),
|
|
352
|
+
timeoutId
|
|
353
|
+
});
|
|
354
|
+
console.log(
|
|
355
|
+
`[MissionQueue] \u4EFB\u52D9 ${mission.id} \u5DF2\u52A0\u5165\u968A\u5217\uFF0C\u7576\u524D\u968A\u5217\u9577\u5EA6: ${this.queue.length}`
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* 取出下一個等待的任務
|
|
361
|
+
*/
|
|
362
|
+
dequeue() {
|
|
363
|
+
const item = this.queue.shift();
|
|
364
|
+
if (item) {
|
|
365
|
+
clearTimeout(item.timeoutId);
|
|
366
|
+
}
|
|
367
|
+
return item ?? null;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 從隊列中移除指定任務
|
|
371
|
+
*/
|
|
372
|
+
removeFromQueue(missionId) {
|
|
373
|
+
const index = this.queue.findIndex((q) => q.mission.id === missionId);
|
|
374
|
+
if (index >= 0) {
|
|
375
|
+
const [removed] = this.queue.splice(index, 1);
|
|
376
|
+
clearTimeout(removed.timeoutId);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* 清空隊列並拒絕所有等待的任務
|
|
381
|
+
*/
|
|
382
|
+
clear(reason) {
|
|
383
|
+
for (const item of this.queue) {
|
|
384
|
+
clearTimeout(item.timeoutId);
|
|
385
|
+
item.reject(new Error(reason));
|
|
386
|
+
}
|
|
387
|
+
this.queue = [];
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* 取得隊列統計資訊
|
|
391
|
+
*/
|
|
392
|
+
getStats() {
|
|
393
|
+
const oldestWaitMs = this.queue.length > 0 ? Date.now() - this.queue[0].enqueuedAt : null;
|
|
394
|
+
return {
|
|
395
|
+
length: this.queue.length,
|
|
396
|
+
maxSize: this.maxSize,
|
|
397
|
+
oldestWaitMs
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
var PoolExhaustedException = class extends Error {
|
|
402
|
+
constructor(message) {
|
|
403
|
+
super(message);
|
|
404
|
+
this.name = "PoolExhaustedException";
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
var QueueTimeoutException = class extends Error {
|
|
408
|
+
constructor(message) {
|
|
409
|
+
super(message);
|
|
410
|
+
this.name = "QueueTimeoutException";
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
249
414
|
// src/Application/PoolManager.ts
|
|
250
415
|
var PoolManager = class {
|
|
251
|
-
constructor(dockerAdapter, rocketRepository, refurbishUnit, router) {
|
|
416
|
+
constructor(dockerAdapter, rocketRepository, refurbishUnit, router, config) {
|
|
252
417
|
this.dockerAdapter = dockerAdapter;
|
|
253
418
|
this.rocketRepository = rocketRepository;
|
|
254
419
|
this.refurbishUnit = refurbishUnit;
|
|
255
420
|
this.router = router;
|
|
421
|
+
this.config = { ...DEFAULT_POOL_CONFIG, ...config };
|
|
422
|
+
this.missionQueue = new MissionQueue(this.config.maxQueueSize, this.config.queueTimeoutMs);
|
|
423
|
+
}
|
|
424
|
+
config;
|
|
425
|
+
missionQueue;
|
|
426
|
+
dynamicRocketCount = 0;
|
|
427
|
+
/**
|
|
428
|
+
* 取得當前 Pool 狀態
|
|
429
|
+
*/
|
|
430
|
+
async getPoolStatus() {
|
|
431
|
+
const rockets = await this.rocketRepository.findAll();
|
|
432
|
+
const statusCounts = {
|
|
433
|
+
total: rockets.length,
|
|
434
|
+
idle: 0,
|
|
435
|
+
orbiting: 0,
|
|
436
|
+
refurbishing: 0,
|
|
437
|
+
decommissioned: 0
|
|
438
|
+
};
|
|
439
|
+
for (const rocket of rockets) {
|
|
440
|
+
switch (rocket.status) {
|
|
441
|
+
case "IDLE" /* IDLE */:
|
|
442
|
+
statusCounts.idle++;
|
|
443
|
+
break;
|
|
444
|
+
case "PREPARING" /* PREPARING */:
|
|
445
|
+
case "ORBITING" /* ORBITING */:
|
|
446
|
+
statusCounts.orbiting++;
|
|
447
|
+
break;
|
|
448
|
+
case "REFURBISHING" /* REFURBISHING */:
|
|
449
|
+
statusCounts.refurbishing++;
|
|
450
|
+
break;
|
|
451
|
+
case "DECOMMISSIONED" /* DECOMMISSIONED */:
|
|
452
|
+
statusCounts.decommissioned++;
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
...statusCounts,
|
|
458
|
+
dynamicCount: this.dynamicRocketCount,
|
|
459
|
+
maxRockets: this.config.maxRockets,
|
|
460
|
+
queueLength: this.missionQueue.length
|
|
461
|
+
};
|
|
256
462
|
}
|
|
257
463
|
/**
|
|
258
464
|
* 初始化發射場:預先準備指定數量的火箭
|
|
259
465
|
*/
|
|
260
466
|
async warmup(count) {
|
|
467
|
+
const targetCount = count ?? this.config.warmupCount;
|
|
261
468
|
const currentRockets = await this.rocketRepository.findAll();
|
|
262
|
-
const
|
|
469
|
+
const activeRockets = currentRockets.filter((r) => r.status !== "DECOMMISSIONED" /* DECOMMISSIONED */);
|
|
470
|
+
const needed = Math.min(
|
|
471
|
+
targetCount - activeRockets.length,
|
|
472
|
+
this.config.maxRockets - activeRockets.length
|
|
473
|
+
);
|
|
263
474
|
if (needed <= 0) {
|
|
475
|
+
console.log(`[PoolManager] \u7121\u9700\u71B1\u6A5F\uFF0C\u7576\u524D\u5DF2\u6709 ${activeRockets.length} \u67B6\u706B\u7BAD`);
|
|
264
476
|
return;
|
|
265
477
|
}
|
|
266
|
-
console.log(`[
|
|
478
|
+
console.log(`[PoolManager] \u6B63\u5728\u71B1\u6A5F\uFF0C\u6E96\u5099\u767C\u5C04 ${needed} \u67B6\u65B0\u706B\u7BAD...`);
|
|
267
479
|
for (let i = 0; i < needed; i++) {
|
|
268
480
|
const containerId = await this.dockerAdapter.createBaseContainer();
|
|
269
|
-
const rocketId = `rocket-${
|
|
481
|
+
const rocketId = `rocket-${crypto.randomUUID()}`;
|
|
270
482
|
const rocket = new Rocket(rocketId, containerId);
|
|
271
483
|
await this.rocketRepository.save(rocket);
|
|
272
484
|
}
|
|
485
|
+
console.log(`[PoolManager] \u71B1\u6A5F\u5B8C\u6210\uFF0CPool \u7576\u524D\u5171 ${activeRockets.length + needed} \u67B6\u706B\u7BAD`);
|
|
273
486
|
}
|
|
274
487
|
/**
|
|
275
488
|
* 獲取一架可用的火箭並分配任務
|
|
489
|
+
*
|
|
490
|
+
* @throws {PoolExhaustedException} 當 Pool 耗盡且無法處理請求時
|
|
276
491
|
*/
|
|
277
492
|
async assignMission(mission) {
|
|
278
493
|
let rocket = await this.rocketRepository.findIdle();
|
|
279
|
-
if (
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
494
|
+
if (rocket) {
|
|
495
|
+
rocket.assignMission(mission);
|
|
496
|
+
await this.rocketRepository.save(rocket);
|
|
497
|
+
return rocket;
|
|
498
|
+
}
|
|
499
|
+
const poolStatus = await this.getPoolStatus();
|
|
500
|
+
const activeCount = poolStatus.total - poolStatus.decommissioned;
|
|
501
|
+
console.log(
|
|
502
|
+
`[PoolManager] \u7121\u53EF\u7528\u706B\u7BAD\uFF0C\u7576\u524D\u72C0\u614B: \u7E3D\u6578=${activeCount}/${this.config.maxRockets}, \u968A\u5217=${this.missionQueue.length}/${this.config.maxQueueSize}`
|
|
503
|
+
);
|
|
504
|
+
switch (this.config.exhaustionStrategy) {
|
|
505
|
+
case "reject":
|
|
506
|
+
throw new PoolExhaustedException(`Rocket Pool \u5DF2\u8017\u76E1\uFF0C\u7121\u6CD5\u8655\u7406\u4EFB\u52D9 ${mission.id}`);
|
|
507
|
+
case "dynamic": {
|
|
508
|
+
if (activeCount < this.config.maxRockets && this.dynamicRocketCount < (this.config.dynamicLimit ?? 5)) {
|
|
509
|
+
console.log(`[PoolManager] \u52D5\u614B\u5EFA\u7ACB\u5F8C\u63F4\u706B\u7BAD...`);
|
|
510
|
+
const containerId = await this.dockerAdapter.createBaseContainer();
|
|
511
|
+
rocket = new Rocket(`rocket-dynamic-${Date.now()}`, containerId);
|
|
512
|
+
this.dynamicRocketCount++;
|
|
513
|
+
rocket.assignMission(mission);
|
|
514
|
+
await this.rocketRepository.save(rocket);
|
|
515
|
+
return rocket;
|
|
516
|
+
}
|
|
517
|
+
if (this.missionQueue.isFull) {
|
|
518
|
+
throw new PoolExhaustedException(`Rocket Pool \u8207\u968A\u5217\u5747\u5DF2\u6EFF\uFF0C\u7121\u6CD5\u8655\u7406\u4EFB\u52D9 ${mission.id}`);
|
|
519
|
+
}
|
|
520
|
+
console.log(`[PoolManager] \u4EFB\u52D9 ${mission.id} \u9032\u5165\u7B49\u5F85\u968A\u5217`);
|
|
521
|
+
return this.missionQueue.enqueue(mission);
|
|
522
|
+
}
|
|
523
|
+
case "queue":
|
|
524
|
+
default:
|
|
525
|
+
if (this.missionQueue.isFull) {
|
|
526
|
+
throw new PoolExhaustedException(`Rocket Pool \u8207\u968A\u5217\u5747\u5DF2\u6EFF\uFF0C\u7121\u6CD5\u8655\u7406\u4EFB\u52D9 ${mission.id}`);
|
|
527
|
+
}
|
|
528
|
+
console.log(`[PoolManager] \u4EFB\u52D9 ${mission.id} \u9032\u5165\u7B49\u5F85\u968A\u5217`);
|
|
529
|
+
return this.missionQueue.enqueue(mission);
|
|
283
530
|
}
|
|
284
|
-
rocket.assignMission(mission);
|
|
285
|
-
await this.rocketRepository.save(rocket);
|
|
286
|
-
return rocket;
|
|
287
531
|
}
|
|
288
532
|
/**
|
|
289
533
|
* 回收指定任務的火箭
|
|
@@ -292,7 +536,7 @@ var PoolManager = class {
|
|
|
292
536
|
const allRockets = await this.rocketRepository.findAll();
|
|
293
537
|
const rocket = allRockets.find((r) => r.currentMission?.id === missionId);
|
|
294
538
|
if (!rocket) {
|
|
295
|
-
console.warn(`[
|
|
539
|
+
console.warn(`[PoolManager] \u627E\u4E0D\u5230\u5C6C\u65BC\u4EFB\u52D9 ${missionId} \u7684\u706B\u7BAD`);
|
|
296
540
|
return;
|
|
297
541
|
}
|
|
298
542
|
if (this.router && rocket.assignedDomain) {
|
|
@@ -305,30 +549,67 @@ var PoolManager = class {
|
|
|
305
549
|
rocket.finishRefurbishment();
|
|
306
550
|
}
|
|
307
551
|
await this.rocketRepository.save(rocket);
|
|
552
|
+
await this.processQueue();
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* 處理等待隊列中的任務
|
|
556
|
+
*/
|
|
557
|
+
async processQueue() {
|
|
558
|
+
const queuedItem = this.missionQueue.dequeue();
|
|
559
|
+
if (!queuedItem) {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
const rocket = await this.rocketRepository.findIdle();
|
|
564
|
+
if (rocket) {
|
|
565
|
+
rocket.assignMission(queuedItem.mission);
|
|
566
|
+
await this.rocketRepository.save(rocket);
|
|
567
|
+
queuedItem.resolve(rocket);
|
|
568
|
+
console.log(`[PoolManager] \u968A\u5217\u4EFB\u52D9 ${queuedItem.mission.id} \u5DF2\u5206\u914D\u706B\u7BAD ${rocket.id}`);
|
|
569
|
+
} else {
|
|
570
|
+
console.warn(`[PoolManager] \u8655\u7406\u968A\u5217\u6642\u4ECD\u7121\u53EF\u7528\u706B\u7BAD\uFF0C\u4EFB\u52D9\u91CD\u65B0\u5165\u968A`);
|
|
571
|
+
this.missionQueue.enqueue(queuedItem.mission).then(queuedItem.resolve).catch(queuedItem.reject);
|
|
572
|
+
}
|
|
573
|
+
} catch (error) {
|
|
574
|
+
queuedItem.reject(error);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* 取得隊列統計資訊
|
|
579
|
+
*/
|
|
580
|
+
getQueueStats() {
|
|
581
|
+
return this.missionQueue.getStats();
|
|
308
582
|
}
|
|
309
583
|
};
|
|
310
584
|
|
|
311
585
|
// src/Application/RefurbishUnit.ts
|
|
312
586
|
var RefurbishUnit = class {
|
|
313
|
-
constructor(docker) {
|
|
587
|
+
constructor(docker, config) {
|
|
314
588
|
this.docker = docker;
|
|
589
|
+
this.config = { ...DEFAULT_REFURBISH_CONFIG, ...config };
|
|
315
590
|
}
|
|
591
|
+
config;
|
|
316
592
|
/**
|
|
317
593
|
* 執行火箭翻新邏輯
|
|
318
594
|
*/
|
|
319
595
|
async refurbish(rocket) {
|
|
320
|
-
console.log(
|
|
596
|
+
console.log(
|
|
597
|
+
`[RefurbishUnit] \u6B63\u5728\u7FFB\u65B0\u706B\u7BAD: ${rocket.id} (\u7B56\u7565: ${this.config.strategy}, \u5BB9\u5668: ${rocket.containerId})`
|
|
598
|
+
);
|
|
321
599
|
rocket.splashDown();
|
|
322
600
|
try {
|
|
323
|
-
const
|
|
324
|
-
const
|
|
601
|
+
const commands = this.config.cleanupCommands ?? [];
|
|
602
|
+
const fullCommand = commands.join(" && ");
|
|
603
|
+
const result = await this.docker.executeCommand(rocket.containerId, ["sh", "-c", fullCommand]);
|
|
325
604
|
if (result.exitCode !== 0) {
|
|
326
605
|
console.error(`[RefurbishUnit] \u6E05\u7406\u5931\u6557: ${result.stderr}`);
|
|
327
|
-
|
|
328
|
-
|
|
606
|
+
if (this.config.failureAction === "decommission") {
|
|
607
|
+
rocket.decommission();
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
329
610
|
}
|
|
330
611
|
rocket.finishRefurbishment();
|
|
331
|
-
console.log(`[RefurbishUnit] \u706B\u7BAD ${rocket.id} \u7FFB\u65B0\u5B8C\u6210\uFF0C\u5DF2\u9032\u5165 IDLE \u72C0\u614B
|
|
612
|
+
console.log(`[RefurbishUnit] \u706B\u7BAD ${rocket.id} \u7FFB\u65B0\u5B8C\u6210\uFF0C\u5DF2\u9032\u5165 IDLE \u72C0\u614B`);
|
|
332
613
|
} catch (error) {
|
|
333
614
|
console.error(`[RefurbishUnit] \u56DE\u6536\u904E\u7A0B\u767C\u751F\u7570\u5E38:`, error);
|
|
334
615
|
rocket.decommission();
|
|
@@ -361,8 +642,15 @@ import { getRuntimeAdapter } from "@gravito/core";
|
|
|
361
642
|
var DockerAdapter = class {
|
|
362
643
|
baseImage = "oven/bun:1.0-slim";
|
|
363
644
|
runtime = getRuntimeAdapter();
|
|
645
|
+
// 快取目錄配置
|
|
646
|
+
cacheConfig = {
|
|
647
|
+
hostCachePath: process.env.BUN_CACHE_PATH || `${process.env.HOME}/.bun/install/cache`,
|
|
648
|
+
containerCachePathRoot: "/root/.bun/install/cache",
|
|
649
|
+
containerCachePathBun: "/home/bun/.bun/install/cache"
|
|
650
|
+
};
|
|
364
651
|
async createBaseContainer() {
|
|
365
|
-
const rocketId = `rocket-${
|
|
652
|
+
const rocketId = `rocket-${crypto.randomUUID()}`;
|
|
653
|
+
await this.ensureCacheDirectory();
|
|
366
654
|
const proc = this.runtime.spawn([
|
|
367
655
|
"docker",
|
|
368
656
|
"run",
|
|
@@ -374,10 +662,39 @@ var DockerAdapter = class {
|
|
|
374
662
|
"-p",
|
|
375
663
|
"3000",
|
|
376
664
|
// 讓 Docker 分配隨機宿主機埠
|
|
665
|
+
// === 快取掛載(關鍵) ===
|
|
377
666
|
"-v",
|
|
378
|
-
`${
|
|
667
|
+
`${this.cacheConfig.hostCachePath}:${this.cacheConfig.containerCachePathRoot}:rw`,
|
|
379
668
|
"-v",
|
|
380
|
-
`${
|
|
669
|
+
`${this.cacheConfig.hostCachePath}:${this.cacheConfig.containerCachePathBun}:rw`,
|
|
670
|
+
// === 環境變數配置(確保 bun 使用快取) ===
|
|
671
|
+
"-e",
|
|
672
|
+
`BUN_INSTALL_CACHE_DIR=${this.cacheConfig.containerCachePathBun}`,
|
|
673
|
+
"-e",
|
|
674
|
+
"BUN_INSTALL_CACHE=shared",
|
|
675
|
+
"-e",
|
|
676
|
+
"BUN_INSTALL_PREFER_OFFLINE=true",
|
|
677
|
+
// === 效能相關環境變數 ===
|
|
678
|
+
"-e",
|
|
679
|
+
"NODE_ENV=development",
|
|
680
|
+
// === 資源限制(防止單一容器占用過多資源) ===
|
|
681
|
+
"--memory",
|
|
682
|
+
"1g",
|
|
683
|
+
"--memory-swap",
|
|
684
|
+
"1g",
|
|
685
|
+
"--cpus",
|
|
686
|
+
"1.0",
|
|
687
|
+
// === 安全設定 ===
|
|
688
|
+
"--security-opt",
|
|
689
|
+
"no-new-privileges:true",
|
|
690
|
+
"--cap-drop",
|
|
691
|
+
"ALL",
|
|
692
|
+
"--cap-add",
|
|
693
|
+
"CHOWN",
|
|
694
|
+
"--cap-add",
|
|
695
|
+
"SETUID",
|
|
696
|
+
"--cap-add",
|
|
697
|
+
"SETGID",
|
|
381
698
|
this.baseImage,
|
|
382
699
|
"tail",
|
|
383
700
|
"-f",
|
|
@@ -387,6 +704,7 @@ var DockerAdapter = class {
|
|
|
387
704
|
const containerId = stdout.trim();
|
|
388
705
|
const exitCode = await proc.exited;
|
|
389
706
|
if (containerId.length === 64 && /^[0-9a-f]+$/.test(containerId)) {
|
|
707
|
+
await this.verifyCacheMount(containerId);
|
|
390
708
|
return containerId;
|
|
391
709
|
}
|
|
392
710
|
if (exitCode !== 0) {
|
|
@@ -395,6 +713,37 @@ var DockerAdapter = class {
|
|
|
395
713
|
}
|
|
396
714
|
return containerId;
|
|
397
715
|
}
|
|
716
|
+
/**
|
|
717
|
+
* 確保快取目錄存在
|
|
718
|
+
*
|
|
719
|
+
* @private
|
|
720
|
+
* @since 1.3.0
|
|
721
|
+
*/
|
|
722
|
+
async ensureCacheDirectory() {
|
|
723
|
+
const cachePath = this.cacheConfig.hostCachePath;
|
|
724
|
+
const proc = this.runtime.spawn(["mkdir", "-p", cachePath]);
|
|
725
|
+
await proc.exited;
|
|
726
|
+
const chmodProc = this.runtime.spawn(["chmod", "777", cachePath]);
|
|
727
|
+
await chmodProc.exited;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* 驗證快取是否正確掛載
|
|
731
|
+
*
|
|
732
|
+
* @private
|
|
733
|
+
* @since 1.3.0
|
|
734
|
+
*/
|
|
735
|
+
async verifyCacheMount(containerId) {
|
|
736
|
+
const result = await this.executeCommand(containerId, [
|
|
737
|
+
"sh",
|
|
738
|
+
"-c",
|
|
739
|
+
`ls -la ${this.cacheConfig.containerCachePathBun} 2>/dev/null || echo 'NOT_MOUNTED'`
|
|
740
|
+
]);
|
|
741
|
+
if (result.stdout.includes("NOT_MOUNTED")) {
|
|
742
|
+
console.warn(`[DockerAdapter] \u8B66\u544A: \u5BB9\u5668 ${containerId} \u7684\u5FEB\u53D6\u76EE\u9304\u53EF\u80FD\u672A\u6B63\u78BA\u639B\u8F09`);
|
|
743
|
+
} else {
|
|
744
|
+
console.log(`[DockerAdapter] \u5FEB\u53D6\u76EE\u9304\u5DF2\u78BA\u8A8D\u639B\u8F09: ${containerId}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
398
747
|
async getExposedPort(containerId, containerPort = 3e3) {
|
|
399
748
|
const proc = this.runtime.spawn(["docker", "port", containerId, containerPort.toString()]);
|
|
400
749
|
const stdout = await new Response(proc.stdout ?? null).text();
|
|
@@ -477,7 +826,7 @@ import { getRuntimeAdapter as getRuntimeAdapter2 } from "@gravito/core";
|
|
|
477
826
|
var ShellGitAdapter = class {
|
|
478
827
|
baseDir = "/tmp/gravito-launchpad-git";
|
|
479
828
|
async clone(repoUrl, branch) {
|
|
480
|
-
const dirName = `${Date.now()}-${
|
|
829
|
+
const dirName = `${Date.now()}-${crypto.randomUUID()}`;
|
|
481
830
|
const targetDir = `${this.baseDir}/${dirName}`;
|
|
482
831
|
await mkdir(this.baseDir, { recursive: true });
|
|
483
832
|
const runtime = getRuntimeAdapter2();
|
|
@@ -672,9 +1021,19 @@ var LaunchpadServiceProvider = class extends ServiceProvider {
|
|
|
672
1021
|
}
|
|
673
1022
|
};
|
|
674
1023
|
var LaunchpadOrbit = class {
|
|
1024
|
+
/**
|
|
1025
|
+
* Create a new LaunchpadOrbit instance.
|
|
1026
|
+
* @param ripple - Ripple instance for real-time telemetry communication.
|
|
1027
|
+
*/
|
|
675
1028
|
constructor(ripple) {
|
|
676
1029
|
this.ripple = ripple;
|
|
677
1030
|
}
|
|
1031
|
+
/**
|
|
1032
|
+
* Install the Launchpad orbit into PlanetCore.
|
|
1033
|
+
* Registers the service provider and sets up webhook routes for deployment.
|
|
1034
|
+
*
|
|
1035
|
+
* @param core - The PlanetCore instance.
|
|
1036
|
+
*/
|
|
678
1037
|
async install(core) {
|
|
679
1038
|
core.register(new LaunchpadServiceProvider());
|
|
680
1039
|
core.router.post("/launch", async (c) => {
|
|
@@ -748,12 +1107,7 @@ async function bootstrapLaunchpad() {
|
|
|
748
1107
|
PORT: 4e3,
|
|
749
1108
|
CACHE_DRIVER: "file"
|
|
750
1109
|
},
|
|
751
|
-
orbits: [
|
|
752
|
-
new OrbitCache(),
|
|
753
|
-
ripple,
|
|
754
|
-
new LaunchpadOrbit(ripple)
|
|
755
|
-
// 傳入實例
|
|
756
|
-
]
|
|
1110
|
+
orbits: [new OrbitCache(), ripple, new LaunchpadOrbit(ripple)]
|
|
757
1111
|
});
|
|
758
1112
|
await core.bootstrap();
|
|
759
1113
|
const liftoffConfig = core.liftoff();
|
|
@@ -769,13 +1123,25 @@ async function bootstrapLaunchpad() {
|
|
|
769
1123
|
};
|
|
770
1124
|
}
|
|
771
1125
|
export {
|
|
1126
|
+
DEFAULT_POOL_CONFIG,
|
|
1127
|
+
DEFAULT_REFURBISH_CONFIG,
|
|
772
1128
|
LaunchpadOrbit,
|
|
773
1129
|
Mission,
|
|
1130
|
+
MissionAssigned,
|
|
774
1131
|
MissionControl,
|
|
1132
|
+
MissionQueue,
|
|
1133
|
+
MissionQueueTimeout,
|
|
1134
|
+
MissionQueued,
|
|
775
1135
|
PayloadInjector,
|
|
1136
|
+
PoolExhausted,
|
|
1137
|
+
PoolExhaustedException,
|
|
776
1138
|
PoolManager,
|
|
1139
|
+
QueueTimeoutException,
|
|
777
1140
|
RefurbishUnit,
|
|
1141
|
+
RefurbishmentCompleted,
|
|
778
1142
|
Rocket,
|
|
1143
|
+
RocketIgnited,
|
|
1144
|
+
RocketSplashedDown,
|
|
779
1145
|
RocketStatus,
|
|
780
1146
|
bootstrapLaunchpad
|
|
781
1147
|
};
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/launchpad",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Container lifecycle management system for flash deployments",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
10
|
-
"test": "bun test",
|
|
11
|
-
"test:coverage": "bun test --coverage --coverage-
|
|
12
|
-
"test:ci": "bun test --coverage --coverage-
|
|
13
|
-
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
10
|
+
"test": "bun test --timeout=10000",
|
|
11
|
+
"test:coverage": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
|
|
12
|
+
"test:ci": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
|
|
13
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
|
14
|
+
"test:unit": "bun test tests/ --timeout=10000",
|
|
15
|
+
"test:integration": "test $(find tests -name '*.integration.test.ts' 2>/dev/null | wc -l) -gt 0 && find tests -name '*.integration.test.ts' -print0 | xargs -0 bun test --timeout=10000 || echo 'No integration tests found'"
|
|
14
16
|
},
|
|
15
17
|
"dependencies": {
|
|
16
18
|
"@gravito/enterprise": "workspace:*",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const lcovPath = process.argv[2] ?? 'coverage/lcov.info'
|
|
5
|
+
const threshold = Number.parseFloat(process.env.COVERAGE_THRESHOLD ?? '80')
|
|
6
|
+
|
|
7
|
+
const root = resolve(process.cwd())
|
|
8
|
+
const srcRoot = `${resolve(root, 'src')}/`
|
|
9
|
+
|
|
10
|
+
// 檢查 lcov.info 是否存在
|
|
11
|
+
if (!existsSync(lcovPath)) {
|
|
12
|
+
console.error(`Coverage file not found: ${lcovPath}`)
|
|
13
|
+
process.exit(1)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let content: string
|
|
17
|
+
try {
|
|
18
|
+
content = readFileSync(lcovPath, 'utf-8')
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error(`Failed to read coverage file: ${error}`)
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lines = content.split('\n')
|
|
25
|
+
|
|
26
|
+
let currentFile: string | null = null
|
|
27
|
+
let total = 0
|
|
28
|
+
let hit = 0
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
if (line.startsWith('SF:')) {
|
|
32
|
+
const filePath = line.slice(3).trim()
|
|
33
|
+
const abs = resolve(root, filePath)
|
|
34
|
+
currentFile = abs.startsWith(srcRoot) ? abs : null
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!currentFile) {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (line.startsWith('DA:')) {
|
|
43
|
+
const parts = line.slice(3).split(',')
|
|
44
|
+
if (parts.length >= 2) {
|
|
45
|
+
total += 1
|
|
46
|
+
const count = Number.parseInt(parts[1] ?? '0', 10)
|
|
47
|
+
if (count > 0) {
|
|
48
|
+
hit += 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const percent = total === 0 ? 0 : (hit / total) * 100
|
|
55
|
+
const rounded = Math.round(percent * 100) / 100
|
|
56
|
+
|
|
57
|
+
if (rounded < threshold) {
|
|
58
|
+
console.error(
|
|
59
|
+
`launchpad coverage ${rounded}% is below threshold ${threshold}%. Covered lines: ${hit}/${total}.`
|
|
60
|
+
)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
console.log(`launchpad coverage ${rounded}% (${hit}/${total}) meets threshold ${threshold}%.`)
|