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
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Unitup Workspace Guidelines for AI Agents
|
|
2
|
+
|
|
3
|
+
Welcome to **Unitup** — a lightweight, zero-dependency, cross-platform process manager and zero-downtime deployment orchestration engine.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Core Invariants
|
|
8
|
+
|
|
9
|
+
1. **Zero External Dependencies**:
|
|
10
|
+
- Production code in `src/` must NEVER import anything outside Node.js built-ins (`node:*`).
|
|
11
|
+
- `package.json` must keep `"dependencies": {}`.
|
|
12
|
+
2. **Zero-Downtime Architectural Boundary**:
|
|
13
|
+
- Zero-downtime runtime primitives belong to `src/core/` (ProcessManager, Router, GenerationManager, ReadinessChecker, DrainManager, Supervisor, IPC).
|
|
14
|
+
- Deployment sequencing belongs to `src/deploy/` (DeploymentManager, RollbackManager).
|
|
15
|
+
- CLI (`src/cli.js`) is only a thin translation layer.
|
|
16
|
+
3. **Multi-OS Native Daemon Support**:
|
|
17
|
+
- Linux: systemd user/system services & timers.
|
|
18
|
+
- macOS: launchd LaunchAgents / LaunchDaemons `.plist`.
|
|
19
|
+
- Windows: Windows Services via `sc.exe` and `WindowsServiceHost`.
|
|
20
|
+
4. **Validation Triad**:
|
|
21
|
+
- Every code modification must pass:
|
|
22
|
+
```bash
|
|
23
|
+
npm test && npx tsc --noEmit && npx biome check src/ test/
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Workspace Skills Available
|
|
29
|
+
|
|
30
|
+
For deep-dive runbooks and procedures, activate the specialized skills in `.agents/skills/`:
|
|
31
|
+
|
|
32
|
+
- **[zero-downtime-deployment](file:///Users/ahmet/projects/unitup/.agents/skills/zero-downtime-deployment/SKILL.md)**: State machine, canary weighted routing, rollback, connection draining, and streaming protocol handling.
|
|
33
|
+
- **[platform-service-adapters](file:///Users/ahmet/projects/unitup/.agents/skills/platform-service-adapters/SKILL.md)**: Systemd, launchd, and Windows service integration, escaping, user lingering, and command runner mocking.
|
|
34
|
+
- **[multi-runtime-management](file:///Users/ahmet/projects/unitup/.agents/skills/multi-runtime-management/SKILL.md)**: Auto-detection and arguments for Node, Python, Bun, Deno, Go, Ruby, PHP, and generic binaries.
|
|
35
|
+
- **[unitup-development-and-qa](file:///Users/ahmet/projects/unitup/.agents/skills/unitup-development-and-qa/SKILL.md)**: Testing standards with `node:test`, TypeScript declaration sync, and Biome linting.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: multi-runtime-management
|
|
3
|
+
description: >-
|
|
4
|
+
Manage, extend, and test multi-runtime support in Unitup: Node.js, Python, Bun, Deno, Go, Ruby,
|
|
5
|
+
PHP, Elixir, and generic executables. Use when modifying runtime detection, shebang parsing,
|
|
6
|
+
argument building, environment variable normalization, or memory limits.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Multi-Runtime Management in Unitup
|
|
10
|
+
|
|
11
|
+
This skill details how Unitup automatically detects, configures, and executes services across different programming runtimes without requiring external dependencies or containers.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Runtime Architecture
|
|
16
|
+
|
|
17
|
+
Runtime logic resides in:
|
|
18
|
+
```text
|
|
19
|
+
src/runtimes/
|
|
20
|
+
├── base.js # BaseRuntime contract: detect(), getExecutable(), getArgs()
|
|
21
|
+
├── node.js # NodeRuntime (.js, .mjs, .cjs)
|
|
22
|
+
├── python.js # PythonRuntime (.py, python shebang, unbuffered -u flag)
|
|
23
|
+
├── bun.js # BunRuntime (.js, .ts with bun)
|
|
24
|
+
├── deno.js # DenoRuntime (.ts with deno, auto permission flags)
|
|
25
|
+
├── go.js # GoRuntime (.go -> go run, or compiled binaries)
|
|
26
|
+
├── ruby.js # RubyRuntime (.rb)
|
|
27
|
+
├── php.js # PHPRuntime (.php -> built-in web server or CLI)
|
|
28
|
+
├── shell.js # ShellRuntime (.sh, bash shebang)
|
|
29
|
+
├── elixir.js # ElixirRuntime (.ex, .exs)
|
|
30
|
+
├── generic.js # GenericExecutableRuntime (arbitrary command line)
|
|
31
|
+
└── index.js # detectRuntime(), resolveRuntimeConfig(), listSupportedRuntimes()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. Detection & Resolution Rules
|
|
37
|
+
|
|
38
|
+
### 1. File Extension Detection
|
|
39
|
+
- `.js`, `.mjs`, `.cjs` -> Node.js (or Bun/Deno if explicitly specified via `--runtime`)
|
|
40
|
+
- `.py` -> Python (`python3` or `python` with `-u` for unbuffered logs)
|
|
41
|
+
- `.rb` -> Ruby (`ruby`)
|
|
42
|
+
- `.php` -> PHP (`php`)
|
|
43
|
+
- `.sh` -> Shell (`bash` or `sh`)
|
|
44
|
+
- `.go` -> Go (`go run`)
|
|
45
|
+
- `.ex`, `.exs` -> Elixir (`elixir`)
|
|
46
|
+
|
|
47
|
+
### 2. TypeScript Ambiguity Rule
|
|
48
|
+
- `.ts` files **cannot** be auto-resolved to a single engine because both Bun and Deno execute TypeScript natively.
|
|
49
|
+
- An error is thrown requiring the user to specify `--runtime bun` or `--runtime deno`.
|
|
50
|
+
|
|
51
|
+
### 3. Shebang Header Detection
|
|
52
|
+
If a file has no extension or is executable:
|
|
53
|
+
- Reads the first 256 bytes of the file.
|
|
54
|
+
- Inspects `#!/usr/bin/env ...` or `#!/bin/...` to detect runtime (e.g. `python3`, `ruby`, `node`, `bash`).
|
|
55
|
+
|
|
56
|
+
### 4. Generic Command (`--command`)
|
|
57
|
+
- When `--command "my-binary --flag"` is provided:
|
|
58
|
+
- Bypasses runtime detection.
|
|
59
|
+
- Resolves executable using `resolveExecutable()`.
|
|
60
|
+
- Splits and preserves argument tokens safely without shell injection vulnerabilities.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 3. Normalization & App Metadata
|
|
65
|
+
|
|
66
|
+
[normalizeServiceConfig](file:///Users/ahmet/projects/unitup/src/service/normalize.js) converts raw CLI/API flags into a canonical service descriptor:
|
|
67
|
+
|
|
68
|
+
- Resolves absolute `cwd` (Working directory).
|
|
69
|
+
- Resolves executable path or validates binary exists in `$PATH`.
|
|
70
|
+
- Normalizes memory limits: parses `512M`, `1G`, `256MB` into bytes and native OS config strings.
|
|
71
|
+
- Reads and saves metadata in `~/.config/unitup/apps/<name>.json`.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 4. Testing Multi-Runtime Capabilities
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Run multi-runtime detection and argument builder tests
|
|
79
|
+
node --test test/multi-runtime.test.js
|
|
80
|
+
```
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: platform-service-adapters
|
|
3
|
+
description: >-
|
|
4
|
+
Implement, test, and debug cross-platform OS service adapters in Unitup: systemd (Linux),
|
|
5
|
+
launchd (macOS), and Windows Services (Windows). Use when working with service definition generators,
|
|
6
|
+
system commands (systemctl, launchctl, sc.exe), user vs. system scopes, linger, or timer units.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Cross-Platform Service Adapters in Unitup
|
|
10
|
+
|
|
11
|
+
This skill covers the implementation patterns, escaping rules, permission boundaries, and testing practices for Unitup's native OS service adapters.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Adapter Architecture
|
|
16
|
+
|
|
17
|
+
Unitup implements a unified interface across operating systems via [ServiceAdapter](file:///Users/ahmet/projects/unitup/src/platform/base.js):
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
src/platform/
|
|
21
|
+
├── base.js # Base ServiceAdapter abstract class with standard contract
|
|
22
|
+
├── linux.js # LinuxAdapter: systemd user & system units via systemctl
|
|
23
|
+
├── macos.js # MacOSAdapter: launchd launchd.plist via launchctl
|
|
24
|
+
├── windows.js # WindowsAdapter: Windows Service via sc.exe
|
|
25
|
+
├── windows-host.js # WindowsServiceHost child process manager for Windows
|
|
26
|
+
└── index.js # getAdapter(platform), getPlatformCapabilities()
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Standard Adapter Methods:
|
|
30
|
+
Each adapter implements:
|
|
31
|
+
- `getCapabilities()`: `{ systemd, launchd, windowsService, userUnits, systemUnits, timers, memoryLimits }`
|
|
32
|
+
- `generateService(config)`: Returns formatted service definition string
|
|
33
|
+
- `install(config, opts)`: Writes service file and registers with OS daemon
|
|
34
|
+
- `uninstall(name, opts)`: Unregisters service and deletes service file
|
|
35
|
+
- `start(name, opts)`, `stop(name, opts)`, `restart(name, opts)`
|
|
36
|
+
- `status(name, opts)`: Queries native service state (running, stopped, PID, uptime)
|
|
37
|
+
- `enable(name, opts)`, `disable(name, opts)`
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 2. Platform Specifics & Service Formats
|
|
42
|
+
|
|
43
|
+
### A. Linux (systemd)
|
|
44
|
+
- **User scope (default)**: `~/.config/systemd/user/unitup-<name>.service`
|
|
45
|
+
- Requires user lingering for persistence across logout: `loginctl enable-linger <user>` (verified via `checkUserLinger()`).
|
|
46
|
+
- Controlled with `systemctl --user`.
|
|
47
|
+
- **System scope**: `/etc/systemd/system/unitup-<name>.service`
|
|
48
|
+
- Requires root privileges; throws `PermissionRequiredError` if unprivileged.
|
|
49
|
+
- **Unit Content Structure**:
|
|
50
|
+
```ini
|
|
51
|
+
[Unit]
|
|
52
|
+
Description=Unitup Service - <name>
|
|
53
|
+
After=network.target
|
|
54
|
+
|
|
55
|
+
[Service]
|
|
56
|
+
Type=simple
|
|
57
|
+
WorkingDirectory=/path/to/app
|
|
58
|
+
ExecStart=/path/to/executable ...
|
|
59
|
+
Restart=always
|
|
60
|
+
RestartSec=3
|
|
61
|
+
MemoryMax=512M
|
|
62
|
+
|
|
63
|
+
[Install]
|
|
64
|
+
WantedBy=default.target
|
|
65
|
+
```
|
|
66
|
+
- **Escaping**: Always use `formatSystemdEnv()` and `escapeExecArg()` from [src/utils.js](file:///Users/ahmet/projects/unitup/src/utils.js).
|
|
67
|
+
|
|
68
|
+
### B. macOS (launchd)
|
|
69
|
+
- **User scope (default)**: `~/Library/LaunchAgents/com.unitup.<name>.plist`
|
|
70
|
+
- Domain target: `gui/<uid>` (for logged-in user) or `user/<uid>`.
|
|
71
|
+
- Bootstrapping: `launchctl bootstrap gui/<uid> <plistPath>`
|
|
72
|
+
- Kickstarting: `launchctl kickstart -k gui/<uid>/com.unitup.<name>`
|
|
73
|
+
- **System scope**: `/Library/LaunchDaemons/com.unitup.<name>.plist`
|
|
74
|
+
- Domain target: `system` (requires root).
|
|
75
|
+
- **XML Plist Generation**: Generated cleanly with standard XML elements (`ProgramArguments`, `WorkingDirectory`, `KeepAlive`, `EnvironmentVariables`, `StandardOutPath`, `StandardErrorPath`).
|
|
76
|
+
|
|
77
|
+
### C. Windows (Windows Services)
|
|
78
|
+
- **Tooling**: Uses `sc.exe create <name> binPath= ... start= auto`.
|
|
79
|
+
- **WindowsServiceHost**: Because standard node scripts cannot run directly as Windows Services without a service wrapper, Unitup uses [WindowsServiceHost](file:///Users/ahmet/projects/unitup/src/platform/windows-host.js) to bridge process signals, log redirection, and Windows shutdown events.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 3. Testing Platform Adapters Safely
|
|
84
|
+
|
|
85
|
+
Native system commands (`systemctl`, `launchctl`, `sc.exe`) cannot and should not be executed directly in unit tests.
|
|
86
|
+
|
|
87
|
+
Unitup provides runner injection via [src/utils.js](file:///Users/ahmet/projects/unitup/src/utils.js):
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
import { setCommandRunner, resetCommandRunner } from '../src/utils.js';
|
|
91
|
+
|
|
92
|
+
// Mock commands in tests:
|
|
93
|
+
setCommandRunner(async (cmd, args) => {
|
|
94
|
+
if (cmd === 'systemctl' && args.includes('is-active')) {
|
|
95
|
+
return { stdout: 'active\n', stderr: '', code: 0 };
|
|
96
|
+
}
|
|
97
|
+
return { stdout: '', stderr: '', code: 0 };
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Always reset runner in afterEach / finally:
|
|
101
|
+
resetCommandRunner();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Verification Commands:
|
|
105
|
+
```bash
|
|
106
|
+
# Test all platform adapters
|
|
107
|
+
node --test test/platform.test.js test/macos.test.js test/windows.test.js test/unit.test.js
|
|
108
|
+
```
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unitup-development-and-qa
|
|
3
|
+
description: >-
|
|
4
|
+
Development standards, testing procedures, TypeScript type verification, Biome linting,
|
|
5
|
+
and zero-external-dependency constraints for the Unitup codebase. Use when adding new features,
|
|
6
|
+
running tests, updating index.d.ts, or validating pull requests.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Unitup Development, QA & Quality Standards
|
|
10
|
+
|
|
11
|
+
This skill outlines the core engineering invariants, code quality checks, and testing workflows required for any contribution to Unitup.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Golden Constraint: Zero External Dependencies
|
|
16
|
+
|
|
17
|
+
**Unitup has strictly ZERO production external dependencies (`dependencies: {}` in package.json).**
|
|
18
|
+
|
|
19
|
+
- Only Node.js built-in standard modules are allowed (`node:fs`, `node:path`, `node:os`, `node:child_process`, `node:http`, `node:net`, `node:events`, `node:assert`, `node:test`).
|
|
20
|
+
- Never add npm packages to `dependencies`.
|
|
21
|
+
- Development dependencies (`devDependencies`) are strictly limited to developer tooling: `biome` for linting/formatting and `typescript` for `.d.ts` type-checking.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 2. Testing Principles & Node.js Test Runner
|
|
26
|
+
|
|
27
|
+
Unitup uses the native Node.js test runner (`node:test` and `node:assert`). No Jest, Mocha, or Vitest.
|
|
28
|
+
|
|
29
|
+
### Test Authoring Rules:
|
|
30
|
+
1. **Never use hardcoded network ports**:
|
|
31
|
+
- Always use `port: 0` for ephemeral port allocation or `findFreePort()`.
|
|
32
|
+
- Hardcoding ports like `3000` or `8080` breaks developer machines running local web servers.
|
|
33
|
+
2. **Deterministic Cleanup**:
|
|
34
|
+
- Every test that opens an HTTP server, router, socket, or temporary file must clean it up in an `afterEach` or `finally` block.
|
|
35
|
+
3. **Mock Process & System Calls**:
|
|
36
|
+
- Do not call real `systemctl`, `launchctl`, or `sc.exe` in unit tests. Use `setCommandRunner()`.
|
|
37
|
+
- Use simulated process managers when testing deployment state machines to keep tests blazing fast (< 100ms per suite).
|
|
38
|
+
|
|
39
|
+
### Common Test Commands:
|
|
40
|
+
```bash
|
|
41
|
+
# Run a specific test suite
|
|
42
|
+
node --test test/canary.test.js
|
|
43
|
+
node --test test/router.test.js
|
|
44
|
+
node --test test/deployment-manager.test.js
|
|
45
|
+
|
|
46
|
+
# Run the complete test suite across all platforms
|
|
47
|
+
npm test
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 3. TypeScript Type Synchronization (`index.d.ts`)
|
|
53
|
+
|
|
54
|
+
Unitup is written in standard ES Modules (`"type": "module"`), but provides high-grade TypeScript declarations in [index.d.ts](file:///Users/ahmet/projects/unitup/index.d.ts).
|
|
55
|
+
|
|
56
|
+
Whenever a public function, class, option, or result object is added or modified in `src/`:
|
|
57
|
+
1. Update `index.d.ts` with matching types.
|
|
58
|
+
2. Verify with the TypeScript compiler:
|
|
59
|
+
```bash
|
|
60
|
+
npx tsc --noEmit
|
|
61
|
+
```
|
|
62
|
+
3. Ensure `npx tsc --noEmit` exits with **0 errors**.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 4. Linting & Formatting with Biome
|
|
67
|
+
|
|
68
|
+
Unitup uses Biome for sub-second formatting and static analysis:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Check code style, formatting, and lint rules
|
|
72
|
+
npx biome check src/ test/
|
|
73
|
+
|
|
74
|
+
# Automatically apply safe fixes
|
|
75
|
+
npx biome check --write src/ test/
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Always ensure `npx biome check src/ test/` exits cleanly with zero errors before concluding tasks.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 5. Pre-Commit / Pre-Push Checklist
|
|
83
|
+
|
|
84
|
+
Before submitting any task, run the full validation chain:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm test && npx tsc --noEmit && npx biome check src/ test/
|
|
88
|
+
```
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: zero-downtime-deployment
|
|
3
|
+
description: >-
|
|
4
|
+
Develop, test, and debug zero-downtime deployments, canary releases, rollback sequencing,
|
|
5
|
+
generation management, router reverse proxying, connection draining, and readiness probes in Unitup.
|
|
6
|
+
Use when modifying core routing, process lifecycle, readiness checks, drain manager, or deploy orchestration.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Zero-Downtime Deployment & Traffic Routing in Unitup
|
|
10
|
+
|
|
11
|
+
This skill provides architectural guidelines, lifecycle specifications, and testing procedures for Unitup's zero-downtime deployment subsystem.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Architectural Invariant: Core vs. Deploy Separation
|
|
16
|
+
|
|
17
|
+
Unitup strictly decouples low-level process and routing primitives from deployment sequencing:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
unitup
|
|
21
|
+
├── core/ # Zero-downtime runtime primitives (No deployment sequencing)
|
|
22
|
+
│ ├── process-manager.js # Child process lifecycle (start, stop, isAlive, signal, waitForExit)
|
|
23
|
+
│ ├── generation-manager.js # Generation records, ephemeral internal ports, disk persistence
|
|
24
|
+
│ ├── router.js # HTTP & WebSocket reverse proxy on stable public port, atomic switch, canary weights
|
|
25
|
+
│ ├── readiness.js # TCP connect & HTTP 2xx health probes, fast startup failure detection
|
|
26
|
+
│ ├── drain-manager.js # In-flight connection tracking, graceful drain, process retirement
|
|
27
|
+
│ ├── lock.js # Cross-process deployment concurrency lock
|
|
28
|
+
│ ├── supervisor.js # Daemon supervisor (unitupd) managing generations and IPC
|
|
29
|
+
│ └── ipc.js # Domain socket / Named Pipe IPC server and client
|
|
30
|
+
│
|
|
31
|
+
└── deploy/ # Orchestration layer (Composes core primitives)
|
|
32
|
+
├── deployment-manager.js # State machine for full deployments and canary rollouts
|
|
33
|
+
└── rollback-manager.js # Rollback sequencing and canary abortion
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Golden Rules:
|
|
37
|
+
1. **Never put deployment sequencing into `core/`**: Router, ProcessManager, GenerationManager, and DrainManager must have no knowledge of "steps" or deployment decisions.
|
|
38
|
+
2. **Never put networking or OS primitives into `deploy/`**: DeploymentManager and RollbackManager must only compose core primitives.
|
|
39
|
+
3. **Never build zero-downtime inside CLI commands**: `src/cli.js` must remain a thin adapter that calls `deploy`, `promote`, or `rollback`.
|
|
40
|
+
4. **Never create per-generation OS service units**: Only one supervisor service unit exists per application.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. Deployment State Machine
|
|
45
|
+
|
|
46
|
+
DeploymentManager executes a strict, observable state machine:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
IDLE
|
|
50
|
+
└── ACQUIRING_LOCK
|
|
51
|
+
└── CREATING_GENERATION
|
|
52
|
+
└── STARTING
|
|
53
|
+
└── WAITING_READY
|
|
54
|
+
├── (If canary): SETTING_CANARY -> COMPLETE
|
|
55
|
+
└── (If full deploy):
|
|
56
|
+
SWITCHING
|
|
57
|
+
└── STABILIZING
|
|
58
|
+
└── DRAINING
|
|
59
|
+
└── STOPPING_PREVIOUS
|
|
60
|
+
└── COMPLETE
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Critical Safety Invariants:
|
|
64
|
+
- **Previous Generation Safety**: The previous generation is NEVER stopped or drained until the new generation has passed readiness and traffic has switched.
|
|
65
|
+
- **Readiness Failure Preservation**: If the new generation fails readiness or crashes on startup, it is immediately stopped and removed. The router remains pointed at the previous generation, and the deployment fails cleanly.
|
|
66
|
+
- **Stabilization Window**: During `stabilizationWindowMs` (default 500ms), if the new generation crashes, traffic is automatically reverted to the previous generation before an error is thrown.
|
|
67
|
+
- **Port Mismatch & EADDRINUSE Diagnosis**: If user code hardcodes its listening port instead of reading `process.env.PORT`, `ReadinessChecker` diagnoses this immediately (raising `PortMismatchError` or diagnosing `EADDRINUSE` from captured process stderr) rather than hanging for 15 seconds.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 3. Canary & Weighted Routing Mechanics
|
|
72
|
+
|
|
73
|
+
### Router Primitives (`src/core/router.js`):
|
|
74
|
+
- `setCanary(serviceName, generation, weight)`: Accepts decimal (`0.15`) or percentage (`"15%"` or `15`), normalized to `[0.0, 1.0]`.
|
|
75
|
+
- `resolveBackend(serviceName, req)`:
|
|
76
|
+
1. **Header Override**:
|
|
77
|
+
- `x-canary: true`, `1`, `always` -> Routes to canary generation deterministically.
|
|
78
|
+
- `x-canary: false`, `0`, `never` -> Routes to primary active generation deterministically.
|
|
79
|
+
2. **Probabilistic Splitting**: If `Math.random() < canary.weight`, routes to canary.
|
|
80
|
+
3. **Fallback**: Routes to primary active generation.
|
|
81
|
+
- `promoteCanary(serviceName)`: Atomically shifts primary backend to canary, clears canary map, returns promoted generation.
|
|
82
|
+
- `clearCanary(serviceName)`: Removes canary backend without touching primary active generation.
|
|
83
|
+
|
|
84
|
+
### Canary Deployment Flow:
|
|
85
|
+
```bash
|
|
86
|
+
# 1. Deploy canary with 10% traffic (keeps existing active generation intact)
|
|
87
|
+
unitup deploy <app> --canary --weight 10%
|
|
88
|
+
|
|
89
|
+
# 2. Inspect status (shows both active and canary generations with port & weight)
|
|
90
|
+
unitup status <app>
|
|
91
|
+
|
|
92
|
+
# 3A. Promote canary to 100% active (drains and stops old active generation)
|
|
93
|
+
unitup promote <app>
|
|
94
|
+
|
|
95
|
+
# 3B. OR abort canary (stops and drains canary, keeps old active generation intact)
|
|
96
|
+
unitup rollback <app>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## 4. Connection Draining & Streaming Protocols
|
|
102
|
+
|
|
103
|
+
[DrainManager](file:///Users/ahmet/projects/unitup/src/core/drain-manager.js) ensures graceful retirement:
|
|
104
|
+
1. `inFlightRequests` map tracks active HTTP requests and WebSocket connections per generation ID.
|
|
105
|
+
2. In `_handleHttpRequest`: increments in-flight counter, decrements on response `'finish'` or `'close'`.
|
|
106
|
+
3. In `_handleWebSocketUpgrade`: increments in-flight counter, decrements on socket `'close'`, `'end'`, or `'error'`.
|
|
107
|
+
4. `DrainManager.drain(generation, { timeout, pollInterval, stopProcess })`:
|
|
108
|
+
- Polls `router.getInFlightCount(generation.id)`.
|
|
109
|
+
- Waits for active in-flight requests, long-lived WebSockets, Server-Sent Events (SSE), and chunked uploads to complete up to `timeout`.
|
|
110
|
+
- Stops the process gracefully (`SIGTERM` -> timeout -> `SIGKILL`).
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## 5. Testing & Verification Runbook
|
|
115
|
+
|
|
116
|
+
When modifying zero-downtime or routing code, run:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
# 1. Core routing and architectural boundary tests
|
|
120
|
+
node --test test/router.test.js test/deployment-manager.test.js test/rollback-manager.test.js test/canary.test.js
|
|
121
|
+
|
|
122
|
+
# 2. Connection draining & streaming protocols test
|
|
123
|
+
node --test test/live-connections.test.js test/drain-manager.test.js
|
|
124
|
+
|
|
125
|
+
# 3. Continuous zero-downtime traffic integration test (v1 -> v2 -> v1 -> v2 under load)
|
|
126
|
+
node --test test/zero-downtime-integration.test.js
|
|
127
|
+
|
|
128
|
+
# 4. Full test suite and type check
|
|
129
|
+
npm test
|
|
130
|
+
npx tsc --noEmit
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Test Construction Rules:
|
|
134
|
+
- **Never bind hardcoded ports** like `3000` or `8080`. Always use `port: 0` (ephemeral port allocation) or `findFreePort()` to avoid collisions with running user services.
|
|
135
|
+
- **Always clean up servers and sockets** in `afterEach` or `finally` blocks.
|
package/index.d.ts
CHANGED
|
@@ -337,6 +337,20 @@ export class InvalidServiceConfigError extends UnitupError {
|
|
|
337
337
|
export class ExecutableNotFoundError extends UnitupError {
|
|
338
338
|
executable: string;
|
|
339
339
|
}
|
|
340
|
+
export class ReadinessTimeoutError extends Error {
|
|
341
|
+
generation: GenerationRecord;
|
|
342
|
+
}
|
|
343
|
+
export class ProcessStartupError extends Error {
|
|
344
|
+
generation: GenerationRecord;
|
|
345
|
+
}
|
|
346
|
+
export class PortMismatchError extends Error {
|
|
347
|
+
generation: GenerationRecord;
|
|
348
|
+
detectedPort: number;
|
|
349
|
+
}
|
|
350
|
+
export class DeploymentLockError extends Error {
|
|
351
|
+
service: string;
|
|
352
|
+
lockedPid?: number;
|
|
353
|
+
}
|
|
340
354
|
|
|
341
355
|
// Adapters
|
|
342
356
|
export abstract class ServiceAdapter {
|
|
@@ -443,6 +457,7 @@ export function formatTable(
|
|
|
443
457
|
columns: Array<{ key: string; label: string }>
|
|
444
458
|
): string;
|
|
445
459
|
export function formatMemoryBytes(bytes: number | string): string;
|
|
460
|
+
export function formatDuration(ms: number): string;
|
|
446
461
|
export function validateMemorySize(val: string | number, paramName?: string): string;
|
|
447
462
|
export function validateDuration(val: string | number, paramName?: string): string;
|
|
448
463
|
|
|
@@ -515,4 +530,125 @@ export function readScheduleMetadata(name: string): Record<string, unknown> | nu
|
|
|
515
530
|
export function getScheduleMetadataPath(name: string): string;
|
|
516
531
|
export function getSchedulesDir(): string;
|
|
517
532
|
|
|
533
|
+
// Zero-Downtime Deployment Primitives & Orchestration
|
|
534
|
+
export interface GenerationRecord {
|
|
535
|
+
id: number;
|
|
536
|
+
service: string;
|
|
537
|
+
pid: number;
|
|
538
|
+
internalPort: number;
|
|
539
|
+
status: 'starting' | 'active' | 'previous' | 'draining' | 'stopped' | 'canary' | string;
|
|
540
|
+
createdAt: string;
|
|
541
|
+
activatedAt?: string;
|
|
542
|
+
drainingAt?: string;
|
|
543
|
+
stoppedAt?: string;
|
|
544
|
+
canaryWeight?: number;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export interface DeploymentResult {
|
|
548
|
+
service: string;
|
|
549
|
+
previousGeneration: number | null;
|
|
550
|
+
currentGeneration: number;
|
|
551
|
+
downtimeMs: number;
|
|
552
|
+
status: string;
|
|
553
|
+
canaryWeight?: number;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export interface PromoteResult {
|
|
557
|
+
service: string;
|
|
558
|
+
promotedGeneration: number;
|
|
559
|
+
previousGeneration: number | null;
|
|
560
|
+
downtimeMs: number;
|
|
561
|
+
status: string;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export interface RollbackResult {
|
|
565
|
+
service: string;
|
|
566
|
+
rolledBackFrom: number | null;
|
|
567
|
+
activeGeneration: number;
|
|
568
|
+
status: string;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function deploy(name: string, options?: Record<string, unknown>): Promise<DeploymentResult>;
|
|
572
|
+
export function promote(name: string, options?: Record<string, unknown>): Promise<PromoteResult>;
|
|
573
|
+
export function rollback(name: string, options?: Record<string, unknown>): Promise<RollbackResult>;
|
|
574
|
+
export function generations(name: string, options?: Record<string, unknown>): GenerationRecord[];
|
|
575
|
+
|
|
576
|
+
export class ProcessManager {
|
|
577
|
+
constructor(options?: Record<string, unknown>);
|
|
578
|
+
start(command: string, args?: string[], options?: Record<string, unknown>): { pid: number; child: any; exitPromise: Promise<any> };
|
|
579
|
+
stop(processOrPid: number | object, options?: { timeout?: number; signal?: string }): Promise<boolean>;
|
|
580
|
+
signal(processOrPid: number | object, signal?: string): boolean;
|
|
581
|
+
isAlive(processOrPid: number | object): boolean;
|
|
582
|
+
waitForExit(processOrPid: number | object, timeout?: number): Promise<{ exited: boolean; code?: number | null; signal?: string | null }>;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export class GenerationManager {
|
|
586
|
+
constructor(options?: Record<string, unknown>);
|
|
587
|
+
create(serviceName: string, config?: Record<string, unknown>, options?: Record<string, unknown>): Promise<GenerationRecord>;
|
|
588
|
+
getActive(serviceName: string): GenerationRecord | null;
|
|
589
|
+
getPrevious(serviceName: string): GenerationRecord | null;
|
|
590
|
+
getCanary(serviceName: string): GenerationRecord | null;
|
|
591
|
+
getGeneration(serviceName: string, id: number | string): GenerationRecord | null;
|
|
592
|
+
markActive(generation: GenerationRecord): void;
|
|
593
|
+
markCanary(generation: GenerationRecord, weight?: number | string): void;
|
|
594
|
+
markDraining(generation: GenerationRecord): void;
|
|
595
|
+
remove(generation: GenerationRecord, purge?: boolean): void;
|
|
596
|
+
cleanupStale(serviceName: string): number;
|
|
597
|
+
listGenerations(serviceName: string): GenerationRecord[];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export class Router {
|
|
601
|
+
constructor(options?: Record<string, unknown>);
|
|
602
|
+
listen(publicPort: number, host?: string): Promise<{ port: number; host: string }>;
|
|
603
|
+
switchBackend(serviceName: string, generation: { id: number; internalPort: number }): void;
|
|
604
|
+
getActiveBackend(serviceName: string): GenerationRecord | null;
|
|
605
|
+
setCanary(serviceName: string, generation: { id: number; internalPort: number }, weight?: number | string): void;
|
|
606
|
+
clearCanary(serviceName: string): void;
|
|
607
|
+
promoteCanary(serviceName: string): void;
|
|
608
|
+
getCanary(serviceName: string): { generation: { id: number; internalPort: number }; weight: number } | null;
|
|
609
|
+
resolveBackend(serviceName: string, req?: any): { id: number; internalPort: number } | null;
|
|
610
|
+
getInFlightCount(generationId: number | string): number;
|
|
611
|
+
close(): Promise<void>;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export class ReadinessChecker {
|
|
615
|
+
constructor(options?: Record<string, unknown>);
|
|
616
|
+
checkTcp(port: number, host?: string, timeout?: number): Promise<boolean>;
|
|
617
|
+
checkHttp(port: number, path?: string, timeout?: number): Promise<boolean>;
|
|
618
|
+
ensure(generation: GenerationRecord, options?: { path?: string }): Promise<boolean>;
|
|
619
|
+
wait(generation: GenerationRecord, options?: { timeout?: number; interval?: number; path?: string }): Promise<boolean>;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export class DrainManager {
|
|
623
|
+
constructor(options?: Record<string, unknown>);
|
|
624
|
+
drain(generation: GenerationRecord, options?: { timeout?: number; pollInterval?: number; stopProcess?: boolean }): Promise<{ drained: boolean; inFlightRemaining: number; timedOut: boolean }>;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export class DeploymentLock {
|
|
628
|
+
constructor(options?: Record<string, unknown>);
|
|
629
|
+
acquire(serviceName: string): Promise<boolean>;
|
|
630
|
+
release(serviceName: string): Promise<boolean>;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export class DeploymentManager {
|
|
634
|
+
constructor(options?: Record<string, unknown>);
|
|
635
|
+
deploy(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>): Promise<DeploymentResult>;
|
|
636
|
+
promote(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>): Promise<PromoteResult>;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export class RollbackManager {
|
|
640
|
+
constructor(options?: Record<string, unknown>);
|
|
641
|
+
rollback(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>): Promise<RollbackResult>;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export class Supervisor {
|
|
645
|
+
constructor(serviceName: string, serviceConfig?: Record<string, unknown>, options?: Record<string, unknown>);
|
|
646
|
+
start(): Promise<Supervisor>;
|
|
647
|
+
stop(): Promise<void>;
|
|
648
|
+
static run(serviceName: string): Promise<void>;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export const defaultDeploymentManager: DeploymentManager;
|
|
652
|
+
export const defaultRollbackManager: RollbackManager;
|
|
653
|
+
|
|
518
654
|
export default defaultManager;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unitup",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|