unitup 0.2.0 → 0.3.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/.agents/AGENTS.md +35 -0
- package/.agents/skills/multi-runtime-management/SKILL.md +80 -0
- package/.agents/skills/platform-service-adapters/SKILL.md +108 -0
- package/.agents/skills/unitup-development-and-qa/SKILL.md +88 -0
- package/.agents/skills/zero-downtime-deployment/SKILL.md +135 -0
- package/index.d.ts +136 -0
- package/package.json +1 -1
- package/src/cli.js +374 -2
- package/src/core/drain-manager.js +70 -0
- package/src/core/generation-manager.js +414 -0
- package/src/core/index.js +8 -0
- package/src/core/ipc.js +236 -0
- package/src/core/lock.js +133 -0
- package/src/core/process-manager.js +313 -0
- package/src/core/readiness.js +243 -0
- package/src/core/router.js +390 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +407 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +186 -0
- package/src/index.js +54 -0
- package/src/platform/macos.js +4 -1
- package/src/platform/windows.js +4 -1
- package/src/service/config.js +8 -0
- package/src/service/normalize.js +30 -1
- package/src/unit.js +5 -1
- package/src/utils.js +80 -0
package/src/cli.js
CHANGED
|
@@ -36,7 +36,10 @@ import {
|
|
|
36
36
|
} from './systemd.js';
|
|
37
37
|
import {
|
|
38
38
|
findProjectConfig,
|
|
39
|
+
formatDuration,
|
|
40
|
+
formatRelativeTime,
|
|
39
41
|
formatTable,
|
|
42
|
+
parseDurationMs,
|
|
40
43
|
readGlobalConfig,
|
|
41
44
|
readProjectConfig,
|
|
42
45
|
sanitizeServiceName,
|
|
@@ -44,6 +47,13 @@ import {
|
|
|
44
47
|
saveProjectConfig,
|
|
45
48
|
validateMemorySize
|
|
46
49
|
} from './utils.js';
|
|
50
|
+
import { GenerationManager } from './core/generation-manager.js';
|
|
51
|
+
import { IPCClient } from './core/ipc.js';
|
|
52
|
+
import { Supervisor } from './core/supervisor.js';
|
|
53
|
+
import {
|
|
54
|
+
defaultDeploymentManager as deploymentManager,
|
|
55
|
+
defaultRollbackManager as rollbackManager
|
|
56
|
+
} from './deploy/index.js';
|
|
47
57
|
|
|
48
58
|
/**
|
|
49
59
|
* Custom light CLI argument parser without runtime dependencies.
|
|
@@ -102,7 +112,13 @@ export function parseArgs(argv) {
|
|
|
102
112
|
onBoot: '',
|
|
103
113
|
onActive: '',
|
|
104
114
|
randomDelay: '',
|
|
105
|
-
persistent: false
|
|
115
|
+
persistent: false,
|
|
116
|
+
port: '',
|
|
117
|
+
zeroDowntime: false,
|
|
118
|
+
ready: '',
|
|
119
|
+
drain: '',
|
|
120
|
+
canary: false,
|
|
121
|
+
weight: ''
|
|
106
122
|
}
|
|
107
123
|
};
|
|
108
124
|
|
|
@@ -354,6 +370,36 @@ export function parseArgs(argv) {
|
|
|
354
370
|
} else if (arg === '--persistent') {
|
|
355
371
|
result.flags.persistent = true;
|
|
356
372
|
i++;
|
|
373
|
+
} else if (arg === '--port') {
|
|
374
|
+
result.flags.port = argv[i + 1] || '';
|
|
375
|
+
i += 2;
|
|
376
|
+
} else if (arg.startsWith('--port=')) {
|
|
377
|
+
result.flags.port = arg.slice(7);
|
|
378
|
+
i++;
|
|
379
|
+
} else if (arg === '--zero-downtime') {
|
|
380
|
+
result.flags.zeroDowntime = true;
|
|
381
|
+
i++;
|
|
382
|
+
} else if (arg === '--ready') {
|
|
383
|
+
result.flags.ready = argv[i + 1] || '';
|
|
384
|
+
i += 2;
|
|
385
|
+
} else if (arg.startsWith('--ready=')) {
|
|
386
|
+
result.flags.ready = arg.slice(8);
|
|
387
|
+
i++;
|
|
388
|
+
} else if (arg === '--drain') {
|
|
389
|
+
result.flags.drain = argv[i + 1] || '';
|
|
390
|
+
i += 2;
|
|
391
|
+
} else if (arg.startsWith('--drain=')) {
|
|
392
|
+
result.flags.drain = arg.slice(8);
|
|
393
|
+
i++;
|
|
394
|
+
} else if (arg === '--canary') {
|
|
395
|
+
result.flags.canary = true;
|
|
396
|
+
i++;
|
|
397
|
+
} else if (arg === '--weight') {
|
|
398
|
+
result.flags.weight = argv[i + 1] || '';
|
|
399
|
+
i += 2;
|
|
400
|
+
} else if (arg.startsWith('--weight=')) {
|
|
401
|
+
result.flags.weight = arg.slice(9);
|
|
402
|
+
i++;
|
|
357
403
|
} else if (!arg.startsWith('-')) {
|
|
358
404
|
if (!result.command) {
|
|
359
405
|
result.command = arg;
|
|
@@ -380,6 +426,10 @@ Usage:
|
|
|
380
426
|
unitup start <name|@group> Start a service (--enable to enable on boot)
|
|
381
427
|
unitup stop <name|@group> Stop a service
|
|
382
428
|
unitup restart <name|@group> Restart a service
|
|
429
|
+
unitup deploy <name> Perform zero-downtime deployment (--canary, --weight 10%)
|
|
430
|
+
unitup rollback <name> Roll back a service to its previous generation or abort canary
|
|
431
|
+
unitup promote <name> Promote a canary generation to 100% active
|
|
432
|
+
unitup generations <name> List all runtime generations and ports for a service
|
|
383
433
|
unitup status <name|@group> Show status summary (--raw, --verbose)
|
|
384
434
|
unitup enable <name> Enable service on startup/boot
|
|
385
435
|
unitup disable <name> Disable service on startup/boot
|
|
@@ -613,7 +663,14 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
613
663
|
memoryMax: memoryMaxFlag,
|
|
614
664
|
memorySwapMax: memorySwapMaxFlag,
|
|
615
665
|
defaultMemory: flags.defaultMemory,
|
|
616
|
-
force: flags.force
|
|
666
|
+
force: flags.force,
|
|
667
|
+
port: flags.port ? Number(flags.port) : projectCfg?.port,
|
|
668
|
+
deploy: {
|
|
669
|
+
...(projectCfg?.deploy || {}),
|
|
670
|
+
...(flags.zeroDowntime ? { zeroDowntime: true } : {}),
|
|
671
|
+
...(flags.ready ? { ready: flags.ready } : {}),
|
|
672
|
+
...(flags.drain ? { drain: flags.drain } : {})
|
|
673
|
+
}
|
|
617
674
|
};
|
|
618
675
|
|
|
619
676
|
if (flags.dryRun) {
|
|
@@ -696,6 +753,249 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
696
753
|
break;
|
|
697
754
|
}
|
|
698
755
|
|
|
756
|
+
case 'deploy': {
|
|
757
|
+
const nameArg = positionals[0];
|
|
758
|
+
if (!nameArg) {
|
|
759
|
+
throw new Error('Service name is required.\nExample: unitup deploy api');
|
|
760
|
+
}
|
|
761
|
+
const safeName = sanitizeServiceName(nameArg);
|
|
762
|
+
console.log(`${safeName}\n`);
|
|
763
|
+
|
|
764
|
+
const isCanary = flags.canary;
|
|
765
|
+
const weight = flags.weight ? Number(flags.weight.replace('%', '')) : undefined;
|
|
766
|
+
|
|
767
|
+
const onProgress = (evt) => {
|
|
768
|
+
if (evt.state === 'STARTING') {
|
|
769
|
+
console.log(`✓ generation #${evt.generation} started`);
|
|
770
|
+
} else if (evt.state === 'WAITING_READY') {
|
|
771
|
+
// waiting for readiness
|
|
772
|
+
} else if (evt.state === 'SETTING_CANARY') {
|
|
773
|
+
const pct = Math.round(evt.weight > 1 ? evt.weight : evt.weight * 100);
|
|
774
|
+
console.log('✓ ready');
|
|
775
|
+
console.log(`✓ canary traffic set: ${pct}% to generation #${evt.generation}`);
|
|
776
|
+
} else if (evt.state === 'SWITCHING') {
|
|
777
|
+
console.log('✓ ready');
|
|
778
|
+
console.log('✓ traffic switched');
|
|
779
|
+
} else if (evt.state === 'STOPPING_PREVIOUS') {
|
|
780
|
+
console.log('✓ previous generation drained\n');
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
const res = await deploymentManager.deploy(
|
|
785
|
+
safeName,
|
|
786
|
+
{},
|
|
787
|
+
{
|
|
788
|
+
publicPort: flags.port ? Number(flags.port) : undefined,
|
|
789
|
+
readyPath: flags.ready || undefined,
|
|
790
|
+
drainTimeout: flags.drain ? parseDurationMs(flags.drain) : undefined,
|
|
791
|
+
canary: isCanary,
|
|
792
|
+
weight,
|
|
793
|
+
onProgress
|
|
794
|
+
}
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
if (res.isCanary) {
|
|
798
|
+
const pct = Math.round(res.canaryWeight > 1 ? res.canaryWeight : res.canaryWeight * 100);
|
|
799
|
+
console.log(
|
|
800
|
+
`\ncanary: v${res.canaryGeneration} (${pct}%) active alongside v${res.activeGeneration} (${100 - pct}%)`
|
|
801
|
+
);
|
|
802
|
+
console.log(`Run "unitup promote ${safeName}" to shift 100% traffic to v${res.canaryGeneration}.`);
|
|
803
|
+
} else {
|
|
804
|
+
const prev = res.previousGeneration ? `v${res.previousGeneration}` : 'none';
|
|
805
|
+
const curr = `v${res.currentGeneration}`;
|
|
806
|
+
console.log(`${prev} -> ${curr}`);
|
|
807
|
+
console.log(`downtime: ${res.downtimeMs ?? 0}ms`);
|
|
808
|
+
}
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
case 'promote': {
|
|
813
|
+
const nameArg = positionals[0];
|
|
814
|
+
if (!nameArg) {
|
|
815
|
+
throw new Error('Service name is required.\nExample: unitup promote api');
|
|
816
|
+
}
|
|
817
|
+
const safeName = sanitizeServiceName(nameArg);
|
|
818
|
+
console.log(`Promoting canary for "${safeName}"...\n`);
|
|
819
|
+
|
|
820
|
+
const onProgress = (evt) => {
|
|
821
|
+
if (evt.state === 'PROMOTING') {
|
|
822
|
+
console.log(`✓ Promoting generation #${evt.generation} to 100%`);
|
|
823
|
+
} else if (evt.state === 'DRAINING') {
|
|
824
|
+
console.log(`✓ Draining previous generation #${evt.generation}`);
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const res = await deploymentManager.promote(
|
|
829
|
+
safeName,
|
|
830
|
+
{},
|
|
831
|
+
{
|
|
832
|
+
drainTimeout: flags.drain ? parseDurationMs(flags.drain) : undefined,
|
|
833
|
+
onProgress
|
|
834
|
+
}
|
|
835
|
+
);
|
|
836
|
+
|
|
837
|
+
console.log(`\n✓ Promoted generation #${res.promotedGeneration} to 100% active.`);
|
|
838
|
+
if (res.previousGeneration) {
|
|
839
|
+
console.log(`v${res.previousGeneration} -> v${res.promotedGeneration}`);
|
|
840
|
+
}
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
case 'rollback': {
|
|
845
|
+
const nameArg = positionals[0];
|
|
846
|
+
if (!nameArg) {
|
|
847
|
+
throw new Error('Service name is required.\nExample: unitup rollback api');
|
|
848
|
+
}
|
|
849
|
+
const safeName = sanitizeServiceName(nameArg);
|
|
850
|
+
console.log(`Rolling back "${safeName}"...\n`);
|
|
851
|
+
|
|
852
|
+
const onProgress = (evt) => {
|
|
853
|
+
if (evt.state === 'ABORTING_CANARY') {
|
|
854
|
+
console.log(`✓ Aborting and draining canary generation #${evt.canaryGeneration}`);
|
|
855
|
+
} else if (evt.state === 'SWITCHING') {
|
|
856
|
+
console.log(`✓ Traffic switched back to generation #${evt.targetGeneration}`);
|
|
857
|
+
} else if (evt.state === 'DRAINING_CURRENT') {
|
|
858
|
+
console.log(`✓ Draining generation #${evt.generation}`);
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
const res = await rollbackManager.rollback(
|
|
863
|
+
safeName,
|
|
864
|
+
{},
|
|
865
|
+
{
|
|
866
|
+
readyPath: flags.ready || undefined,
|
|
867
|
+
drainTimeout: flags.drain ? parseDurationMs(flags.drain) : undefined,
|
|
868
|
+
onProgress
|
|
869
|
+
}
|
|
870
|
+
);
|
|
871
|
+
|
|
872
|
+
if (res.abortedCanary) {
|
|
873
|
+
console.log(
|
|
874
|
+
`\n✓ Aborted canary generation #${res.abortedCanary}. 100% traffic retained on generation #${res.activeGeneration}.`
|
|
875
|
+
);
|
|
876
|
+
} else {
|
|
877
|
+
console.log(`\n✓ Rolled back service "${safeName}" to generation #${res.activeGeneration}.`);
|
|
878
|
+
}
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
case 'supervisor': {
|
|
883
|
+
const nameArg = positionals[0];
|
|
884
|
+
if (!nameArg) {
|
|
885
|
+
throw new Error('Service name is required for supervisor mode.\nExample: unitup supervisor api');
|
|
886
|
+
}
|
|
887
|
+
await Supervisor.run(nameArg);
|
|
888
|
+
break;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
case 'history':
|
|
892
|
+
case 'generations': {
|
|
893
|
+
const nameArg = positionals[0];
|
|
894
|
+
if (!nameArg) {
|
|
895
|
+
throw new Error('Service name is required.\nExample: unitup generations api');
|
|
896
|
+
}
|
|
897
|
+
const safeName = sanitizeServiceName(nameArg);
|
|
898
|
+
const gm = new GenerationManager();
|
|
899
|
+
let list = gm.listGenerations(safeName);
|
|
900
|
+
|
|
901
|
+
// Check if supervisor is running via IPC to enrich with live in-flight data
|
|
902
|
+
let liveStatus = null;
|
|
903
|
+
const ipcClient = new IPCClient(safeName);
|
|
904
|
+
if (await ipcClient.isAlive()) {
|
|
905
|
+
try {
|
|
906
|
+
liveStatus = await ipcClient.send({ action: 'status' }, null, 1000);
|
|
907
|
+
if (liveStatus?.generations) {
|
|
908
|
+
list = liveStatus.generations;
|
|
909
|
+
}
|
|
910
|
+
} catch {
|
|
911
|
+
// fallback to local state
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (!list || list.length === 0) {
|
|
916
|
+
if (flags.json) {
|
|
917
|
+
console.log(JSON.stringify([], null, 2));
|
|
918
|
+
} else {
|
|
919
|
+
console.log(`No generations recorded for service "${safeName}".`);
|
|
920
|
+
}
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const tableData = list.map((g) => {
|
|
925
|
+
let activeFor = '-';
|
|
926
|
+
if (g.status === 'active') {
|
|
927
|
+
const start = g.activatedAt
|
|
928
|
+
? new Date(g.activatedAt).getTime()
|
|
929
|
+
: g.createdAt
|
|
930
|
+
? new Date(g.createdAt).getTime()
|
|
931
|
+
: null;
|
|
932
|
+
if (start && !Number.isNaN(start)) {
|
|
933
|
+
activeFor = formatDuration(Date.now() - start);
|
|
934
|
+
}
|
|
935
|
+
} else if (g.activatedAt) {
|
|
936
|
+
const start = new Date(g.activatedAt).getTime();
|
|
937
|
+
const end = g.deactivatedAt
|
|
938
|
+
? new Date(g.deactivatedAt).getTime()
|
|
939
|
+
: g.stoppedAt
|
|
940
|
+
? new Date(g.stoppedAt).getTime()
|
|
941
|
+
: g.drainingAt
|
|
942
|
+
? new Date(g.drainingAt).getTime()
|
|
943
|
+
: null;
|
|
944
|
+
if (start && end && !Number.isNaN(start) && !Number.isNaN(end) && end >= start) {
|
|
945
|
+
activeFor = formatDuration(end - start);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
return {
|
|
950
|
+
gen: `#${g.id}`,
|
|
951
|
+
status: g.status || 'unknown',
|
|
952
|
+
pid: g.pid ? String(g.pid) : '-',
|
|
953
|
+
port: g.internalPort ? String(g.internalPort) : '-',
|
|
954
|
+
created: formatRelativeTime(g.createdAt),
|
|
955
|
+
activeFor,
|
|
956
|
+
raw: g
|
|
957
|
+
};
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
if (flags.json) {
|
|
961
|
+
console.log(
|
|
962
|
+
JSON.stringify(
|
|
963
|
+
tableData.map((d) => ({
|
|
964
|
+
id: d.raw.id,
|
|
965
|
+
status: d.raw.status,
|
|
966
|
+
pid: d.raw.pid,
|
|
967
|
+
internalPort: d.raw.internalPort,
|
|
968
|
+
createdAt: d.raw.createdAt,
|
|
969
|
+
activatedAt: d.raw.activatedAt || null,
|
|
970
|
+
stoppedAt: d.raw.stoppedAt || null,
|
|
971
|
+
deactivatedAt: d.raw.deactivatedAt || null,
|
|
972
|
+
activeFor: d.activeFor
|
|
973
|
+
})),
|
|
974
|
+
null,
|
|
975
|
+
2
|
|
976
|
+
)
|
|
977
|
+
);
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
console.log(`=== Generations: ${safeName} ===\n`);
|
|
982
|
+
if (liveStatus?.routerPort) {
|
|
983
|
+
console.log(`Public Router: :${liveStatus.routerPort}`);
|
|
984
|
+
console.log(`In-Flight Requests: ${liveStatus.inFlightRequests || 0}\n`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const table = formatTable(tableData, [
|
|
988
|
+
{ key: 'gen', label: 'GEN' },
|
|
989
|
+
{ key: 'status', label: 'STATUS' },
|
|
990
|
+
{ key: 'pid', label: 'PID' },
|
|
991
|
+
{ key: 'port', label: 'PORT' },
|
|
992
|
+
{ key: 'created', label: 'CREATED' },
|
|
993
|
+
{ key: 'activeFor', label: 'ACTIVE FOR' }
|
|
994
|
+
]);
|
|
995
|
+
console.log(table);
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
|
|
699
999
|
case 'enable': {
|
|
700
1000
|
const nameArg = positionals[0];
|
|
701
1001
|
if (!nameArg) {
|
|
@@ -738,6 +1038,56 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
738
1038
|
console.log(raw);
|
|
739
1039
|
} else {
|
|
740
1040
|
const status = await defaultManager.status(name, { system: flags.system });
|
|
1041
|
+
|
|
1042
|
+
// Enrich with generation info if available
|
|
1043
|
+
const gm = new GenerationManager();
|
|
1044
|
+
const genState = gm.loadState(name);
|
|
1045
|
+
let genDetails = null;
|
|
1046
|
+
if (genState?.activeGeneration || genState?.canaryGeneration) {
|
|
1047
|
+
const activeGen = gm.getActive(name);
|
|
1048
|
+
const canaryGen = gm.getCanary(name);
|
|
1049
|
+
genDetails = {
|
|
1050
|
+
activeGeneration: genState.activeGeneration,
|
|
1051
|
+
internalPort: activeGen?.internalPort || null,
|
|
1052
|
+
canaryGeneration: canaryGen?.id || null,
|
|
1053
|
+
canaryPort: canaryGen?.internalPort || null,
|
|
1054
|
+
canaryWeight: canaryGen?.canaryWeight || null,
|
|
1055
|
+
routerPort: null,
|
|
1056
|
+
inFlightRequests: 0
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
const ipcClient = new IPCClient(name);
|
|
1060
|
+
if (await ipcClient.isAlive()) {
|
|
1061
|
+
try {
|
|
1062
|
+
const live = await ipcClient.send({ action: 'status' }, null, 500);
|
|
1063
|
+
if (live?.routerPort) {
|
|
1064
|
+
genDetails.routerPort = live.routerPort;
|
|
1065
|
+
}
|
|
1066
|
+
if (live?.inFlightRequests !== undefined) {
|
|
1067
|
+
genDetails.inFlightRequests = live.inFlightRequests;
|
|
1068
|
+
}
|
|
1069
|
+
if (live?.canaryGeneration) {
|
|
1070
|
+
genDetails.canaryGeneration = live.canaryGeneration.id;
|
|
1071
|
+
genDetails.canaryPort = live.canaryGeneration.internalPort;
|
|
1072
|
+
}
|
|
1073
|
+
if (live?.canaryWeight !== undefined) {
|
|
1074
|
+
genDetails.canaryWeight = live.canaryWeight;
|
|
1075
|
+
}
|
|
1076
|
+
} catch {
|
|
1077
|
+
// ignore
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (flags.json) {
|
|
1083
|
+
const jsonObj = {
|
|
1084
|
+
...status,
|
|
1085
|
+
...(genDetails ? { deployment: genDetails } : {})
|
|
1086
|
+
};
|
|
1087
|
+
console.log(JSON.stringify(jsonObj, null, 2));
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
741
1091
|
console.log(`${status.name}\n`);
|
|
742
1092
|
console.log(`Status: ${status.status || status.state}`);
|
|
743
1093
|
console.log(`PID: ${status.pid}`);
|
|
@@ -746,6 +1096,28 @@ export async function runCli(argv = process.argv.slice(2)) {
|
|
|
746
1096
|
console.log(`Command: ${status.command}`);
|
|
747
1097
|
console.log(`Arguments: ${status.arguments}`);
|
|
748
1098
|
console.log(`Working directory: ${status.cwd}`);
|
|
1099
|
+
|
|
1100
|
+
if (genDetails) {
|
|
1101
|
+
if (genDetails.activeGeneration) {
|
|
1102
|
+
console.log(`Active Generation: #${genDetails.activeGeneration}`);
|
|
1103
|
+
}
|
|
1104
|
+
if (genDetails.canaryGeneration) {
|
|
1105
|
+
const pct = Math.round(
|
|
1106
|
+
genDetails.canaryWeight > 1 ? genDetails.canaryWeight : genDetails.canaryWeight * 100
|
|
1107
|
+
);
|
|
1108
|
+
console.log(
|
|
1109
|
+
`Canary: #${genDetails.canaryGeneration} (${pct}% traffic) -> :${genDetails.canaryPort || 'none'}`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
if (genDetails.routerPort) {
|
|
1113
|
+
const targetPort = genDetails.internalPort ? `:${genDetails.internalPort}` : 'none';
|
|
1114
|
+
console.log(`Router: :${genDetails.routerPort} -> ${targetPort}`);
|
|
1115
|
+
console.log(`In-Flight Requests: ${genDetails.inFlightRequests}`);
|
|
1116
|
+
} else if (genDetails.internalPort) {
|
|
1117
|
+
console.log(`Internal Port: ${genDetails.internalPort}`);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
749
1121
|
if (status.memory) console.log(`Memory: ${status.memory}`);
|
|
750
1122
|
if (status.memoryPeak) console.log(`Memory Peak: ${status.memoryPeak}`);
|
|
751
1123
|
if (status.memoryHigh) console.log(`Memory High: ${status.memoryHigh}`);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DrainManager is responsible for gracefully retiring a generation.
|
|
3
|
+
* It tracks in-flight requests, waits for them to complete up to a timeout,
|
|
4
|
+
* and terminates the process gracefully.
|
|
5
|
+
*
|
|
6
|
+
* It works independently from DeploymentManager.
|
|
7
|
+
*/
|
|
8
|
+
export class DrainManager {
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} options
|
|
11
|
+
* @param {import('./router.js').Router} options.router
|
|
12
|
+
* @param {import('./process-manager.js').ProcessManager} options.processManager
|
|
13
|
+
* @param {number} [options.defaultDrainTimeout=10000]
|
|
14
|
+
*/
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.router = options.router;
|
|
17
|
+
this.processManager = options.processManager;
|
|
18
|
+
this.defaultDrainTimeout = options.defaultDrainTimeout || 10000;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Drains an existing generation: waits for active in-flight requests to finish,
|
|
23
|
+
* then stops the process gracefully.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} generation - { id, pid, internalPort }
|
|
26
|
+
* @param {object} [options]
|
|
27
|
+
* @param {number} [options.timeout] - Max wait time in ms before termination
|
|
28
|
+
* @param {number} [options.pollInterval=50]
|
|
29
|
+
* @param {boolean} [options.stopProcess=true] - Whether to stop the process after draining
|
|
30
|
+
* @returns {Promise<{ drained: boolean, inFlightRemaining: number, timedOut: boolean }>}
|
|
31
|
+
*/
|
|
32
|
+
async drain(generation, options = {}) {
|
|
33
|
+
if (!generation) {
|
|
34
|
+
return { drained: true, inFlightRemaining: 0, timedOut: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const timeout = options.timeout !== undefined ? options.timeout : this.defaultDrainTimeout;
|
|
38
|
+
const pollInterval = options.pollInterval !== undefined ? options.pollInterval : 50;
|
|
39
|
+
const shouldStop = options.stopProcess !== false;
|
|
40
|
+
|
|
41
|
+
const startTime = Date.now();
|
|
42
|
+
let timedOut = false;
|
|
43
|
+
|
|
44
|
+
// 1. Wait for in-flight requests on this generation to complete
|
|
45
|
+
if (this.router) {
|
|
46
|
+
while (this.router.getInFlightCount(generation.id) > 0) {
|
|
47
|
+
if (Date.now() - startTime >= timeout) {
|
|
48
|
+
timedOut = true;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const inFlightRemaining = this.router ? this.router.getInFlightCount(generation.id) : 0;
|
|
56
|
+
|
|
57
|
+
// 2. Stop process if requested and process is still alive
|
|
58
|
+
if (shouldStop && generation.pid && this.processManager && this.processManager.isAlive(generation.pid)) {
|
|
59
|
+
const elapsed = Date.now() - startTime;
|
|
60
|
+
const remainingTimeout = Math.max(1000, timeout - elapsed);
|
|
61
|
+
await this.processManager.stop(generation.pid, { timeout: remainingTimeout });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
drained: inFlightRemaining === 0,
|
|
66
|
+
inFlightRemaining,
|
|
67
|
+
timedOut
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|