@gravito/launchpad 1.2.2 → 1.3.2
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 +66 -0
- package/README.md +51 -43
- package/README.zh-TW.md +110 -0
- package/dist/index.d.mts +248 -5
- package/dist/index.d.ts +248 -5
- package/dist/index.js +392 -20
- package/dist/index.mjs +380 -20
- package/package.json +11 -9
- package/scripts/check-coverage.ts +64 -0
- package/src/Application/MissionQueue.ts +143 -0
- package/src/Application/PoolManager.ts +167 -17
- 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 +79 -2
- 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/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,218 @@ 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
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
|
+
default:
|
|
524
|
+
if (this.missionQueue.isFull) {
|
|
525
|
+
throw new PoolExhaustedException(`Rocket Pool \u8207\u968A\u5217\u5747\u5DF2\u6EFF\uFF0C\u7121\u6CD5\u8655\u7406\u4EFB\u52D9 ${mission.id}`);
|
|
526
|
+
}
|
|
527
|
+
console.log(`[PoolManager] \u4EFB\u52D9 ${mission.id} \u9032\u5165\u7B49\u5F85\u968A\u5217`);
|
|
528
|
+
return this.missionQueue.enqueue(mission);
|
|
283
529
|
}
|
|
284
|
-
rocket.assignMission(mission);
|
|
285
|
-
await this.rocketRepository.save(rocket);
|
|
286
|
-
return rocket;
|
|
287
530
|
}
|
|
288
531
|
/**
|
|
289
532
|
* 回收指定任務的火箭
|
|
@@ -292,7 +535,7 @@ var PoolManager = class {
|
|
|
292
535
|
const allRockets = await this.rocketRepository.findAll();
|
|
293
536
|
const rocket = allRockets.find((r) => r.currentMission?.id === missionId);
|
|
294
537
|
if (!rocket) {
|
|
295
|
-
console.warn(`[
|
|
538
|
+
console.warn(`[PoolManager] \u627E\u4E0D\u5230\u5C6C\u65BC\u4EFB\u52D9 ${missionId} \u7684\u706B\u7BAD`);
|
|
296
539
|
return;
|
|
297
540
|
}
|
|
298
541
|
if (this.router && rocket.assignedDomain) {
|
|
@@ -305,30 +548,67 @@ var PoolManager = class {
|
|
|
305
548
|
rocket.finishRefurbishment();
|
|
306
549
|
}
|
|
307
550
|
await this.rocketRepository.save(rocket);
|
|
551
|
+
await this.processQueue();
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* 處理等待隊列中的任務
|
|
555
|
+
*/
|
|
556
|
+
async processQueue() {
|
|
557
|
+
const queuedItem = this.missionQueue.dequeue();
|
|
558
|
+
if (!queuedItem) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
const rocket = await this.rocketRepository.findIdle();
|
|
563
|
+
if (rocket) {
|
|
564
|
+
rocket.assignMission(queuedItem.mission);
|
|
565
|
+
await this.rocketRepository.save(rocket);
|
|
566
|
+
queuedItem.resolve(rocket);
|
|
567
|
+
console.log(`[PoolManager] \u968A\u5217\u4EFB\u52D9 ${queuedItem.mission.id} \u5DF2\u5206\u914D\u706B\u7BAD ${rocket.id}`);
|
|
568
|
+
} else {
|
|
569
|
+
console.warn(`[PoolManager] \u8655\u7406\u968A\u5217\u6642\u4ECD\u7121\u53EF\u7528\u706B\u7BAD\uFF0C\u4EFB\u52D9\u91CD\u65B0\u5165\u968A`);
|
|
570
|
+
this.missionQueue.enqueue(queuedItem.mission).then(queuedItem.resolve).catch(queuedItem.reject);
|
|
571
|
+
}
|
|
572
|
+
} catch (error) {
|
|
573
|
+
queuedItem.reject(error);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* 取得隊列統計資訊
|
|
578
|
+
*/
|
|
579
|
+
getQueueStats() {
|
|
580
|
+
return this.missionQueue.getStats();
|
|
308
581
|
}
|
|
309
582
|
};
|
|
310
583
|
|
|
311
584
|
// src/Application/RefurbishUnit.ts
|
|
312
585
|
var RefurbishUnit = class {
|
|
313
|
-
constructor(docker) {
|
|
586
|
+
constructor(docker, config) {
|
|
314
587
|
this.docker = docker;
|
|
588
|
+
this.config = { ...DEFAULT_REFURBISH_CONFIG, ...config };
|
|
315
589
|
}
|
|
590
|
+
config;
|
|
316
591
|
/**
|
|
317
592
|
* 執行火箭翻新邏輯
|
|
318
593
|
*/
|
|
319
594
|
async refurbish(rocket) {
|
|
320
|
-
console.log(
|
|
595
|
+
console.log(
|
|
596
|
+
`[RefurbishUnit] \u6B63\u5728\u7FFB\u65B0\u706B\u7BAD: ${rocket.id} (\u7B56\u7565: ${this.config.strategy}, \u5BB9\u5668: ${rocket.containerId})`
|
|
597
|
+
);
|
|
321
598
|
rocket.splashDown();
|
|
322
599
|
try {
|
|
323
|
-
const
|
|
324
|
-
const
|
|
600
|
+
const commands = this.config.cleanupCommands ?? [];
|
|
601
|
+
const fullCommand = commands.join(" && ");
|
|
602
|
+
const result = await this.docker.executeCommand(rocket.containerId, ["sh", "-c", fullCommand]);
|
|
325
603
|
if (result.exitCode !== 0) {
|
|
326
604
|
console.error(`[RefurbishUnit] \u6E05\u7406\u5931\u6557: ${result.stderr}`);
|
|
327
|
-
|
|
328
|
-
|
|
605
|
+
if (this.config.failureAction === "decommission") {
|
|
606
|
+
rocket.decommission();
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
329
609
|
}
|
|
330
610
|
rocket.finishRefurbishment();
|
|
331
|
-
console.log(`[RefurbishUnit] \u706B\u7BAD ${rocket.id} \u7FFB\u65B0\u5B8C\u6210\uFF0C\u5DF2\u9032\u5165 IDLE \u72C0\u614B
|
|
611
|
+
console.log(`[RefurbishUnit] \u706B\u7BAD ${rocket.id} \u7FFB\u65B0\u5B8C\u6210\uFF0C\u5DF2\u9032\u5165 IDLE \u72C0\u614B`);
|
|
332
612
|
} catch (error) {
|
|
333
613
|
console.error(`[RefurbishUnit] \u56DE\u6536\u904E\u7A0B\u767C\u751F\u7570\u5E38:`, error);
|
|
334
614
|
rocket.decommission();
|
|
@@ -361,8 +641,15 @@ import { getRuntimeAdapter } from "@gravito/core";
|
|
|
361
641
|
var DockerAdapter = class {
|
|
362
642
|
baseImage = "oven/bun:1.0-slim";
|
|
363
643
|
runtime = getRuntimeAdapter();
|
|
644
|
+
// 快取目錄配置
|
|
645
|
+
cacheConfig = {
|
|
646
|
+
hostCachePath: process.env.BUN_CACHE_PATH || `${process.env.HOME}/.bun/install/cache`,
|
|
647
|
+
containerCachePathRoot: "/root/.bun/install/cache",
|
|
648
|
+
containerCachePathBun: "/home/bun/.bun/install/cache"
|
|
649
|
+
};
|
|
364
650
|
async createBaseContainer() {
|
|
365
651
|
const rocketId = `rocket-${crypto.randomUUID()}`;
|
|
652
|
+
await this.ensureCacheDirectory();
|
|
366
653
|
const proc = this.runtime.spawn([
|
|
367
654
|
"docker",
|
|
368
655
|
"run",
|
|
@@ -374,10 +661,39 @@ var DockerAdapter = class {
|
|
|
374
661
|
"-p",
|
|
375
662
|
"3000",
|
|
376
663
|
// 讓 Docker 分配隨機宿主機埠
|
|
664
|
+
// === 快取掛載(關鍵) ===
|
|
377
665
|
"-v",
|
|
378
|
-
`${
|
|
666
|
+
`${this.cacheConfig.hostCachePath}:${this.cacheConfig.containerCachePathRoot}:rw`,
|
|
379
667
|
"-v",
|
|
380
|
-
`${
|
|
668
|
+
`${this.cacheConfig.hostCachePath}:${this.cacheConfig.containerCachePathBun}:rw`,
|
|
669
|
+
// === 環境變數配置(確保 bun 使用快取) ===
|
|
670
|
+
"-e",
|
|
671
|
+
`BUN_INSTALL_CACHE_DIR=${this.cacheConfig.containerCachePathBun}`,
|
|
672
|
+
"-e",
|
|
673
|
+
"BUN_INSTALL_CACHE=shared",
|
|
674
|
+
"-e",
|
|
675
|
+
"BUN_INSTALL_PREFER_OFFLINE=true",
|
|
676
|
+
// === 效能相關環境變數 ===
|
|
677
|
+
"-e",
|
|
678
|
+
"NODE_ENV=development",
|
|
679
|
+
// === 資源限制(防止單一容器占用過多資源) ===
|
|
680
|
+
"--memory",
|
|
681
|
+
"1g",
|
|
682
|
+
"--memory-swap",
|
|
683
|
+
"1g",
|
|
684
|
+
"--cpus",
|
|
685
|
+
"1.0",
|
|
686
|
+
// === 安全設定 ===
|
|
687
|
+
"--security-opt",
|
|
688
|
+
"no-new-privileges:true",
|
|
689
|
+
"--cap-drop",
|
|
690
|
+
"ALL",
|
|
691
|
+
"--cap-add",
|
|
692
|
+
"CHOWN",
|
|
693
|
+
"--cap-add",
|
|
694
|
+
"SETUID",
|
|
695
|
+
"--cap-add",
|
|
696
|
+
"SETGID",
|
|
381
697
|
this.baseImage,
|
|
382
698
|
"tail",
|
|
383
699
|
"-f",
|
|
@@ -387,6 +703,7 @@ var DockerAdapter = class {
|
|
|
387
703
|
const containerId = stdout.trim();
|
|
388
704
|
const exitCode = await proc.exited;
|
|
389
705
|
if (containerId.length === 64 && /^[0-9a-f]+$/.test(containerId)) {
|
|
706
|
+
await this.verifyCacheMount(containerId);
|
|
390
707
|
return containerId;
|
|
391
708
|
}
|
|
392
709
|
if (exitCode !== 0) {
|
|
@@ -395,6 +712,37 @@ var DockerAdapter = class {
|
|
|
395
712
|
}
|
|
396
713
|
return containerId;
|
|
397
714
|
}
|
|
715
|
+
/**
|
|
716
|
+
* 確保快取目錄存在
|
|
717
|
+
*
|
|
718
|
+
* @private
|
|
719
|
+
* @since 1.3.0
|
|
720
|
+
*/
|
|
721
|
+
async ensureCacheDirectory() {
|
|
722
|
+
const cachePath = this.cacheConfig.hostCachePath;
|
|
723
|
+
const proc = this.runtime.spawn(["mkdir", "-p", cachePath]);
|
|
724
|
+
await proc.exited;
|
|
725
|
+
const chmodProc = this.runtime.spawn(["chmod", "777", cachePath]);
|
|
726
|
+
await chmodProc.exited;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* 驗證快取是否正確掛載
|
|
730
|
+
*
|
|
731
|
+
* @private
|
|
732
|
+
* @since 1.3.0
|
|
733
|
+
*/
|
|
734
|
+
async verifyCacheMount(containerId) {
|
|
735
|
+
const result = await this.executeCommand(containerId, [
|
|
736
|
+
"sh",
|
|
737
|
+
"-c",
|
|
738
|
+
`ls -la ${this.cacheConfig.containerCachePathBun} 2>/dev/null || echo 'NOT_MOUNTED'`
|
|
739
|
+
]);
|
|
740
|
+
if (result.stdout.includes("NOT_MOUNTED")) {
|
|
741
|
+
console.warn(`[DockerAdapter] \u8B66\u544A: \u5BB9\u5668 ${containerId} \u7684\u5FEB\u53D6\u76EE\u9304\u53EF\u80FD\u672A\u6B63\u78BA\u639B\u8F09`);
|
|
742
|
+
} else {
|
|
743
|
+
console.log(`[DockerAdapter] \u5FEB\u53D6\u76EE\u9304\u5DF2\u78BA\u8A8D\u639B\u8F09: ${containerId}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
398
746
|
async getExposedPort(containerId, containerPort = 3e3) {
|
|
399
747
|
const proc = this.runtime.spawn(["docker", "port", containerId, containerPort.toString()]);
|
|
400
748
|
const stdout = await new Response(proc.stdout ?? null).text();
|
|
@@ -774,13 +1122,25 @@ async function bootstrapLaunchpad() {
|
|
|
774
1122
|
};
|
|
775
1123
|
}
|
|
776
1124
|
export {
|
|
1125
|
+
DEFAULT_POOL_CONFIG,
|
|
1126
|
+
DEFAULT_REFURBISH_CONFIG,
|
|
777
1127
|
LaunchpadOrbit,
|
|
778
1128
|
Mission,
|
|
1129
|
+
MissionAssigned,
|
|
779
1130
|
MissionControl,
|
|
1131
|
+
MissionQueue,
|
|
1132
|
+
MissionQueueTimeout,
|
|
1133
|
+
MissionQueued,
|
|
780
1134
|
PayloadInjector,
|
|
1135
|
+
PoolExhausted,
|
|
1136
|
+
PoolExhaustedException,
|
|
781
1137
|
PoolManager,
|
|
1138
|
+
QueueTimeoutException,
|
|
782
1139
|
RefurbishUnit,
|
|
1140
|
+
RefurbishmentCompleted,
|
|
783
1141
|
Rocket,
|
|
1142
|
+
RocketIgnited,
|
|
1143
|
+
RocketSplashedDown,
|
|
784
1144
|
RocketStatus,
|
|
785
1145
|
bootstrapLaunchpad
|
|
786
1146
|
};
|
package/package.json
CHANGED
|
@@ -1,23 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/launchpad",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.2",
|
|
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
|
-
"@gravito/enterprise": "
|
|
17
|
-
"@gravito/ripple": "
|
|
18
|
-
"@gravito/stasis": "
|
|
18
|
+
"@gravito/enterprise": "^1.0.4",
|
|
19
|
+
"@gravito/ripple": "^4.0.1",
|
|
20
|
+
"@gravito/stasis": "^3.1.1",
|
|
19
21
|
"@octokit/rest": "^22.0.1",
|
|
20
|
-
"@gravito/core": "
|
|
22
|
+
"@gravito/core": "^1.6.1"
|
|
21
23
|
},
|
|
22
24
|
"devDependencies": {
|
|
23
25
|
"bun-types": "^1.3.5",
|
|
@@ -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}%.`)
|