unitup 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/index.d.ts +183 -0
- package/package.json +5 -3
- 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 +244 -0
- package/src/core/router.js +507 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +388 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +187 -0
- package/src/index.js +54 -0
- package/src/platform/macos.js +4 -1
- package/src/platform/windows-host.js +1 -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 +29 -3
- package/src/utils.js +80 -0
- package/SECURITY.md +0 -21
- package/biome.json +0 -44
- package/docs/index.html +0 -587
- package/tsconfig.json +0 -13
- package/types-test/index.ts +0 -158
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Multi-Port Concurrent Routing**: Enhanced `Router` to listen on multiple distinct ports concurrently using an internal server map (`servers: Map<port, { server, port, host }>`), enabling multiple zero-downtime services to operate simultaneously without port collisions or premature listener termination.
|
|
14
|
+
- **Port-Scoped Traffic Isolation**: Added strict port-to-service mapping (`portServices: Map<port, Set<serviceName>>`) ensuring requests on port `A` are strictly isolated from services listening on port `B`.
|
|
15
|
+
- **Domain Normalization in Router**: Added hostname normalization (`hostNormalized`) to resolve incoming Host headers seamlessly against registered service names (e.g., `supanote.app` accurately resolves to service `supanoteapp`).
|
|
16
|
+
- **Automated GitHub Actions Release Pipeline**: Added `.github/workflows/release.yml` using Litepacks reusable release workflow with OIDC Trusted Publishing and provenance.
|
|
17
|
+
- **TypeScript Typecheck Script**: Added `npm run typecheck` (`tsc --noEmit`) to scripts for automated type validation in CI.
|
|
18
|
+
- **Package Distribution Optimization**: Configured `"files"` in `package.json` to publish only essential production files, reducing package size from 2.4 MB to 85 KB.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- **Eliminated Blind Cross-Service Fallback**: Removed indiscriminate fallback to other active backends when a Host header does not match; the router now safely returns `503 Service Unavailable` instead of cross-routing traffic to an unrelated backend.
|
|
22
|
+
- **Service Metadata Persistence**: `writeUnitFile` now explicitly preserves `port` and `deploy` configurations in `saveAppMetadata`, allowing systemd supervisors to retain their target public ports across daemon restarts.
|
|
23
|
+
- **Supervisor Public Port Resolution**: Updated `Supervisor` to reliably resolve public ports across `options.publicPort`, `config.port`, and `config.deploy?.port`.
|
|
24
|
+
- **Connection Draining on Shutdown**: Enhanced `Router.close()` to invoke `server.closeAllConnections()` for immediate and clean termination of idle keep-alive sockets.
|
|
25
|
+
|
|
26
|
+
|
|
10
27
|
## [0.1.5] - 2026-07-31
|
|
11
28
|
|
|
12
29
|
### Fixed
|
package/index.d.ts
CHANGED
|
@@ -85,6 +85,24 @@ export interface CreateServiceOptions {
|
|
|
85
85
|
resetAfter?: number | null;
|
|
86
86
|
};
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Maximum log level written to systemd journal.
|
|
90
|
+
* @default "info"
|
|
91
|
+
*/
|
|
92
|
+
logLevelMax?: string;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rate limit interval for systemd journal logging.
|
|
96
|
+
* @default "10s"
|
|
97
|
+
*/
|
|
98
|
+
logRateLimitIntervalSec?: string;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Maximum number of logs per interval before rate limiting kicks in.
|
|
102
|
+
* @default 200
|
|
103
|
+
*/
|
|
104
|
+
logRateLimitBurst?: number;
|
|
105
|
+
|
|
88
106
|
/**
|
|
89
107
|
* Positional arguments to pass to the script/command execution line.
|
|
90
108
|
*/
|
|
@@ -337,6 +355,20 @@ export class InvalidServiceConfigError extends UnitupError {
|
|
|
337
355
|
export class ExecutableNotFoundError extends UnitupError {
|
|
338
356
|
executable: string;
|
|
339
357
|
}
|
|
358
|
+
export class ReadinessTimeoutError extends Error {
|
|
359
|
+
generation: GenerationRecord;
|
|
360
|
+
}
|
|
361
|
+
export class ProcessStartupError extends Error {
|
|
362
|
+
generation: GenerationRecord;
|
|
363
|
+
}
|
|
364
|
+
export class PortMismatchError extends Error {
|
|
365
|
+
generation: GenerationRecord;
|
|
366
|
+
detectedPort: number;
|
|
367
|
+
}
|
|
368
|
+
export class DeploymentLockError extends Error {
|
|
369
|
+
service: string;
|
|
370
|
+
lockedPid?: number;
|
|
371
|
+
}
|
|
340
372
|
|
|
341
373
|
// Adapters
|
|
342
374
|
export abstract class ServiceAdapter {
|
|
@@ -443,6 +475,7 @@ export function formatTable(
|
|
|
443
475
|
columns: Array<{ key: string; label: string }>
|
|
444
476
|
): string;
|
|
445
477
|
export function formatMemoryBytes(bytes: number | string): string;
|
|
478
|
+
export function formatDuration(ms: number): string;
|
|
446
479
|
export function validateMemorySize(val: string | number, paramName?: string): string;
|
|
447
480
|
export function validateDuration(val: string | number, paramName?: string): string;
|
|
448
481
|
|
|
@@ -515,4 +548,154 @@ export function readScheduleMetadata(name: string): Record<string, unknown> | nu
|
|
|
515
548
|
export function getScheduleMetadataPath(name: string): string;
|
|
516
549
|
export function getSchedulesDir(): string;
|
|
517
550
|
|
|
551
|
+
// Zero-Downtime Deployment Primitives & Orchestration
|
|
552
|
+
export interface GenerationRecord {
|
|
553
|
+
id: number;
|
|
554
|
+
service: string;
|
|
555
|
+
pid: number;
|
|
556
|
+
internalPort: number;
|
|
557
|
+
status: 'starting' | 'active' | 'previous' | 'draining' | 'stopped' | 'canary' | string;
|
|
558
|
+
createdAt: string;
|
|
559
|
+
activatedAt?: string;
|
|
560
|
+
drainingAt?: string;
|
|
561
|
+
stoppedAt?: string;
|
|
562
|
+
canaryWeight?: number;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export interface DeploymentResult {
|
|
566
|
+
service: string;
|
|
567
|
+
previousGeneration: number | null;
|
|
568
|
+
currentGeneration: number;
|
|
569
|
+
downtimeMs: number;
|
|
570
|
+
status: string;
|
|
571
|
+
canaryWeight?: number;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
export interface PromoteResult {
|
|
575
|
+
service: string;
|
|
576
|
+
promotedGeneration: number;
|
|
577
|
+
previousGeneration: number | null;
|
|
578
|
+
downtimeMs: number;
|
|
579
|
+
status: string;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export interface RollbackResult {
|
|
583
|
+
service: string;
|
|
584
|
+
rolledBackFrom: number | null;
|
|
585
|
+
activeGeneration: number;
|
|
586
|
+
status: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function deploy(name: string, options?: Record<string, unknown>): Promise<DeploymentResult>;
|
|
590
|
+
export function promote(name: string, options?: Record<string, unknown>): Promise<PromoteResult>;
|
|
591
|
+
export function rollback(name: string, options?: Record<string, unknown>): Promise<RollbackResult>;
|
|
592
|
+
export function generations(name: string, options?: Record<string, unknown>): GenerationRecord[];
|
|
593
|
+
|
|
594
|
+
export class ProcessManager {
|
|
595
|
+
constructor(options?: Record<string, unknown>);
|
|
596
|
+
start(
|
|
597
|
+
command: string,
|
|
598
|
+
args?: string[],
|
|
599
|
+
options?: Record<string, unknown>
|
|
600
|
+
): { pid: number; child: any; exitPromise: Promise<any> };
|
|
601
|
+
stop(processOrPid: number | object, options?: { timeout?: number; signal?: string }): Promise<boolean>;
|
|
602
|
+
signal(processOrPid: number | object, signal?: string): boolean;
|
|
603
|
+
isAlive(processOrPid: number | object): boolean;
|
|
604
|
+
waitForExit(
|
|
605
|
+
processOrPid: number | object,
|
|
606
|
+
timeout?: number
|
|
607
|
+
): Promise<{ exited: boolean; code?: number | null; signal?: string | null }>;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export class GenerationManager {
|
|
611
|
+
constructor(options?: Record<string, unknown>);
|
|
612
|
+
create(
|
|
613
|
+
serviceName: string,
|
|
614
|
+
config?: Record<string, unknown>,
|
|
615
|
+
options?: Record<string, unknown>
|
|
616
|
+
): Promise<GenerationRecord>;
|
|
617
|
+
getActive(serviceName: string): GenerationRecord | null;
|
|
618
|
+
getPrevious(serviceName: string): GenerationRecord | null;
|
|
619
|
+
getCanary(serviceName: string): GenerationRecord | null;
|
|
620
|
+
getGeneration(serviceName: string, id: number | string): GenerationRecord | null;
|
|
621
|
+
markActive(generation: GenerationRecord): void;
|
|
622
|
+
markCanary(generation: GenerationRecord, weight?: number | string): void;
|
|
623
|
+
markDraining(generation: GenerationRecord): void;
|
|
624
|
+
remove(generation: GenerationRecord, purge?: boolean): void;
|
|
625
|
+
cleanupStale(serviceName: string): number;
|
|
626
|
+
listGenerations(serviceName: string): GenerationRecord[];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export class Router {
|
|
630
|
+
constructor(options?: Record<string, unknown>);
|
|
631
|
+
listen(publicPort: number, host?: string): Promise<{ port: number; host: string }>;
|
|
632
|
+
switchBackend(serviceName: string, generation: { id: number; internalPort: number }): void;
|
|
633
|
+
getActiveBackend(serviceName: string): GenerationRecord | null;
|
|
634
|
+
setCanary(serviceName: string, generation: { id: number; internalPort: number }, weight?: number | string): void;
|
|
635
|
+
clearCanary(serviceName: string): void;
|
|
636
|
+
promoteCanary(serviceName: string): void;
|
|
637
|
+
getCanary(serviceName: string): { generation: { id: number; internalPort: number }; weight: number } | null;
|
|
638
|
+
resolveBackend(serviceName: string, req?: any): { id: number; internalPort: number } | null;
|
|
639
|
+
getInFlightCount(generationId: number | string): number;
|
|
640
|
+
close(): Promise<void>;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export class ReadinessChecker {
|
|
644
|
+
constructor(options?: Record<string, unknown>);
|
|
645
|
+
checkTcp(port: number, host?: string, timeout?: number): Promise<boolean>;
|
|
646
|
+
checkHttp(port: number, path?: string, timeout?: number): Promise<boolean>;
|
|
647
|
+
ensure(generation: GenerationRecord, options?: { path?: string }): Promise<boolean>;
|
|
648
|
+
wait(
|
|
649
|
+
generation: GenerationRecord,
|
|
650
|
+
options?: { timeout?: number; interval?: number; path?: string }
|
|
651
|
+
): Promise<boolean>;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export class DrainManager {
|
|
655
|
+
constructor(options?: Record<string, unknown>);
|
|
656
|
+
drain(
|
|
657
|
+
generation: GenerationRecord,
|
|
658
|
+
options?: { timeout?: number; pollInterval?: number; stopProcess?: boolean }
|
|
659
|
+
): Promise<{ drained: boolean; inFlightRemaining: number; timedOut: boolean }>;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
export class DeploymentLock {
|
|
663
|
+
constructor(options?: Record<string, unknown>);
|
|
664
|
+
acquire(serviceName: string): Promise<boolean>;
|
|
665
|
+
release(serviceName: string): Promise<boolean>;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export class DeploymentManager {
|
|
669
|
+
constructor(options?: Record<string, unknown>);
|
|
670
|
+
deploy(
|
|
671
|
+
serviceName: string,
|
|
672
|
+
serviceConfig?: Record<string, unknown>,
|
|
673
|
+
options?: Record<string, unknown>
|
|
674
|
+
): Promise<DeploymentResult>;
|
|
675
|
+
promote(
|
|
676
|
+
serviceName: string,
|
|
677
|
+
serviceConfig?: Record<string, unknown>,
|
|
678
|
+
options?: Record<string, unknown>
|
|
679
|
+
): Promise<PromoteResult>;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export class RollbackManager {
|
|
683
|
+
constructor(options?: Record<string, unknown>);
|
|
684
|
+
rollback(
|
|
685
|
+
serviceName: string,
|
|
686
|
+
serviceConfig?: Record<string, unknown>,
|
|
687
|
+
options?: Record<string, unknown>
|
|
688
|
+
): Promise<RollbackResult>;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export class Supervisor {
|
|
692
|
+
constructor(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>);
|
|
693
|
+
start(): Promise<Supervisor>;
|
|
694
|
+
stop(): Promise<void>;
|
|
695
|
+
static run(serviceName: string): Promise<void>;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export const defaultDeploymentManager: DeploymentManager;
|
|
699
|
+
export const defaultRollbackManager: RollbackManager;
|
|
700
|
+
|
|
518
701
|
export default defaultManager;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Minimal, zero-dependency cross-platform native service manager (systemd, launchd, Windows Services) for any runtime, script, or executable",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"test": "node --test",
|
|
13
13
|
"lint": "biome check .",
|
|
14
14
|
"format": "biome format --write .",
|
|
15
|
-
"check": "biome check ."
|
|
15
|
+
"check": "biome check .",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
18
19
|
"@biomejs/biome": "^1.9.4",
|
|
@@ -56,5 +57,6 @@
|
|
|
56
57
|
"homepage": "https://github.com/litepacks/unitup#readme",
|
|
57
58
|
"engines": {
|
|
58
59
|
"node": ">=20.0.0"
|
|
59
|
-
}
|
|
60
|
+
},
|
|
61
|
+
"files": ["bin", "src", "index.d.ts", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
60
62
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import readline from 'node:readline';
|
|
4
|
+
import { GenerationManager } from './core/generation-manager.js';
|
|
5
|
+
import { IPCClient } from './core/ipc.js';
|
|
6
|
+
import { Supervisor } from './core/supervisor.js';
|
|
7
|
+
import {
|
|
8
|
+
defaultDeploymentManager as deploymentManager,
|
|
9
|
+
defaultRollbackManager as rollbackManager
|
|
10
|
+
} from './deploy/index.js';
|
|
4
11
|
import { runDoctor } from './doctor.js';
|
|
5
12
|
import { getAdapter } from './platform/index.js';
|
|
6
13
|
import {
|
|
@@ -36,7 +43,10 @@ import {
|
|
|
36
43
|
} from './systemd.js';
|
|
37
44
|
import {
|
|
38
45
|
findProjectConfig,
|
|
46
|
+
formatDuration,
|
|
47
|
+
formatRelativeTime,
|
|
39
48
|
formatTable,
|
|
49
|
+
parseDurationMs,
|
|
40
50
|
readGlobalConfig,
|
|
41
51
|
readProjectConfig,
|
|
42
52
|
sanitizeServiceName,
|
|
@@ -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}`);
|