rollbridge 0.1.10 → 0.1.12
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/README.md +6 -2
- package/docs/cli.md +8 -0
- package/docs/config.md +22 -0
- package/package.json +4 -3
- package/src/cli.js +1 -1
- package/src/config.js +10 -1
- package/src/daemon.js +56 -20
- package/src/release-group.js +38 -4
- package/test/config-path.test.js +16 -1
- package/test/rollbridge.test.js +104 -6
package/README.md
CHANGED
|
@@ -543,8 +543,12 @@ so its output goes to the journal (`journalctl -u rollbridge`). Key directives:
|
|
|
543
543
|
The daemon is long-lived and survives deploys. **Deploy with
|
|
544
544
|
`rollbridge deploy` (or `rollbridge deploy --ensure-daemon`), not
|
|
545
545
|
`systemctl restart`** — pointing `--config` at a stable, daemon-wide file while
|
|
546
|
-
release paths are passed per deploy.
|
|
547
|
-
|
|
546
|
+
release paths are passed per deploy. The daemon reloads compatible process and
|
|
547
|
+
lifecycle config before each deploy, so updated graceful-stop deadlines govern
|
|
548
|
+
the release being retired without interrupting the stable proxy. Listener and
|
|
549
|
+
process-topology changes still require a daemon restart; see
|
|
550
|
+
[`docs/config.md`](docs/config.md#config-reloads). Use `command -v rollbridge`
|
|
551
|
+
to find the absolute CLI path for `ExecStart`.
|
|
548
552
|
|
|
549
553
|
See [`docs/logging.md`](docs/logging.md) for where the daemon's JSON logs go
|
|
550
554
|
(stdout / journald / the `--daemon-log-path` file) and how to rotate them — the
|
package/docs/cli.md
CHANGED
|
@@ -72,6 +72,14 @@ traffic to it, then drains and stops the previous release. Prints
|
|
|
72
72
|
If the new release fails to start or health-check, the previous release stays
|
|
73
73
|
active and the command errors.
|
|
74
74
|
|
|
75
|
+
Before each deploy, the daemon reloads the config path it was started with.
|
|
76
|
+
Compatible process and lifecycle changes apply to the new release and govern
|
|
77
|
+
how the previous release retires, including updated `nonBlockingDrain`,
|
|
78
|
+
`stopSignal`, `lifecycle`, and `gracefulStopMs` settings. The daemon adopts the
|
|
79
|
+
new config only after the replacement release starts successfully. Changes to
|
|
80
|
+
daemon-owned listeners or process topology fail the deploy with a restart
|
|
81
|
+
instruction; see [Config reloads](config.md#config-reloads).
|
|
82
|
+
|
|
75
83
|
- `--release-path <path>` (**required**) — path to the prepared release
|
|
76
84
|
directory; available to process templates as `{{releasePath}}`.
|
|
77
85
|
- `--release-id <id>` — identifier for the release. Defaults to `--revision`,
|
package/docs/config.md
CHANGED
|
@@ -17,6 +17,28 @@ export default {
|
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
+
## Config reloads
|
|
21
|
+
|
|
22
|
+
The daemon reloads the config file it was started with before every deploy and
|
|
23
|
+
rollback. Compatible changes apply to the replacement release, daemon-wide
|
|
24
|
+
service restart definitions, and the retirement of the previous release. This
|
|
25
|
+
means changes to process commands, environment, health checks, lifecycle hooks,
|
|
26
|
+
`nonBlockingDrain`, stop signals, graceful-stop timeouts, restart policies, and
|
|
27
|
+
memory supervision do not require restarting the stable proxy daemon. Updated
|
|
28
|
+
retirement settings also apply to processes that were started by the previous
|
|
29
|
+
release.
|
|
30
|
+
|
|
31
|
+
The replacement config is adopted only after the new release starts and passes
|
|
32
|
+
its health check. Invalid config, an incompatible change, or a failed release
|
|
33
|
+
leaves the previous config and release active.
|
|
34
|
+
|
|
35
|
+
Settings that own daemon listeners or change the managed-process topology still
|
|
36
|
+
require a daemon restart: `application`, `control`, `statePath`, `proxy.host`,
|
|
37
|
+
`proxy.port`, `proxy.upstreamHost`, and changes to process ids, count, `policy`,
|
|
38
|
+
`deployStrategy`, `replicas`, or `port`. A deploy with one of these changes
|
|
39
|
+
fails before starting the replacement and names the settings that require a
|
|
40
|
+
restart.
|
|
41
|
+
|
|
20
42
|
## Top-level fields
|
|
21
43
|
|
|
22
44
|
| Field | Type | Default | Description |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rollbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Zero-downtime process supervisor and local traffic switcher for deploy-managed apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deploy",
|
|
@@ -27,10 +27,11 @@
|
|
|
27
27
|
},
|
|
28
28
|
"scripts": {
|
|
29
29
|
"all-checks": "npm run typecheck && npm run lint && npm test",
|
|
30
|
-
"lint": "eslint",
|
|
30
|
+
"lint": "npm run eslint && npm run typecheck",
|
|
31
31
|
"release:patch": "release-patch",
|
|
32
32
|
"test": "node --test test/*.test.js",
|
|
33
|
-
"typecheck": "tsc --noEmit"
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"eslint": "eslint"
|
|
34
35
|
},
|
|
35
36
|
"engines": {
|
|
36
37
|
"node": ">=20"
|
package/src/cli.js
CHANGED
|
@@ -33,7 +33,7 @@ export async function runCli(argv) {
|
|
|
33
33
|
.action(async (options) => {
|
|
34
34
|
const configPath = await resolveConfigPath(options.config)
|
|
35
35
|
const config = await loadConfig(configPath)
|
|
36
|
-
const daemon = new RollbridgeDaemon({config})
|
|
36
|
+
const daemon = new RollbridgeDaemon({config, configPath})
|
|
37
37
|
|
|
38
38
|
await daemon.start()
|
|
39
39
|
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import fs from "node:fs/promises"
|
|
4
|
+
import {createHash} from "node:crypto"
|
|
5
|
+
import {createRequire} from "node:module"
|
|
4
6
|
import os from "node:os"
|
|
5
7
|
import path from "node:path"
|
|
6
8
|
import {pathToFileURL} from "node:url"
|
|
@@ -27,6 +29,7 @@ import {pathToFileURL} from "node:url"
|
|
|
27
29
|
|
|
28
30
|
const PROCESS_POLICIES = new Set(["proxied", "companion", "singleton", "service"])
|
|
29
31
|
const DEFAULT_CONFIG_FILENAMES = ["rollbridge.js"]
|
|
32
|
+
const commonJsRequire = createRequire(import.meta.url)
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
35
|
* Imports a JavaScript config module without validating it.
|
|
@@ -38,7 +41,13 @@ const DEFAULT_CONFIG_FILENAMES = ["rollbridge.js"]
|
|
|
38
41
|
*/
|
|
39
42
|
export async function parseConfigFile(configPath) {
|
|
40
43
|
const absolutePath = path.resolve(configPath)
|
|
41
|
-
const
|
|
44
|
+
const configUrl = pathToFileURL(absolutePath)
|
|
45
|
+
const configSource = await fs.readFile(absolutePath)
|
|
46
|
+
|
|
47
|
+
configUrl.searchParams.set("rollbridgeConfig", createHash("sha256").update(configSource).digest("hex"))
|
|
48
|
+
delete commonJsRequire.cache[commonJsRequire.resolve(absolutePath)]
|
|
49
|
+
|
|
50
|
+
const moduleNamespace = await import(configUrl.href)
|
|
42
51
|
const exported = moduleNamespace.default
|
|
43
52
|
|
|
44
53
|
if (exported === undefined) {
|
package/src/daemon.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import fs from "node:fs/promises"
|
|
4
4
|
import http from "node:http"
|
|
5
5
|
import net from "node:net"
|
|
6
|
+
import {isDeepStrictEqual} from "node:util"
|
|
6
7
|
import httpProxy from "http-proxy"
|
|
8
|
+
import {loadConfig} from "./config.js"
|
|
7
9
|
import EventLog from "./event-log.js"
|
|
8
10
|
import ReleaseGroup from "./release-group.js"
|
|
9
11
|
import {clearState, isProcessAlive, liveProcesses, readState, writeState} from "./state-store.js"
|
|
@@ -23,10 +25,12 @@ export default class RollbridgeDaemon {
|
|
|
23
25
|
/**
|
|
24
26
|
* @param {object} args - Options.
|
|
25
27
|
* @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
|
|
28
|
+
* @param {string} [args.configPath] - Config file path to reload before deploys.
|
|
26
29
|
* @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
|
|
27
30
|
*/
|
|
28
|
-
constructor({config, logger}) {
|
|
31
|
+
constructor({config, configPath, logger}) {
|
|
29
32
|
this.config = config
|
|
33
|
+
this.configPath = configPath
|
|
30
34
|
this.eventLog = new EventLog(EVENT_HISTORY_LIMIT)
|
|
31
35
|
|
|
32
36
|
const baseLogger = logger || ((message, data = {}) => console.log(JSON.stringify({at: new Date().toISOString(), data, message})))
|
|
@@ -319,9 +323,13 @@ export default class RollbridgeDaemon {
|
|
|
319
323
|
async deploy({releaseId, releasePath, revision}) {
|
|
320
324
|
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
321
325
|
|
|
326
|
+
const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
|
|
327
|
+
|
|
328
|
+
this.assertReloadCompatible(nextConfig)
|
|
329
|
+
|
|
322
330
|
const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
|
|
323
331
|
const release = new ReleaseGroup({
|
|
324
|
-
config:
|
|
332
|
+
config: nextConfig,
|
|
325
333
|
logger: this.logger,
|
|
326
334
|
releaseId: newReleaseId,
|
|
327
335
|
releasePath,
|
|
@@ -343,6 +351,7 @@ export default class RollbridgeDaemon {
|
|
|
343
351
|
|
|
344
352
|
const previousRelease = this.activeRelease
|
|
345
353
|
|
|
354
|
+
this.config = nextConfig
|
|
346
355
|
this.releases.set(release.releaseId, release)
|
|
347
356
|
release.activate()
|
|
348
357
|
this.activeRelease = release
|
|
@@ -352,7 +361,7 @@ export default class RollbridgeDaemon {
|
|
|
352
361
|
await this.replaceSingletons(release)
|
|
353
362
|
|
|
354
363
|
if (previousRelease) {
|
|
355
|
-
void this.drainAndPrune(previousRelease)
|
|
364
|
+
void this.drainAndPrune(previousRelease, nextConfig)
|
|
356
365
|
}
|
|
357
366
|
|
|
358
367
|
this.persistState()
|
|
@@ -363,6 +372,45 @@ export default class RollbridgeDaemon {
|
|
|
363
372
|
}
|
|
364
373
|
}
|
|
365
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Rejects config changes that require rebinding daemon-owned resources or changing process topology.
|
|
377
|
+
* @param {import("./config.js").RollbridgeConfig} nextConfig - Freshly loaded config.
|
|
378
|
+
* @returns {void}
|
|
379
|
+
*/
|
|
380
|
+
assertReloadCompatible(nextConfig) {
|
|
381
|
+
/** @type {string[]} */
|
|
382
|
+
const restartRequired = []
|
|
383
|
+
|
|
384
|
+
if (nextConfig.application !== this.config.application) restartRequired.push("application")
|
|
385
|
+
if (!isDeepStrictEqual(nextConfig.control, this.config.control)) restartRequired.push("control")
|
|
386
|
+
if (nextConfig.statePath !== this.config.statePath) restartRequired.push("statePath")
|
|
387
|
+
|
|
388
|
+
if (nextConfig.proxy.host !== this.config.proxy.host) restartRequired.push("proxy.host")
|
|
389
|
+
if (nextConfig.proxy.port !== this.config.proxy.port) restartRequired.push("proxy.port")
|
|
390
|
+
if (nextConfig.proxy.upstreamHost !== this.config.proxy.upstreamHost) restartRequired.push("proxy.upstreamHost")
|
|
391
|
+
|
|
392
|
+
if (nextConfig.processes.length !== this.config.processes.length) {
|
|
393
|
+
restartRequired.push("processes")
|
|
394
|
+
} else {
|
|
395
|
+
for (const processConfig of this.config.processes) {
|
|
396
|
+
const nextProcessConfig = nextConfig.processes.find((candidate) => candidate.id === processConfig.id)
|
|
397
|
+
|
|
398
|
+
if (!nextProcessConfig ||
|
|
399
|
+
nextProcessConfig.policy !== processConfig.policy ||
|
|
400
|
+
nextProcessConfig.deployStrategy !== processConfig.deployStrategy ||
|
|
401
|
+
nextProcessConfig.replicas !== processConfig.replicas ||
|
|
402
|
+
!isDeepStrictEqual(nextProcessConfig.port, processConfig.port)) {
|
|
403
|
+
restartRequired.push("processes")
|
|
404
|
+
break
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (restartRequired.length > 0) {
|
|
410
|
+
throw new Error(`Config changes to ${restartRequired.join(", ")} cannot be applied live; restart the Rollbridge daemon before deploying.`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
366
414
|
/**
|
|
367
415
|
* Rolls back to a previously-active release by re-running the deploy flow on its
|
|
368
416
|
* retained metadata: it re-starts the target release, health-checks it, switches
|
|
@@ -418,7 +466,7 @@ export default class RollbridgeDaemon {
|
|
|
418
466
|
async ensureServices(release, startedServices) {
|
|
419
467
|
await release.allocatePorts()
|
|
420
468
|
|
|
421
|
-
for (const processConfig of
|
|
469
|
+
for (const processConfig of release.config.processes) {
|
|
422
470
|
if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
|
|
423
471
|
if (this.services.has(processConfig.id)) continue
|
|
424
472
|
|
|
@@ -473,20 +521,7 @@ export default class RollbridgeDaemon {
|
|
|
473
521
|
|
|
474
522
|
const nextDefinition = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
|
|
475
523
|
|
|
476
|
-
service.updateDefinition(
|
|
477
|
-
command: nextDefinition.command,
|
|
478
|
-
cwd: nextDefinition.cwd,
|
|
479
|
-
env: nextDefinition.env,
|
|
480
|
-
lifecycle: nextDefinition.lifecycle,
|
|
481
|
-
logger: nextDefinition.logger,
|
|
482
|
-
memory: nextDefinition.memory,
|
|
483
|
-
outputLines: nextDefinition.outputLines,
|
|
484
|
-
restart: nextDefinition.restart,
|
|
485
|
-
restartDelayMs: nextDefinition.restartDelayMs,
|
|
486
|
-
shouldRestart: nextDefinition.shouldRestart,
|
|
487
|
-
stopSignal: nextDefinition.stopSignal,
|
|
488
|
-
stopTimeoutMs: nextDefinition.stopTimeoutMs
|
|
489
|
-
})
|
|
524
|
+
service.updateDefinition(nextDefinition)
|
|
490
525
|
}
|
|
491
526
|
}
|
|
492
527
|
|
|
@@ -611,11 +646,12 @@ export default class RollbridgeDaemon {
|
|
|
611
646
|
/**
|
|
612
647
|
* Drains and stops a retired release in the background, then prunes stopped releases.
|
|
613
648
|
* @param {ReleaseGroup} release - Release to drain and stop.
|
|
649
|
+
* @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
|
|
614
650
|
* @returns {Promise<void>} Resolves once drained, stopped, and pruned.
|
|
615
651
|
*/
|
|
616
|
-
async drainAndPrune(release) {
|
|
652
|
+
async drainAndPrune(release, config = this.config) {
|
|
617
653
|
try {
|
|
618
|
-
await release.drainAndStop(
|
|
654
|
+
await release.drainAndStop(config.proxy.drainTimeoutMs, config)
|
|
619
655
|
this.logger("release drained", {releaseId: release.releaseId})
|
|
620
656
|
} catch (error) {
|
|
621
657
|
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
|
package/src/release-group.js
CHANGED
|
@@ -91,8 +91,9 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
91
91
|
}
|
|
92
92
|
} catch (error) {
|
|
93
93
|
this.state = "failed"
|
|
94
|
-
this.logStartupFailure(error instanceof Error ? error : String(error))
|
|
94
|
+
this.logStartupFailure(error instanceof Error ? error : String(error), {phase: "before cleanup"})
|
|
95
95
|
await this.stop()
|
|
96
|
+
this.logStartupFailure(error instanceof Error ? error : String(error), {phase: "after cleanup"})
|
|
96
97
|
throw error
|
|
97
98
|
}
|
|
98
99
|
}
|
|
@@ -125,13 +126,15 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
|
-
* Logs process diagnostics
|
|
129
|
+
* Logs process diagnostics around failed startup cleanup.
|
|
129
130
|
* @param {Error | string} error - Startup failure.
|
|
131
|
+
* @param {{phase: string}} options - Diagnostic phase.
|
|
130
132
|
* @returns {void}
|
|
131
133
|
*/
|
|
132
|
-
logStartupFailure(error) {
|
|
134
|
+
logStartupFailure(error, {phase}) {
|
|
133
135
|
this.logger("release startup failed", {
|
|
134
136
|
error: error instanceof Error ? error.message : error,
|
|
137
|
+
phase,
|
|
135
138
|
releaseId: this.releaseId
|
|
136
139
|
})
|
|
137
140
|
|
|
@@ -143,6 +146,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
143
146
|
exitCode: status.exitCode ?? null,
|
|
144
147
|
exitSignal: status.exitSignal ?? null,
|
|
145
148
|
logs: status.logs,
|
|
149
|
+
phase,
|
|
146
150
|
pid: status.pid ?? null,
|
|
147
151
|
processId: status.id,
|
|
148
152
|
releaseId: this.releaseId,
|
|
@@ -228,6 +232,34 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
228
232
|
})
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Applies refreshed process definitions before retiring this release.
|
|
237
|
+
* @param {import("./config.js").RollbridgeConfig} config - Current deployment config.
|
|
238
|
+
* @returns {void}
|
|
239
|
+
*/
|
|
240
|
+
refreshProcessDefinitions(config) {
|
|
241
|
+
this.config = config
|
|
242
|
+
this.handoffServiceIds.clear()
|
|
243
|
+
this.nonBlockingDrainIds.clear()
|
|
244
|
+
|
|
245
|
+
for (const processConfig of config.processes) {
|
|
246
|
+
const instances = this.getProcesses(processConfig.id)
|
|
247
|
+
|
|
248
|
+
for (let index = 0; index < instances.length; index += 1) {
|
|
249
|
+
const instance = instances[index]
|
|
250
|
+
const nextDefinition = this.buildProcess(processConfig, {
|
|
251
|
+
count: processConfig.replicas,
|
|
252
|
+
index,
|
|
253
|
+
instanceId: instance.id
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
instance.process.updateDefinition(nextDefinition)
|
|
257
|
+
if (processConfig.policy === "service" && processConfig.deployStrategy === "handoff") this.handoffServiceIds.add(instance.id)
|
|
258
|
+
if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(instance.id)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
231
263
|
/**
|
|
232
264
|
* @param {import("./config.js").ProcessConfig} processConfig - Process config.
|
|
233
265
|
* @param {{count: number, index: number}} replica - Replica index and total count.
|
|
@@ -321,13 +353,15 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
321
353
|
/**
|
|
322
354
|
* Starts draining and stops once existing connections close or timeout.
|
|
323
355
|
* @param {number} timeoutMs - Drain timeout.
|
|
356
|
+
* @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
|
|
324
357
|
* @returns {Promise<void>} Resolves when stopped.
|
|
325
358
|
*/
|
|
326
|
-
async drainAndStop(timeoutMs) {
|
|
359
|
+
async drainAndStop(timeoutMs, config = this.config) {
|
|
327
360
|
if (this.state === "stopped") return
|
|
328
361
|
|
|
329
362
|
this.state = "draining"
|
|
330
363
|
this.drainStartedAt = new Date().toISOString()
|
|
364
|
+
this.refreshProcessDefinitions(config)
|
|
331
365
|
|
|
332
366
|
// Stop nonBlockingDrain processes (e.g. job workers) immediately and in the background, so
|
|
333
367
|
// their lifecycle drain runs as soon as the release is retired — in parallel with the
|
package/test/config-path.test.js
CHANGED
|
@@ -5,7 +5,7 @@ import fs from "node:fs/promises"
|
|
|
5
5
|
import os from "node:os"
|
|
6
6
|
import path from "node:path"
|
|
7
7
|
import test from "node:test"
|
|
8
|
-
import {resolveConfigPath} from "../src/config.js"
|
|
8
|
+
import {loadConfig, resolveConfigPath} from "../src/config.js"
|
|
9
9
|
import {runCli} from "../src/cli.js"
|
|
10
10
|
|
|
11
11
|
const validConfig = {
|
|
@@ -65,6 +65,21 @@ test("resolveConfigPath throws an actionable error when no default config exists
|
|
|
65
65
|
}
|
|
66
66
|
})
|
|
67
67
|
|
|
68
|
+
test("loadConfig reloads a changed CommonJS module", async () => {
|
|
69
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
70
|
+
const configPath = await writeConfigModule(dir)
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
assert.equal((await loadConfig(configPath)).application, "demo")
|
|
74
|
+
|
|
75
|
+
await fs.writeFile(configPath, `module.exports = ${JSON.stringify({...validConfig, application: "updated"}, null, 2)}\n`)
|
|
76
|
+
|
|
77
|
+
assert.equal((await loadConfig(configPath)).application, "updated")
|
|
78
|
+
} finally {
|
|
79
|
+
await fs.rm(dir, {force: true, recursive: true})
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
68
83
|
test("validate CLI command resolves the default config when --config is omitted", async () => {
|
|
69
84
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-cfgpath-"))
|
|
70
85
|
const originalCwd = process.cwd()
|
package/test/rollbridge.test.js
CHANGED
|
@@ -102,6 +102,96 @@ test("failed health check leaves the previous release active", async () => {
|
|
|
102
102
|
}
|
|
103
103
|
})
|
|
104
104
|
|
|
105
|
+
test("deploy reloads process config and retires the previous worker with the refreshed timeout", async () => {
|
|
106
|
+
const fixture = await createFixture({nonBlockingDrainWorker: true, workerStopDelayMs: 10000})
|
|
107
|
+
const initialConfig = normalizeConfig({
|
|
108
|
+
...fixture.config,
|
|
109
|
+
processes: fixture.config.processes.map((processConfig) => processConfig.id === "worker"
|
|
110
|
+
? {...processConfig, gracefulStopMs: "indefinite"}
|
|
111
|
+
: processConfig)
|
|
112
|
+
})
|
|
113
|
+
const configPath = await writeConfigFile(initialConfig, fixture.root)
|
|
114
|
+
const daemon = new RollbridgeDaemon({config: initialConfig, configPath, logger: () => {}})
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
await daemon.start()
|
|
118
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
119
|
+
|
|
120
|
+
const refreshedConfig = normalizeConfig({
|
|
121
|
+
...initialConfig,
|
|
122
|
+
processes: initialConfig.processes.map((processConfig) => processConfig.id === "worker"
|
|
123
|
+
? {...processConfig, gracefulStopMs: 50}
|
|
124
|
+
: processConfig)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await writeConfigFile(refreshedConfig, fixture.root)
|
|
128
|
+
await daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"})
|
|
129
|
+
await waitFor(() => statusRelease(daemon, "v1").processes.find((processStatus) => processStatus.id === "worker")?.state === "stopped", 1000)
|
|
130
|
+
|
|
131
|
+
assert.equal(daemon.config.processes.find((processConfig) => processConfig.id === "worker")?.gracefulStopMs, 50)
|
|
132
|
+
assert.equal(await fetchText(daemon, "/release"), "v2")
|
|
133
|
+
} finally {
|
|
134
|
+
await daemon.shutdown()
|
|
135
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test("deploy rejects a reloaded config that changes the running proxy", async () => {
|
|
140
|
+
const fixture = await createFixture()
|
|
141
|
+
const configPath = await writeConfigFile(fixture.config, fixture.root)
|
|
142
|
+
const daemon = new RollbridgeDaemon({config: fixture.config, configPath, logger: () => {}})
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
await daemon.start()
|
|
146
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
147
|
+
await writeConfigFile(normalizeConfig({
|
|
148
|
+
...fixture.config,
|
|
149
|
+
proxy: {...fixture.config.proxy, host: "0.0.0.0"}
|
|
150
|
+
}), fixture.root)
|
|
151
|
+
|
|
152
|
+
await assert.rejects(
|
|
153
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
154
|
+
/proxy\.host.*restart the Rollbridge daemon/
|
|
155
|
+
)
|
|
156
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
157
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
158
|
+
} finally {
|
|
159
|
+
await daemon.shutdown()
|
|
160
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("a failed deploy does not adopt reloaded process config", async () => {
|
|
165
|
+
const fixture = await createFixture()
|
|
166
|
+
const configPath = await writeConfigFile(fixture.config, fixture.root)
|
|
167
|
+
const daemon = new RollbridgeDaemon({config: fixture.config, configPath, logger: () => {}})
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await daemon.start()
|
|
171
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
172
|
+
|
|
173
|
+
const failingConfig = normalizeConfig({
|
|
174
|
+
...fixture.config,
|
|
175
|
+
processes: fixture.config.processes.map((processConfig) => processConfig.id === "web"
|
|
176
|
+
? {...processConfig, health: {...processConfig.health, path: "/not-ready", timeoutMs: 100}}
|
|
177
|
+
: processConfig)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await writeConfigFile(failingConfig, fixture.root)
|
|
181
|
+
await assert.rejects(
|
|
182
|
+
() => daemon.deploy({releaseId: "v2", releasePath: fixture.root, revision: "v2"}),
|
|
183
|
+
/Health check failed/
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
assert.equal(daemon.config.processes.find((processConfig) => processConfig.id === "web")?.health?.path, "/ping")
|
|
187
|
+
assert.equal(daemon.status().activeReleaseId, "v1")
|
|
188
|
+
assert.equal(await fetchText(daemon, "/release"), "v1")
|
|
189
|
+
} finally {
|
|
190
|
+
await daemon.shutdown()
|
|
191
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
105
195
|
test("wildcard proxy bind host targets release processes through loopback", async () => {
|
|
106
196
|
const fixture = await createFixture({proxyHost: "0.0.0.0"})
|
|
107
197
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -123,8 +213,8 @@ test("wildcard proxy bind host targets release processes through loopback", asyn
|
|
|
123
213
|
}
|
|
124
214
|
})
|
|
125
215
|
|
|
126
|
-
test("failed release startup logs process output
|
|
127
|
-
const fixture = await createFixture({webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
|
|
216
|
+
test("failed release startup logs process output and cleanup status", async () => {
|
|
217
|
+
const fixture = await createFixture({handoffService: true, webCommand: `${JSON.stringify(process.execPath)} -e "console.log('startup stdout'); console.error('startup stderr'); const http = require('node:http'); http.createServer((_request, response) => { response.writeHead(500); response.end('bad') }).listen(Number(process.env.ROLLBRIDGE_PORT), '127.0.0.1')"`, webHealthTimeoutMs: 500})
|
|
128
218
|
/** @type {Array<{data?: Record<string, import("../src/json.js").JsonValue>, message: string}>} */
|
|
129
219
|
const logs = []
|
|
130
220
|
const daemon = new RollbridgeDaemon({
|
|
@@ -140,13 +230,22 @@ test("failed release startup logs process output before cleanup", async () => {
|
|
|
140
230
|
/Health check failed/
|
|
141
231
|
)
|
|
142
232
|
|
|
143
|
-
const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.processId === "web")
|
|
233
|
+
const processStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "before cleanup" && entry.data?.processId === "web")
|
|
234
|
+
const cleanupProcessStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "web")
|
|
235
|
+
const handoffServiceStatusLog = logs.find((entry) => entry.message === "release startup process status" && entry.data?.phase === "after cleanup" && entry.data?.processId === "beacon")
|
|
144
236
|
|
|
145
237
|
assert.ok(processStatusLog, "expected failed web process diagnostics to be logged")
|
|
146
238
|
assert.ok(processStatusLog.data, "expected diagnostic data")
|
|
147
239
|
assert.ok(Array.isArray(processStatusLog.data.logs), "expected retained process output in diagnostics")
|
|
148
240
|
assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stdout"))
|
|
149
241
|
assert.ok(processStatusLog.data.logs.some((entry) => typeof entry === "object" && entry && "line" in entry && entry.line === "startup stderr"))
|
|
242
|
+
assert.equal(processStatusLog.data.state, "running")
|
|
243
|
+
assert.ok(cleanupProcessStatusLog, "expected failed web cleanup diagnostics to be logged")
|
|
244
|
+
assert.equal(cleanupProcessStatusLog.data?.state, "stopped")
|
|
245
|
+
assert.equal(cleanupProcessStatusLog.data?.exitSignal, "SIGTERM")
|
|
246
|
+
assert.ok(handoffServiceStatusLog, "expected handoff service cleanup diagnostics to be logged")
|
|
247
|
+
assert.equal(handoffServiceStatusLog.data?.state, "stopped")
|
|
248
|
+
assert.equal(handoffServiceStatusLog.data?.exitSignal, "SIGTERM")
|
|
150
249
|
} finally {
|
|
151
250
|
await daemon.shutdown()
|
|
152
251
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
@@ -1267,10 +1366,9 @@ async function processEvents(logPath) {
|
|
|
1267
1366
|
* @returns {Promise<string>} Written config path.
|
|
1268
1367
|
*/
|
|
1269
1368
|
async function writeConfigFile(config, root) {
|
|
1270
|
-
const configPath = path.join(root, "rollbridge.
|
|
1369
|
+
const configPath = path.join(root, "rollbridge.mjs")
|
|
1271
1370
|
|
|
1272
|
-
|
|
1273
|
-
await fs.writeFile(configPath, `module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
1371
|
+
await fs.writeFile(configPath, `export default ${JSON.stringify(config, null, 2)}\n`)
|
|
1274
1372
|
|
|
1275
1373
|
return configPath
|
|
1276
1374
|
}
|