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/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
+ }