rollbridge 0.1.49 → 0.1.55
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.md +5 -0
- package/README.md +5 -0
- package/changelog.d/20260909120000-velocious-testing.md +1 -0
- package/docs/cli.md +7 -1
- package/docs/generation-deployment-contract.md +9 -0
- package/eslint.config.js +8 -0
- package/package.json +3 -2
- package/src/cli.js +10 -2
- package/src/daemon.js +102 -12
- package/src/process-guardian.js +5 -1
- package/src/release-group.js +48 -1
- package/test/completion.test.js +18 -16
- package/test/config-examples.test.js +16 -17
- package/test/config-path.test.js +10 -11
- package/test/config-validation.test.js +163 -167
- package/test/control-protocol.test.js +75 -14
- package/test/daemon-bootstrap.test.js +104 -104
- package/test/daemon-runtime.test.js +17 -26
- package/test/doctor.test.js +51 -49
- package/test/event-log.test.js +13 -11
- package/test/guardian-client.test.js +160 -145
- package/test/health.test.js +6 -4
- package/test/logs.test.js +23 -17
- package/test/managed-process.test.js +96 -91
- package/test/owner-recovery.test.js +254 -239
- package/test/owner-replacement.test.js +228 -223
- package/test/package-metadata.test.js +48 -39
- package/test/port-allocator.test.js +13 -16
- package/test/predeploy-cleanup.test.js +12 -10
- package/test/process-memory.test.js +17 -15
- package/test/proxy.test.js +10 -8
- package/test/recover.test.js +30 -23
- package/test/release-group.test.js +16 -17
- package/test/release-retention.test.js +10 -8
- package/test/release-runtime-retention.test.js +31 -39
- package/test/rollbridge.test.js +388 -395
- package/test/shutdown-completion.test.js +51 -51
- package/test/state-store.test.js +10 -8
- package/test/system-ids.test.js +15 -13
package/AGENTS.md
CHANGED
|
@@ -61,6 +61,11 @@ external-supervisor migration path, not that atomic handoff.
|
|
|
61
61
|
|
|
62
62
|
## Validation and publication
|
|
63
63
|
|
|
64
|
+
All tests use `@velocious/testing` declarations, hooks, and `expect` matchers.
|
|
65
|
+
Do not import `node:test` or `node:assert`, or add assertion compatibility wrappers.
|
|
66
|
+
Run every changed test file locally with `npx --no-install velocious-test test/<name>.test.js`
|
|
67
|
+
and require it to pass before committing or pushing; lint or CI is not a substitute.
|
|
68
|
+
|
|
64
69
|
The project is ESM JavaScript with JSDoc type checking. Package scripts are the
|
|
65
70
|
source of truth: `npm run typecheck`, `npm run lint`, `npm test`, and the combined
|
|
66
71
|
`npm run all-checks`. Run focused checks for the files changed; documentation-only
|
package/README.md
CHANGED
|
@@ -576,6 +576,7 @@ Inspect state:
|
|
|
576
576
|
|
|
577
577
|
```bash
|
|
578
578
|
rollbridge status --config rollbridge.js
|
|
579
|
+
rollbridge status --no-logs --config rollbridge.js
|
|
579
580
|
```
|
|
580
581
|
|
|
581
582
|
`status` reports each managed process's `state`, `pid`, recent `logs`, last
|
|
@@ -587,6 +588,10 @@ and why it last started (`lastStartReason`: `deploy`, `crash`, `manual`, or
|
|
|
587
588
|
`rssBytes`, `memoryRestarts`, `lastMemoryRestartAt`, and `children` (the sampled
|
|
588
589
|
process tree — each group member's `pid`, `command`, and `rssBytes`).
|
|
589
590
|
|
|
591
|
+
For machine lifecycle attestations that do not need captured process output,
|
|
592
|
+
pass `--no-logs`. It returns the same status projection while omitting only each
|
|
593
|
+
release, service, and singleton process's `logs` array.
|
|
594
|
+
|
|
590
595
|
Print the recent captured stdout/stderr per process (a one-shot snapshot of the
|
|
591
596
|
retained `outputLines`, not a live stream):
|
|
592
597
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
- Migrated all Rollbridge tests to `@velocious/testing` declarations, lifecycle APIs, and native `expect` matchers. Lint now rejects Node test and assertion imports in tests.
|
package/docs/cli.md
CHANGED
|
@@ -253,7 +253,7 @@ stays safe.
|
|
|
253
253
|
## `status`
|
|
254
254
|
|
|
255
255
|
```
|
|
256
|
-
rollbridge status [--config <path>]
|
|
256
|
+
rollbridge status [--config <path>] [--no-logs]
|
|
257
257
|
```
|
|
258
258
|
|
|
259
259
|
Prints the daemon status JSON: the active release id, the proxy address, and —
|
|
@@ -264,6 +264,12 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
|
|
|
264
264
|
`lastMemoryRestartAt`, and `children` (the process tree: each group member's
|
|
265
265
|
`pid`, `command`, and `rssBytes`).
|
|
266
266
|
|
|
267
|
+
`--no-logs` omits only the captured `logs` array from release, service, and
|
|
268
|
+
singleton process statuses. Use it for bounded machine-readable lifecycle
|
|
269
|
+
attestations that do not need process output. The equivalent control request is
|
|
270
|
+
`{"command":"status","includeLogs":false}`; omitted `includeLogs` continues to
|
|
271
|
+
return the complete status payload.
|
|
272
|
+
|
|
267
273
|
`daemonRuntime` identifies the immutable Rollbridge runtime serving the proxy:
|
|
268
274
|
its runtime `format`, package `version`, content `digest`, and absolute `path`.
|
|
269
275
|
`ensure-daemon` uses this attestation before reusing a responsive daemon.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Generation deployment contract
|
|
2
|
+
|
|
3
|
+
Rollbridge treats web authority, jobs-generation authority, durable transition state, and old-release draining as separate concerns.
|
|
4
|
+
|
|
5
|
+
A successful activation-lifecycle deployment starts and health-checks the candidate, waits for the old generation's retirement/quiescence acknowledgement, activates the candidate jobs generation, switches web authority, checkpoints `committed`, and returns. The acknowledged old generation then continues draining and stopping asynchronously; deployment completion does not wait for that later work.
|
|
6
|
+
|
|
7
|
+
Failed candidates never receive traffic. Recovery is exact-authority and journaled; no operator should edit durable state. A degraded incumbent keeps web authority while jobs remain explicitly degraded until a fresh generation commits.
|
|
8
|
+
|
|
9
|
+
Persistent guardians may retain old processes, but new release processes must use current lifecycle semantics. Lifecycle command failures must retain bounded stdout/stderr and stage metadata. Runtime content digest is authoritative; package versions are compatibility metadata rather than unique code identity.
|
package/eslint.config.js
CHANGED
|
@@ -81,6 +81,14 @@ export default defineConfig([
|
|
|
81
81
|
"no-unused-vars": ["error", {argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_"}]
|
|
82
82
|
}
|
|
83
83
|
},
|
|
84
|
+
{
|
|
85
|
+
files: ["test/**/*.js"],
|
|
86
|
+
rules: {
|
|
87
|
+
"no-restricted-imports": ["error", {
|
|
88
|
+
patterns: [{group: ["node:test", "node:test/*", "test", "node:assert", "node:assert/*", "assert", "assert/*"], message: "Use @velocious/testing declarations and expect matchers."}]
|
|
89
|
+
}]
|
|
90
|
+
}
|
|
91
|
+
},
|
|
84
92
|
jsdoc({
|
|
85
93
|
config: "flat/recommended",
|
|
86
94
|
files: ["**/*.{js,mjs,cjs}", "**/bin/rollbridge"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rollbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.55",
|
|
4
4
|
"description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deploy",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"all-checks": "npm run typecheck && npm run lint && npm test",
|
|
30
30
|
"lint": "npm run eslint && npm run typecheck",
|
|
31
31
|
"release:patch": "release-patch",
|
|
32
|
-
"test": "
|
|
32
|
+
"test": "velocious-test test/*.test.js",
|
|
33
33
|
"typecheck": "tsc --noEmit",
|
|
34
34
|
"eslint": "eslint"
|
|
35
35
|
},
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"@eslint/js": "^10.0.1",
|
|
45
45
|
"@types/http-proxy": "^1.17.17",
|
|
46
46
|
"@types/node": "^25.9.1",
|
|
47
|
+
"@velocious/testing": "^0.0.12",
|
|
47
48
|
"eslint": "^10.4.0",
|
|
48
49
|
"eslint-plugin-jsdoc": "^62.9.0",
|
|
49
50
|
"globals": "^17.6.0",
|
package/src/cli.js
CHANGED
|
@@ -41,6 +41,7 @@ export async function runCli(argv) {
|
|
|
41
41
|
.option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
|
|
42
42
|
.option("--takeover-owner", "Boot and health-check before retiring the current external owner")
|
|
43
43
|
.option("--replace-owner", "Resume a prepared durable owner replacement")
|
|
44
|
+
.option("--reset-retired-owner", "Reset missing guardian identity from the exact committed bootstrap")
|
|
44
45
|
.addOption(new Option("--guardian-daemon-log-path <path>").hideHelp())
|
|
45
46
|
.addOption(new Option("--guardian-daemon-pid-path <path>").hideHelp())
|
|
46
47
|
.addOption(new Option("--guardian-daemon-start-timeout-ms <ms>").hideHelp())
|
|
@@ -88,6 +89,12 @@ export async function runCli(argv) {
|
|
|
88
89
|
process.once("SIGTERM", () => { void shutdown() })
|
|
89
90
|
|
|
90
91
|
if (options.takeoverOwner && (!bootstrap || !bootstrap.attestation)) throw new Error("Daemon --takeover-owner requires the complete bootstrap release tuple and --boot-attestation.")
|
|
92
|
+
if (options.resetRetiredOwner && (!bootstrap || options.takeoverOwner || options.replaceOwner)) throw new Error("Daemon --reset-retired-owner requires the complete bootstrap tuple and cannot be combined with owner takeover/replacement.")
|
|
93
|
+
|
|
94
|
+
if (options.resetRetiredOwner) {
|
|
95
|
+
startupPromise = daemon.resetRetiredOwnerRecovery()
|
|
96
|
+
await startupPromise
|
|
97
|
+
}
|
|
91
98
|
|
|
92
99
|
if (options.replaceOwner) {
|
|
93
100
|
if (bootstrap || options.takeoverOwner) throw new Error("Daemon --replace-owner cannot be combined with bootstrap takeover options.")
|
|
@@ -95,7 +102,7 @@ export async function runCli(argv) {
|
|
|
95
102
|
await startupPromise
|
|
96
103
|
}
|
|
97
104
|
|
|
98
|
-
if (!options.takeoverOwner && !options.replaceOwner) {
|
|
105
|
+
if (!options.takeoverOwner && !options.replaceOwner && !options.resetRetiredOwner) {
|
|
99
106
|
try {
|
|
100
107
|
startupPromise = daemon.start({exposeControl: !bootstrap})
|
|
101
108
|
await startupPromise
|
|
@@ -266,11 +273,12 @@ export async function runCli(argv) {
|
|
|
266
273
|
program
|
|
267
274
|
.command("status")
|
|
268
275
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
276
|
+
.option("--no-logs", "Omit captured stdout/stderr from process statuses")
|
|
269
277
|
.action(async (options) => {
|
|
270
278
|
const configPath = await resolveConfigPath(options.config)
|
|
271
279
|
const config = await loadConfig(configPath)
|
|
272
280
|
const response = await sendControlCommand({
|
|
273
|
-
command: {command: "status"},
|
|
281
|
+
command: {command: "status", ...(options.logs ? {} : {includeLogs: false})},
|
|
274
282
|
path: config.control.path
|
|
275
283
|
})
|
|
276
284
|
|
package/src/daemon.js
CHANGED
|
@@ -27,6 +27,10 @@ const STATE_PERSIST_INTERVAL_MS = 5000
|
|
|
27
27
|
* @typedef {"candidate_ready" | "retiring_previous" | "previous_retired" | "activating_candidate" | "restoring_previous" | "retiring_failed_candidate" | "degraded_active" | "committed_pending" | "committed" | "restoring_committed"} GenerationTransitionPhase
|
|
28
28
|
* @typedef {{activationError?: string, activationLifecycle?: boolean, candidateReleaseId: string, candidateReleasePath: string, candidateRevision: string, compensationError?: string, configDigest: string, degradedIncumbent?: boolean, error?: string, journalRevision?: number, phase: GenerationTransitionPhase, previousReleaseId: string | null, startedAt: string, updatedAt: string}} GenerationTransition
|
|
29
29
|
* @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonPid: number, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, generationTransition?: GenerationTransition, ownerRecovery: {configDigest: string, ready: boolean} | undefined, ownerTransition?: OwnerTransition, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
30
|
+
* @typedef {Omit<import("./managed-process.js").ManagedProcessStatus, "logs">} ManagedProcessStatusWithoutLogs
|
|
31
|
+
* @typedef {Omit<import("./release-group.js").ReleaseStatus, "processes"> & {processes: ManagedProcessStatusWithoutLogs[]}} ReleaseStatusWithoutLogs
|
|
32
|
+
* @typedef {Omit<ProcessStatus, "process"> & {process: ManagedProcessStatusWithoutLogs}} ProcessStatusWithoutLogs
|
|
33
|
+
* @typedef {Omit<DaemonStatus, "releases" | "services" | "singletons"> & {releases: ReleaseStatusWithoutLogs[], services: ProcessStatusWithoutLogs[], singletons: ProcessStatusWithoutLogs[]}} DaemonStatusWithoutLogs
|
|
30
34
|
* @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
|
|
31
35
|
* @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>}} OwnerRecoverySnapshot
|
|
32
36
|
* @typedef {{authority: JsonValue, config: import("./config.js").RollbridgeConfig, listenerConnectionSources?: Record<string, Record<string, {http: number, websocket: number}>>, listenerSourceId?: string, recovery?: {command: JsonValue, reconnectGraceMs: number, startupTimeoutMs: number}, releaseConfigs?: Record<string, import("./config.js").RollbridgeConfig>, retiredListenerHandoff?: number, serviceReleaseIds?: Record<string, string>, singletonReleaseIds?: Record<string, string>, snapshot: OwnerRecoverySnapshot}} PrivateOwnerState
|
|
@@ -186,6 +190,28 @@ export default class RollbridgeDaemon {
|
|
|
186
190
|
return true
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
/** Re-establishes durable guardian ownership from an exact committed external-owner bootstrap. */
|
|
194
|
+
async resetRetiredOwnerRecovery() {
|
|
195
|
+
if (!this.statePath || !this.config.ownerRecovery || !this.bootstrap) throw new Error("Retired owner reset requires ownerRecovery, statePath, and the exact bootstrap tuple")
|
|
196
|
+
const state = await readState(this.statePath)
|
|
197
|
+
const snapshot = state && typeof state === "object" && !Array.isArray(state) ? /** @type {OwnerRecoverySnapshot} */ (state) : undefined
|
|
198
|
+
const transition = snapshot?.generationTransition
|
|
199
|
+
const persistedBootstrap = snapshot?.bootstrap
|
|
200
|
+
const exactBootstrap = snapshot?.activeReleaseId === this.bootstrap.releaseId &&
|
|
201
|
+
persistedBootstrap?.releaseId === this.bootstrap.releaseId && persistedBootstrap?.releasePath === this.bootstrap.releasePath && persistedBootstrap?.revision === this.bootstrap.revision &&
|
|
202
|
+
transition?.phase === "committed" && transition.candidateReleaseId === this.bootstrap.releaseId && transition.candidateReleasePath === this.bootstrap.releasePath && transition.candidateRevision === this.bootstrap.revision
|
|
203
|
+
if (!snapshot || snapshot.recovery?.guardian || !exactBootstrap) throw new Error("Retired owner reset requires an exact committed bootstrap with missing guardian identity")
|
|
204
|
+
const control = await inspectControlSocket(this.config.control.path)
|
|
205
|
+
if (control.alive) throw new Error(`Retired owner reset refuses responsive control owner at ${this.config.control.path}`)
|
|
206
|
+
this.guardianIdentity = {socketPath: `${this.statePath}.guardian.sock`, token: crypto.randomBytes(32).toString("hex")}
|
|
207
|
+
this.guardian = new GuardianClient(this.guardianIdentity)
|
|
208
|
+
await this.guardian.launch()
|
|
209
|
+
this.guardianIdentity.pid = this.guardian.pid
|
|
210
|
+
this.watchOwnerReplacementEvents()
|
|
211
|
+
await this.guardian.claimOwner(this.config.ownerRecovery.reconnectGraceMs ?? 30000, this.ownerAuthority())
|
|
212
|
+
this.logger("retired owner guardian reset prepared", {guardianPid: this.guardianIdentity.pid ?? null, releaseId: this.bootstrap.releaseId})
|
|
213
|
+
}
|
|
214
|
+
|
|
189
215
|
/** Connects to the durable process guardian and reconstructs a matching persisted owner snapshot. */
|
|
190
216
|
async initializeOwnerRecovery() {
|
|
191
217
|
if (!this.statePath) throw new Error("ownerRecovery requires statePath")
|
|
@@ -1349,7 +1375,7 @@ export default class RollbridgeDaemon {
|
|
|
1349
1375
|
}
|
|
1350
1376
|
|
|
1351
1377
|
if (commandName === "status") {
|
|
1352
|
-
return this.status()
|
|
1378
|
+
return this.status({includeLogs: statusIncludeLogs(data.includeLogs)})
|
|
1353
1379
|
}
|
|
1354
1380
|
|
|
1355
1381
|
if (commandName === "events") {
|
|
@@ -1942,14 +1968,10 @@ export default class RollbridgeDaemon {
|
|
|
1942
1968
|
if (this.activeRelease !== previous) throw new Error(`Previous release ${previous.releaseId} is not the authoritative proxy target`)
|
|
1943
1969
|
const activationConfig = previous.config.processes.find((processConfig) => processConfig.lifecycle.activateCommand !== undefined)
|
|
1944
1970
|
const coordinator = activationConfig ? previous.getProcesses(activationConfig.id)[0]?.process : undefined
|
|
1945
|
-
|
|
1946
|
-
if (!releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} no longer owns live proxy/web traffic`)
|
|
1947
|
-
await candidate.drainAndStop(candidate.config.proxy.drainTimeoutMs, candidate.config)
|
|
1948
|
-
const candidateProcesses = candidate.status().processes
|
|
1971
|
+
const missingRetiredCoordinator = !coordinator && /^Process .+ is not retained for reactivation$/u.test(retirementFailure ?? "")
|
|
1949
1972
|
|
|
1950
|
-
if (
|
|
1951
|
-
|
|
1952
|
-
}
|
|
1973
|
+
if ((!coordinator && !missingRetiredCoordinator) || (coordinator && coordinator.status().lifecycleRole !== "retired")) throw new Error(`Previous release ${previous.releaseId} does not retain a retired or terminally absent generation coordinator`)
|
|
1974
|
+
if (!releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} no longer owns live proxy/web traffic`)
|
|
1953
1975
|
if (this.activeRelease !== previous || !releaseOwnsLiveProxyTraffic(previous)) throw new Error(`Previous release ${previous.releaseId} lost live proxy/web authority during retired-incumbent recovery`)
|
|
1954
1976
|
const result = /** @type {Record<string, JsonValue>} */ ({
|
|
1955
1977
|
activeReleaseId: previous.releaseId,
|
|
@@ -1960,7 +1982,10 @@ export default class RollbridgeDaemon {
|
|
|
1960
1982
|
recoveryStatus: "retired_incumbent_accepted"
|
|
1961
1983
|
})
|
|
1962
1984
|
|
|
1963
|
-
if (transition.phase === "degraded_active")
|
|
1985
|
+
if (transition.phase === "degraded_active") {
|
|
1986
|
+
if (candidate.state !== "stopped") void this.drainAndPrune(candidate, candidate.config)
|
|
1987
|
+
return result
|
|
1988
|
+
}
|
|
1964
1989
|
const previousError = transition.error
|
|
1965
1990
|
const previousPhase = transition.phase
|
|
1966
1991
|
const previousJournalRevision = transition.journalRevision
|
|
@@ -1984,6 +2009,7 @@ export default class RollbridgeDaemon {
|
|
|
1984
2009
|
}
|
|
1985
2010
|
throw new Error(`retired-incumbent recovery checkpoint failed: ${error instanceof Error ? error.message : String(error)}`, {cause: error})
|
|
1986
2011
|
}
|
|
2012
|
+
if (candidate.state !== "stopped") void this.drainAndPrune(candidate, candidate.config)
|
|
1987
2013
|
this.logger("retired incumbent accepted as jobs-degraded", result)
|
|
1988
2014
|
return result
|
|
1989
2015
|
}
|
|
@@ -2888,8 +2914,25 @@ export default class RollbridgeDaemon {
|
|
|
2888
2914
|
return this.proxyPort
|
|
2889
2915
|
}
|
|
2890
2916
|
|
|
2891
|
-
/**
|
|
2892
|
-
|
|
2917
|
+
/**
|
|
2918
|
+
* @overload
|
|
2919
|
+
* @returns {DaemonStatus} Full status payload.
|
|
2920
|
+
*/
|
|
2921
|
+
/**
|
|
2922
|
+
* @overload
|
|
2923
|
+
* @param {{includeLogs: false}} options - Log-free status response options.
|
|
2924
|
+
* @returns {DaemonStatusWithoutLogs} Log-free status payload.
|
|
2925
|
+
*/
|
|
2926
|
+
/**
|
|
2927
|
+
* @overload
|
|
2928
|
+
* @param {{includeLogs: boolean}} options - Status response options.
|
|
2929
|
+
* @returns {DaemonStatus | DaemonStatusWithoutLogs} Status payload.
|
|
2930
|
+
*/
|
|
2931
|
+
/**
|
|
2932
|
+
* @param {{includeLogs?: boolean}} [options] - Status response options.
|
|
2933
|
+
* @returns {DaemonStatus | DaemonStatusWithoutLogs} Status payload.
|
|
2934
|
+
*/
|
|
2935
|
+
status({includeLogs = true} = {}) {
|
|
2893
2936
|
// Re-check liveness and prune the dead permanently, so the list self-clears as the operator
|
|
2894
2937
|
// stops the leftovers (e.g. via `rollbridge recover`). Pruning (not just filtering) matters:
|
|
2895
2938
|
// a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
|
|
@@ -2899,7 +2942,7 @@ export default class RollbridgeDaemon {
|
|
|
2899
2942
|
const singletonOwnerReleaseIds = new Set(this.singletonReleaseIds.values())
|
|
2900
2943
|
const transitionReleaseIds = this.generationTransitionReleaseIds()
|
|
2901
2944
|
|
|
2902
|
-
|
|
2945
|
+
const status = {
|
|
2903
2946
|
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
2904
2947
|
application: this.config.application,
|
|
2905
2948
|
bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
|
|
@@ -2930,6 +2973,8 @@ export default class RollbridgeDaemon {
|
|
|
2930
2973
|
process: processInstance.status()
|
|
2931
2974
|
}))
|
|
2932
2975
|
}
|
|
2976
|
+
|
|
2977
|
+
return includeLogs ? status : withoutStatusProcessLogs(status)
|
|
2933
2978
|
}
|
|
2934
2979
|
}
|
|
2935
2980
|
|
|
@@ -2941,6 +2986,50 @@ export function ownerConfigDigest(config) {
|
|
|
2941
2986
|
return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex")
|
|
2942
2987
|
}
|
|
2943
2988
|
|
|
2989
|
+
/**
|
|
2990
|
+
* Removes captured output from a live status response without changing any other status field.
|
|
2991
|
+
* @param {DaemonStatus} status - Full status payload.
|
|
2992
|
+
* @returns {DaemonStatusWithoutLogs} Log-free status payload.
|
|
2993
|
+
*/
|
|
2994
|
+
function withoutStatusProcessLogs(status) {
|
|
2995
|
+
return {
|
|
2996
|
+
...status,
|
|
2997
|
+
releases: status.releases.map((release) => ({
|
|
2998
|
+
...release,
|
|
2999
|
+
processes: release.processes.map(withoutProcessLogs)
|
|
3000
|
+
})),
|
|
3001
|
+
services: status.services.map(({process, ...service}) => ({
|
|
3002
|
+
...service,
|
|
3003
|
+
process: withoutProcessLogs(process)
|
|
3004
|
+
})),
|
|
3005
|
+
singletons: status.singletons.map(({process, ...singleton}) => ({
|
|
3006
|
+
...singleton,
|
|
3007
|
+
process: withoutProcessLogs(process)
|
|
3008
|
+
}))
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
/**
|
|
3013
|
+
* @param {import("./managed-process.js").ManagedProcessStatus} processStatus - Full managed-process status.
|
|
3014
|
+
* @returns {ManagedProcessStatusWithoutLogs} Status without captured process output.
|
|
3015
|
+
*/
|
|
3016
|
+
function withoutProcessLogs(processStatus) {
|
|
3017
|
+
const {logs: _logs, ...withoutLogs} = processStatus
|
|
3018
|
+
|
|
3019
|
+
return withoutLogs
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
/**
|
|
3023
|
+
* @param {JsonValue} value - Optional status request field.
|
|
3024
|
+
* @returns {boolean} Whether captured process output is included.
|
|
3025
|
+
*/
|
|
3026
|
+
function statusIncludeLogs(value) {
|
|
3027
|
+
if (value === undefined) return true
|
|
3028
|
+
if (typeof value !== "boolean") throw new Error("includeLogs must be a boolean")
|
|
3029
|
+
|
|
3030
|
+
return value
|
|
3031
|
+
}
|
|
3032
|
+
|
|
2944
3033
|
/**
|
|
2945
3034
|
* @param {PrivateOwnerState} transfer - Authenticated private incumbent state.
|
|
2946
3035
|
* @param {GenerationTransition} transition - Unresolved generation transition.
|
|
@@ -2968,6 +3057,7 @@ function terminalRetirementFailure(failure) {
|
|
|
2968
3057
|
return typeof failure === "string" && (
|
|
2969
3058
|
/^Cannot activate .+ generation from retired$/iu.test(failure) ||
|
|
2970
3059
|
/^activate command exited non-zero with status \d+$/u.test(failure) ||
|
|
3060
|
+
/^Process .+ is not retained for reactivation$/u.test(failure) ||
|
|
2971
3061
|
/^Release .+ retirement quiescence failed: quiet command exited non-zero with status \d+$/u.test(failure)
|
|
2972
3062
|
)
|
|
2973
3063
|
}
|
package/src/process-guardian.js
CHANGED
|
@@ -631,7 +631,11 @@ async function execute(request, socket) {
|
|
|
631
631
|
},
|
|
632
632
|
shouldRestart: () => record.desired
|
|
633
633
|
}
|
|
634
|
-
|
|
634
|
+
// Only process keys inventoried from the legacy guardian remain delegated to it.
|
|
635
|
+
// New release processes must be owned by the current guardian so current lifecycle
|
|
636
|
+
// fields (including activation timeouts) are enforced instead of downgraded by an
|
|
637
|
+
// older guardian protocol implementation.
|
|
638
|
+
const managedProcess = recoversLegacyProcess && legacyGuardian
|
|
635
639
|
? legacyGuardian.process(request.key, managedDefinition)
|
|
636
640
|
: new ManagedProcess(managedDefinition)
|
|
637
641
|
|
package/src/release-group.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
+
import {spawn} from "node:child_process"
|
|
3
4
|
import {EventEmitter} from "node:events"
|
|
4
5
|
import ManagedProcess from "./managed-process.js"
|
|
5
6
|
import {findAvailablePort} from "./port-allocator.js"
|
|
@@ -14,6 +15,40 @@ import {waitForHealth} from "./health.js"
|
|
|
14
15
|
* @typedef {{count?: number, guardianKey?: string, index?: number, instanceId?: string, shouldRestart?: () => boolean}} BuildProcessOptions
|
|
15
16
|
*/
|
|
16
17
|
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Runs a lifecycle authority command in the current daemon so nested legacy guardians
|
|
21
|
+
* cannot silently downgrade lifecycle timeout semantics for newly deployed processes.
|
|
22
|
+
* @param {{command: string, cwd: string, env: Record<string, string>, label: string, timeoutMs: number}} options - Command contract.
|
|
23
|
+
* @returns {Promise<void>} Completion after an acknowledged command.
|
|
24
|
+
*/
|
|
25
|
+
async function runLifecycleCommand({command, cwd, env, label, timeoutMs}) {
|
|
26
|
+
await /** @type {Promise<void>} */ (new Promise((resolve, reject) => {
|
|
27
|
+
const child = spawn(command, {cwd, detached: true, env: {...process.env, ...env}, shell: true, stdio: "ignore"})
|
|
28
|
+
let settled = false
|
|
29
|
+
/** @param {Error | undefined} error - Completion failure. */
|
|
30
|
+
/** @param {Error | undefined} error - Completion failure. */
|
|
31
|
+
const finish = (error) => {
|
|
32
|
+
if (settled) return
|
|
33
|
+
settled = true
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
if (error) reject(error)
|
|
36
|
+
else resolve()
|
|
37
|
+
}
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
if (child.pid) {
|
|
40
|
+
try { process.kill(-child.pid, "SIGKILL") } catch { /* The command already exited. */ }
|
|
41
|
+
}
|
|
42
|
+
finish(new Error(`${label} timed out after ${timeoutMs}ms`))
|
|
43
|
+
}, timeoutMs)
|
|
44
|
+
child.once("error", finish)
|
|
45
|
+
child.once("exit", (code, signal) => {
|
|
46
|
+
if (code === 0) finish(undefined)
|
|
47
|
+
else finish(new Error(`${label} exited non-zero with ${signal ? `signal ${signal}` : `status ${code ?? "unknown"}`}`))
|
|
48
|
+
})
|
|
49
|
+
}))
|
|
50
|
+
}
|
|
51
|
+
|
|
17
52
|
/**
|
|
18
53
|
* @param {string} id - Process id.
|
|
19
54
|
* @returns {string} Environment suffix.
|
|
@@ -276,7 +311,19 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
276
311
|
const [instance] = this.getProcesses(processConfig.id)
|
|
277
312
|
|
|
278
313
|
if (!instance) throw new Error(`Generation activation process ${processConfig.id} is not running for release ${this.releaseId}`)
|
|
279
|
-
|
|
314
|
+
if (instance.process.constructor.name === ManagedProcess.name) {
|
|
315
|
+
await instance.process.activateStrict()
|
|
316
|
+
} else {
|
|
317
|
+
const definition = this.processDefinition(processConfig)
|
|
318
|
+
await runLifecycleCommand({
|
|
319
|
+
command: /** @type {string} */ (processConfig.lifecycle.activateCommand),
|
|
320
|
+
cwd: /** @type {string} */ (definition.cwd),
|
|
321
|
+
env: /** @type {Record<string, string>} */ (definition.env),
|
|
322
|
+
label: "activate command",
|
|
323
|
+
timeoutMs: processConfig.lifecycle.activateTimeoutMs ?? 30000
|
|
324
|
+
})
|
|
325
|
+
await instance.process.setLifecycleRole("active")
|
|
326
|
+
}
|
|
280
327
|
}
|
|
281
328
|
|
|
282
329
|
/** Restores the retained generation coordinator to its active role in place. */
|
package/test/completion.test.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import test from "node:test"
|
|
3
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
5
4
|
import {runCli} from "../src/cli.js"
|
|
6
5
|
|
|
6
|
+
describe("completion", () => {
|
|
7
|
+
|
|
7
8
|
/**
|
|
8
9
|
* Runs the CLI while capturing stdout, stderr, and the resulting exit code.
|
|
9
10
|
* @param {string[]} argv - Process argv.
|
|
@@ -39,29 +40,30 @@ async function capture(argv) {
|
|
|
39
40
|
test("completion bash prints a sourceable script with commands and option flags", async () => {
|
|
40
41
|
const {code, output} = await capture(["node", "rollbridge", "completion", "bash"])
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
expect(code).not.toBe(1)
|
|
44
|
+
expect(output).toMatch(/complete -F _rollbridge rollbridge/)
|
|
45
|
+
expect(output).toMatch(/compgen -W "daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion"/)
|
|
45
46
|
// A command's own options are completed after the command.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
expect(output).toMatch(/deploy\)\n\s+opts="[^"]*--release-path[^"]*"/)
|
|
48
|
+
expect(output).toMatch(/recover-generation-transition\)\n\s+opts="--config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent"/)
|
|
49
|
+
expect(output).toMatch(/ensure-daemon\)\n\s+opts="[^"]*--daemon-runtime-path[^"]*"/)
|
|
50
|
+
expect(output).toMatch(/restart\)\n\s+opts="[^"]*--policy[^"]*"/)
|
|
50
51
|
})
|
|
51
52
|
|
|
52
53
|
test("completion zsh prints a #compdef script with per-command options", async () => {
|
|
53
54
|
const {output} = await capture(["node", "rollbridge", "completion", "zsh"])
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
expect(output).toMatch(/^#compdef rollbridge/)
|
|
57
|
+
expect(output).toMatch(/compdef _rollbridge rollbridge/)
|
|
58
|
+
expect(output).toMatch(/commands=\(daemon deploy rollback recover-generation-transition ensure-daemon status stop restart shutdown validate doctor logs events predeploy-cleanup recover completion\)/)
|
|
59
|
+
expect(output).toMatch(/recover-generation-transition\) compadd -- --config --release-path --release-id --revision --previous-release-id --accept-retired-incumbent/)
|
|
60
|
+
expect(output).toMatch(/events\) compadd -- [^\n]*--limit/)
|
|
60
61
|
})
|
|
61
62
|
|
|
62
63
|
test("completion rejects an unsupported shell with a non-zero exit code", async () => {
|
|
63
64
|
const {code, errorOutput} = await capture(["node", "rollbridge", "completion", "fish"])
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
expect(code).toBe(1)
|
|
67
|
+
expect(errorOutput).toMatch(/Unsupported shell "fish"\. Supported shells: bash, zsh\./)
|
|
68
|
+
})
|
|
67
69
|
})
|
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs/promises"
|
|
5
4
|
import os from "node:os"
|
|
6
5
|
import path from "node:path"
|
|
7
|
-
import test from "
|
|
6
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
8
7
|
import {fileURLToPath} from "node:url"
|
|
9
8
|
import {loadConfig} from "../src/config.js"
|
|
10
9
|
|
|
10
|
+
describe("config-examples", () => {
|
|
11
|
+
|
|
11
12
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
12
13
|
|
|
13
14
|
test("TensorBuzz example config loads", async () => {
|
|
14
15
|
const config = await loadConfig(path.join(repoRoot, "examples", "tensorbuzz.com.js"))
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
config.processes.map((processConfig) => [processConfig.id, processConfig.policy]),
|
|
23
|
-
[
|
|
17
|
+
expect(config.application).toBe("tensorbuzz")
|
|
18
|
+
expect(config.control.path).toBe("/tmp/rollbridge-tensorbuzz.sock")
|
|
19
|
+
expect(config.proxy.host).toBe("127.0.0.1")
|
|
20
|
+
expect(config.proxy.port).toBe(4500)
|
|
21
|
+
expect(config.proxy.healthPath).toBe("/ping")
|
|
22
|
+
expect(config.processes.map((processConfig) => [processConfig.id, processConfig.policy])).toEqual([
|
|
24
23
|
["beacon", "service"],
|
|
25
24
|
["background-jobs-main", "service"],
|
|
26
25
|
["background-jobs-worker", "companion"],
|
|
27
26
|
["web", "proxied"]
|
|
28
|
-
]
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
assert.equal(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT, "{{ports.background-jobs-main}}")
|
|
27
|
+
])
|
|
28
|
+
expect(config.processes[2].lifecycle.reactivateCommand).toBe("appctl jobs-worker-reactivate --pid $ROLLBRIDGE_PID")
|
|
29
|
+
expect(config.processes[3].env.VELOCIOUS_BACKGROUND_JOBS_PORT).toBe("{{ports.background-jobs-main}}")
|
|
32
30
|
})
|
|
33
31
|
|
|
34
32
|
test("loadConfig resolves a config module that exports a function", async () => {
|
|
@@ -50,11 +48,12 @@ test("loadConfig resolves a config module that exports a function", async () =>
|
|
|
50
48
|
try {
|
|
51
49
|
const config = await loadConfig(configPath)
|
|
52
50
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
expect(config.application).toBe("computed-app")
|
|
52
|
+
expect(config.proxy.port).toBe(8190)
|
|
53
|
+
expect(config.processes[0].id).toBe("web")
|
|
56
54
|
} finally {
|
|
57
55
|
delete process.env.ROLLBRIDGE_TEST_APP
|
|
58
56
|
await fs.rm(dir, {force: true, recursive: true})
|
|
59
57
|
}
|
|
60
58
|
})
|
|
59
|
+
})
|
package/test/config-path.test.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import assert from "node:assert/strict"
|
|
4
3
|
import fs from "node:fs/promises"
|
|
5
4
|
import os from "node:os"
|
|
6
5
|
import path from "node:path"
|
|
7
|
-
import test from "
|
|
6
|
+
import {describe, expect, test} from "@velocious/testing"
|
|
8
7
|
import {loadConfig, resolveConfigPath} from "../src/config.js"
|
|
9
8
|
import {runCli} from "../src/cli.js"
|
|
10
9
|
|
|
10
|
+
describe("config-path", () => {
|
|
11
|
+
|
|
11
12
|
const validConfig = {
|
|
12
13
|
application: "demo",
|
|
13
14
|
control: {path: "/tmp/rollbridge-config-path.sock"},
|
|
@@ -34,7 +35,7 @@ test("resolveConfigPath returns an explicit path unchanged", async () => {
|
|
|
34
35
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
35
36
|
|
|
36
37
|
try {
|
|
37
|
-
|
|
38
|
+
expect(await resolveConfigPath("/somewhere/custom.js", dir)).toBe("/somewhere/custom.js")
|
|
38
39
|
} finally {
|
|
39
40
|
await fs.rm(dir, {force: true, recursive: true})
|
|
40
41
|
}
|
|
@@ -46,7 +47,7 @@ test("resolveConfigPath resolves rollbridge.js in the working directory", async
|
|
|
46
47
|
try {
|
|
47
48
|
await writeConfigModule(dir)
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
expect(await resolveConfigPath(undefined, dir)).toBe(path.join(dir, "rollbridge.js"))
|
|
50
51
|
} finally {
|
|
51
52
|
await fs.rm(dir, {force: true, recursive: true})
|
|
52
53
|
}
|
|
@@ -56,10 +57,7 @@ test("resolveConfigPath throws an actionable error when no default config exists
|
|
|
56
57
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
57
58
|
|
|
58
59
|
try {
|
|
59
|
-
await
|
|
60
|
-
() => resolveConfigPath(undefined, dir),
|
|
61
|
-
/No config file found.*rollbridge\.js/
|
|
62
|
-
)
|
|
60
|
+
await expect(resolveConfigPath(undefined, dir)).rejects.toThrow(/No config file found.*rollbridge\.js/)
|
|
63
61
|
} finally {
|
|
64
62
|
await fs.rm(dir, {force: true, recursive: true})
|
|
65
63
|
}
|
|
@@ -70,11 +68,11 @@ test("loadConfig reloads a changed CommonJS module", async () => {
|
|
|
70
68
|
const configPath = await writeConfigModule(dir)
|
|
71
69
|
|
|
72
70
|
try {
|
|
73
|
-
|
|
71
|
+
expect((await loadConfig(configPath)).application).toBe("demo")
|
|
74
72
|
|
|
75
73
|
await fs.writeFile(configPath, `module.exports = ${JSON.stringify({...validConfig, application: "updated"}, null, 2)}\n`)
|
|
76
74
|
|
|
77
|
-
|
|
75
|
+
expect((await loadConfig(configPath)).application).toBe("updated")
|
|
78
76
|
} finally {
|
|
79
77
|
await fs.rm(dir, {force: true, recursive: true})
|
|
80
78
|
}
|
|
@@ -101,5 +99,6 @@ test("validate CLI command resolves the default config when --config is omitted"
|
|
|
101
99
|
await fs.rm(dir, {force: true, recursive: true})
|
|
102
100
|
}
|
|
103
101
|
|
|
104
|
-
|
|
102
|
+
expect(lines.join("\n")).toMatch(/rollbridge\.js is valid: 1 process, proxy on 127\.0\.0\.1:8182\./)
|
|
103
|
+
})
|
|
105
104
|
})
|