rollbridge 0.1.11 → 0.1.13
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 +22 -2
- package/docs/cli.md +31 -0
- package/docs/config.md +22 -0
- package/package.json +1 -1
- package/src/cli.js +59 -1
- package/src/config.js +10 -1
- package/src/daemon.js +82 -28
- package/src/release-group.js +38 -2
- package/src/state-store.js +8 -0
- package/test/config-path.test.js +16 -1
- package/test/daemon-bootstrap.test.js +363 -0
- package/test/fixtures/dummy-app.js +24 -1
- package/test/rollbridge.test.js +92 -3
- package/tmp/worker-control/rollbridge-bootstrap/activity-3.jsonl +28 -0
- package/tmp/worker-control/rollbridge-bootstrap/current-head-review-activity.jsonl +13 -0
- package/tmp/worker-control/rollbridge-bootstrap/current-head-review-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/fs-probe-10.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/plan.md +9 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-activity.jsonl +3 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-activity.jsonl +25 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-fresh-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/repair-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/review-activity.jsonl +14 -0
- package/tmp/worker-control/rollbridge-bootstrap/review-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-activity.jsonl +3 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-activity.jsonl +38 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-fresh-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/terminal-repair-transcript.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/transcript-2.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/transcript-3.jsonl +1 -0
- package/tmp/worker-control/rollbridge-bootstrap/transcript.jsonl +1 -0
package/README.md
CHANGED
|
@@ -399,6 +399,22 @@ Start the daemon:
|
|
|
399
399
|
rollbridge daemon --config rollbridge.js
|
|
400
400
|
```
|
|
401
401
|
|
|
402
|
+
Start the daemon and bootstrap an exact prepared release before leaving it in
|
|
403
|
+
the foreground (for example from a boot-time service manager):
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
rollbridge daemon --config /srv/ticket-server/rollbridge.js \
|
|
407
|
+
--release-path /srv/ticket-server/releases/20260813090000/ticket-server \
|
|
408
|
+
--release-id 20260813090000 --revision abc123
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The four bootstrap inputs are all-or-nothing and use absolute config/release
|
|
412
|
+
paths. Rollbridge binds its proxy and control listeners, activates the release
|
|
413
|
+
through the normal deploy path, and stays foreground. A failed activation stops
|
|
414
|
+
only processes started by that attempt and exits non-zero; persisted processes
|
|
415
|
+
from a previous daemon are reported as orphans and are never recovered or killed
|
|
416
|
+
implicitly; their live PID records remain in `statePath` for explicit recovery.
|
|
417
|
+
|
|
402
418
|
Start the daemon only when it is not already running:
|
|
403
419
|
|
|
404
420
|
```bash
|
|
@@ -543,8 +559,12 @@ so its output goes to the journal (`journalctl -u rollbridge`). Key directives:
|
|
|
543
559
|
The daemon is long-lived and survives deploys. **Deploy with
|
|
544
560
|
`rollbridge deploy` (or `rollbridge deploy --ensure-daemon`), not
|
|
545
561
|
`systemctl restart`** — pointing `--config` at a stable, daemon-wide file while
|
|
546
|
-
release paths are passed per deploy.
|
|
547
|
-
|
|
562
|
+
release paths are passed per deploy. The daemon reloads compatible process and
|
|
563
|
+
lifecycle config before each deploy, so updated graceful-stop deadlines govern
|
|
564
|
+
the release being retired without interrupting the stable proxy. Listener and
|
|
565
|
+
process-topology changes still require a daemon restart; see
|
|
566
|
+
[`docs/config.md`](docs/config.md#config-reloads). Use `command -v rollbridge`
|
|
567
|
+
to find the absolute CLI path for `ExecStart`.
|
|
548
568
|
|
|
549
569
|
See [`docs/logging.md`](docs/logging.md) for where the daemon's JSON logs go
|
|
550
570
|
(stdout / journald / the `--daemon-log-path` file) and how to rotate them — the
|
package/docs/cli.md
CHANGED
|
@@ -24,6 +24,7 @@ process-policy details.
|
|
|
24
24
|
|
|
25
25
|
```
|
|
26
26
|
rollbridge daemon [--config <path>]
|
|
27
|
+
[--release-path <path> --release-id <id> --revision <sha>]
|
|
27
28
|
```
|
|
28
29
|
|
|
29
30
|
Runs the supervisor in the foreground: binds the stable proxy port and the
|
|
@@ -32,6 +33,28 @@ processes, closes the servers, removes the control socket, and exits `0`.
|
|
|
32
33
|
Structured JSON log lines are written to stdout. Run it under a process manager
|
|
33
34
|
such as systemd (see `examples/rollbridge.service`).
|
|
34
35
|
|
|
36
|
+
For boot/crash recovery, pass an explicit absolute `--config` and all three
|
|
37
|
+
release options together. Rollbridge validates every bootstrap input before it
|
|
38
|
+
binds listeners or starts processes, then binds the listeners and activates that
|
|
39
|
+
exact release through the same deploy path used by the control socket: services,
|
|
40
|
+
companions, the proxied process and health check, traffic switching, singletons,
|
|
41
|
+
and service-template refresh. It then remains in the foreground with the normal
|
|
42
|
+
signal behavior.
|
|
43
|
+
|
|
44
|
+
Bootstrap paths must be absolute and normalized, the release path must be an
|
|
45
|
+
accessible directory, and release id/revision values accept letters, numbers,
|
|
46
|
+
dots, underscores, and hyphens (maximum 200 characters, beginning with a letter
|
|
47
|
+
or number). Supplying only some bootstrap options, or an invalid value, exits
|
|
48
|
+
non-zero before listeners start. Activation failure emits a structured
|
|
49
|
+
`bootstrap activation failed` event, cleans up processes owned by that attempt,
|
|
50
|
+
and exits non-zero without inventing an active release. `statePath` entries from
|
|
51
|
+
a previous daemon remain advisory orphans: bootstrap never runs recovery and
|
|
52
|
+
never signals those processes, and retains their live PID records in `statePath`
|
|
53
|
+
for explicit recovery.
|
|
54
|
+
|
|
55
|
+
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
56
|
+
and waits for control-socket deployments.
|
|
57
|
+
|
|
35
58
|
## `ensure-daemon`
|
|
36
59
|
|
|
37
60
|
```
|
|
@@ -72,6 +95,14 @@ traffic to it, then drains and stops the previous release. Prints
|
|
|
72
95
|
If the new release fails to start or health-check, the previous release stays
|
|
73
96
|
active and the command errors.
|
|
74
97
|
|
|
98
|
+
Before each deploy, the daemon reloads the config path it was started with.
|
|
99
|
+
Compatible process and lifecycle changes apply to the new release and govern
|
|
100
|
+
how the previous release retires, including updated `nonBlockingDrain`,
|
|
101
|
+
`stopSignal`, `lifecycle`, and `gracefulStopMs` settings. The daemon adopts the
|
|
102
|
+
new config only after the replacement release starts successfully. Changes to
|
|
103
|
+
daemon-owned listeners or process topology fail the deploy with a restart
|
|
104
|
+
instruction; see [Config reloads](config.md#config-reloads).
|
|
105
|
+
|
|
75
106
|
- `--release-path <path>` (**required**) — path to the prepared release
|
|
76
107
|
directory; available to process templates as `{{releasePath}}`.
|
|
77
108
|
- `--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
package/src/cli.js
CHANGED
|
@@ -30,10 +30,14 @@ export async function runCli(argv) {
|
|
|
30
30
|
program
|
|
31
31
|
.command("daemon")
|
|
32
32
|
.option("-c, --config <path>", "Config file path (defaults to rollbridge.js)")
|
|
33
|
+
.option("--release-path <path>", "Bootstrap release path (requires --config, --release-id, and --revision)")
|
|
34
|
+
.option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
|
|
35
|
+
.option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
|
|
33
36
|
.action(async (options) => {
|
|
37
|
+
const bootstrap = await validateDaemonBootstrapOptions(options)
|
|
34
38
|
const configPath = await resolveConfigPath(options.config)
|
|
35
39
|
const config = await loadConfig(configPath)
|
|
36
|
-
const daemon = new RollbridgeDaemon({config})
|
|
40
|
+
const daemon = new RollbridgeDaemon({config, configPath})
|
|
37
41
|
|
|
38
42
|
await daemon.start()
|
|
39
43
|
|
|
@@ -44,6 +48,17 @@ export async function runCli(argv) {
|
|
|
44
48
|
|
|
45
49
|
process.once("SIGINT", () => { void shutdown() })
|
|
46
50
|
process.once("SIGTERM", () => { void shutdown() })
|
|
51
|
+
|
|
52
|
+
if (bootstrap) {
|
|
53
|
+
try {
|
|
54
|
+
await daemon.deploy(bootstrap)
|
|
55
|
+
} catch {
|
|
56
|
+
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
|
|
57
|
+
await daemon.shutdown()
|
|
58
|
+
process.exitCode = 1
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
}
|
|
47
62
|
})
|
|
48
63
|
|
|
49
64
|
program
|
|
@@ -696,6 +711,49 @@ async function validateConfigFile(configPath) {
|
|
|
696
711
|
}
|
|
697
712
|
}
|
|
698
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Validates the daemon's optional all-or-nothing bootstrap release interface before
|
|
716
|
+
* config loading or listener startup.
|
|
717
|
+
* @param {{config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
|
|
718
|
+
* @returns {Promise<{releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
|
|
719
|
+
*/
|
|
720
|
+
async function validateDaemonBootstrapOptions(options) {
|
|
721
|
+
const bootstrapValues = [options.releasePath, options.releaseId, options.revision]
|
|
722
|
+
const bootstrapRequested = bootstrapValues.some((value) => value !== undefined)
|
|
723
|
+
|
|
724
|
+
if (!bootstrapRequested) return undefined
|
|
725
|
+
|
|
726
|
+
if (!options.config || bootstrapValues.some((value) => value === undefined)) {
|
|
727
|
+
throw new Error("Daemon bootstrap options --config, --release-path, --release-id, and --revision must be provided together.")
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (!path.isAbsolute(options.config)) throw new Error("Daemon bootstrap --config must be an absolute path.")
|
|
731
|
+
if (path.normalize(options.config) !== options.config) throw new Error("Daemon bootstrap --config must be normalized and must not contain unsafe traversal segments.")
|
|
732
|
+
if (!path.isAbsolute(/** @type {string} */ (options.releasePath))) throw new Error("Daemon bootstrap --release-path must be an absolute path.")
|
|
733
|
+
if (path.normalize(/** @type {string} */ (options.releasePath)) !== options.releasePath) throw new Error("Daemon bootstrap --release-path must be normalized and must not contain unsafe traversal segments.")
|
|
734
|
+
|
|
735
|
+
const releaseId = /** @type {string} */ (options.releaseId)
|
|
736
|
+
const revision = /** @type {string} */ (options.revision)
|
|
737
|
+
const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/
|
|
738
|
+
|
|
739
|
+
if (!safeIdentifier.test(releaseId)) throw new Error("Daemon bootstrap --release-id must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
|
|
740
|
+
if (!safeIdentifier.test(revision)) throw new Error("Daemon bootstrap --revision must be a non-empty safe identifier containing only letters, numbers, dots, underscores, and hyphens.")
|
|
741
|
+
|
|
742
|
+
let releaseStat
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
releaseStat = await fsPromises.stat(/** @type {string} */ (options.releasePath))
|
|
746
|
+
} catch (error) {
|
|
747
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
748
|
+
|
|
749
|
+
throw new Error(`Daemon bootstrap --release-path is not accessible: ${reason}`, {cause: error})
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (!releaseStat.isDirectory()) throw new Error("Daemon bootstrap --release-path must name a directory.")
|
|
753
|
+
|
|
754
|
+
return {releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
|
|
755
|
+
}
|
|
756
|
+
|
|
699
757
|
/**
|
|
700
758
|
* Starts a daemon when needed and waits until it accepts status commands.
|
|
701
759
|
* @param {object} args - Options.
|
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})))
|
|
@@ -52,6 +56,7 @@ export default class RollbridgeDaemon {
|
|
|
52
56
|
this.statePath = config.statePath
|
|
53
57
|
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
54
58
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
59
|
+
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
55
60
|
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
56
61
|
// startup and surfaced in status(). The daemon cannot re-manage them, only report them.
|
|
57
62
|
this.orphans = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
|
|
@@ -319,30 +324,42 @@ export default class RollbridgeDaemon {
|
|
|
319
324
|
async deploy({releaseId, releasePath, revision}) {
|
|
320
325
|
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
321
326
|
|
|
327
|
+
const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
|
|
328
|
+
|
|
329
|
+
this.assertReloadCompatible(nextConfig)
|
|
330
|
+
|
|
322
331
|
const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
|
|
323
332
|
const release = new ReleaseGroup({
|
|
324
|
-
config:
|
|
333
|
+
config: nextConfig,
|
|
325
334
|
logger: this.logger,
|
|
326
335
|
releaseId: newReleaseId,
|
|
327
336
|
releasePath,
|
|
328
337
|
revision,
|
|
329
|
-
servicePorts: this.servicePorts
|
|
338
|
+
servicePorts: this.servicePorts,
|
|
339
|
+
shouldStart: () => !this.stopping
|
|
330
340
|
})
|
|
331
341
|
|
|
332
342
|
this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
|
|
333
343
|
const startedServices = /** @type {string[]} */ ([])
|
|
334
344
|
|
|
345
|
+
this.startingReleases.add(release)
|
|
346
|
+
|
|
335
347
|
try {
|
|
336
348
|
await this.ensureServices(release, startedServices)
|
|
337
349
|
await release.start()
|
|
350
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
338
351
|
} catch (error) {
|
|
339
352
|
this.logger("deploy failed", {error: error instanceof Error ? error.message : String(error), releaseId: newReleaseId})
|
|
353
|
+
await release.stop()
|
|
340
354
|
await this.stopStartedServices(startedServices)
|
|
341
355
|
throw error
|
|
356
|
+
} finally {
|
|
357
|
+
this.startingReleases.delete(release)
|
|
342
358
|
}
|
|
343
359
|
|
|
344
360
|
const previousRelease = this.activeRelease
|
|
345
361
|
|
|
362
|
+
this.config = nextConfig
|
|
346
363
|
this.releases.set(release.releaseId, release)
|
|
347
364
|
release.activate()
|
|
348
365
|
this.activeRelease = release
|
|
@@ -352,7 +369,7 @@ export default class RollbridgeDaemon {
|
|
|
352
369
|
await this.replaceSingletons(release)
|
|
353
370
|
|
|
354
371
|
if (previousRelease) {
|
|
355
|
-
void this.drainAndPrune(previousRelease)
|
|
372
|
+
void this.drainAndPrune(previousRelease, nextConfig)
|
|
356
373
|
}
|
|
357
374
|
|
|
358
375
|
this.persistState()
|
|
@@ -363,6 +380,45 @@ export default class RollbridgeDaemon {
|
|
|
363
380
|
}
|
|
364
381
|
}
|
|
365
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Rejects config changes that require rebinding daemon-owned resources or changing process topology.
|
|
385
|
+
* @param {import("./config.js").RollbridgeConfig} nextConfig - Freshly loaded config.
|
|
386
|
+
* @returns {void}
|
|
387
|
+
*/
|
|
388
|
+
assertReloadCompatible(nextConfig) {
|
|
389
|
+
/** @type {string[]} */
|
|
390
|
+
const restartRequired = []
|
|
391
|
+
|
|
392
|
+
if (nextConfig.application !== this.config.application) restartRequired.push("application")
|
|
393
|
+
if (!isDeepStrictEqual(nextConfig.control, this.config.control)) restartRequired.push("control")
|
|
394
|
+
if (nextConfig.statePath !== this.config.statePath) restartRequired.push("statePath")
|
|
395
|
+
|
|
396
|
+
if (nextConfig.proxy.host !== this.config.proxy.host) restartRequired.push("proxy.host")
|
|
397
|
+
if (nextConfig.proxy.port !== this.config.proxy.port) restartRequired.push("proxy.port")
|
|
398
|
+
if (nextConfig.proxy.upstreamHost !== this.config.proxy.upstreamHost) restartRequired.push("proxy.upstreamHost")
|
|
399
|
+
|
|
400
|
+
if (nextConfig.processes.length !== this.config.processes.length) {
|
|
401
|
+
restartRequired.push("processes")
|
|
402
|
+
} else {
|
|
403
|
+
for (const processConfig of this.config.processes) {
|
|
404
|
+
const nextProcessConfig = nextConfig.processes.find((candidate) => candidate.id === processConfig.id)
|
|
405
|
+
|
|
406
|
+
if (!nextProcessConfig ||
|
|
407
|
+
nextProcessConfig.policy !== processConfig.policy ||
|
|
408
|
+
nextProcessConfig.deployStrategy !== processConfig.deployStrategy ||
|
|
409
|
+
nextProcessConfig.replicas !== processConfig.replicas ||
|
|
410
|
+
!isDeepStrictEqual(nextProcessConfig.port, processConfig.port)) {
|
|
411
|
+
restartRequired.push("processes")
|
|
412
|
+
break
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (restartRequired.length > 0) {
|
|
418
|
+
throw new Error(`Config changes to ${restartRequired.join(", ")} cannot be applied live; restart the Rollbridge daemon before deploying.`)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
366
422
|
/**
|
|
367
423
|
* Rolls back to a previously-active release by re-running the deploy flow on its
|
|
368
424
|
* retained metadata: it re-starts the target release, health-checks it, switches
|
|
@@ -418,7 +474,8 @@ export default class RollbridgeDaemon {
|
|
|
418
474
|
async ensureServices(release, startedServices) {
|
|
419
475
|
await release.allocatePorts()
|
|
420
476
|
|
|
421
|
-
for (const processConfig of
|
|
477
|
+
for (const processConfig of release.config.processes) {
|
|
478
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
422
479
|
if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
|
|
423
480
|
if (this.services.has(processConfig.id)) continue
|
|
424
481
|
|
|
@@ -438,6 +495,8 @@ export default class RollbridgeDaemon {
|
|
|
438
495
|
delete this.servicePorts[processConfig.id]
|
|
439
496
|
throw error
|
|
440
497
|
}
|
|
498
|
+
|
|
499
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
441
500
|
}
|
|
442
501
|
}
|
|
443
502
|
|
|
@@ -473,20 +532,7 @@ export default class RollbridgeDaemon {
|
|
|
473
532
|
|
|
474
533
|
const nextDefinition = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
|
|
475
534
|
|
|
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
|
-
})
|
|
535
|
+
service.updateDefinition(nextDefinition)
|
|
490
536
|
}
|
|
491
537
|
}
|
|
492
538
|
|
|
@@ -497,12 +543,14 @@ export default class RollbridgeDaemon {
|
|
|
497
543
|
*/
|
|
498
544
|
async replaceSingletons(release) {
|
|
499
545
|
for (const processConfig of this.config.processes) {
|
|
546
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
500
547
|
if (processConfig.policy !== "singleton") continue
|
|
501
548
|
|
|
502
549
|
const previous = this.singletons.get(processConfig.id)
|
|
503
550
|
|
|
504
551
|
if (previous) {
|
|
505
552
|
await previous.stop()
|
|
553
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
506
554
|
}
|
|
507
555
|
|
|
508
556
|
const singleton = release.buildProcess(processConfig)
|
|
@@ -611,11 +659,12 @@ export default class RollbridgeDaemon {
|
|
|
611
659
|
/**
|
|
612
660
|
* Drains and stops a retired release in the background, then prunes stopped releases.
|
|
613
661
|
* @param {ReleaseGroup} release - Release to drain and stop.
|
|
662
|
+
* @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
|
|
614
663
|
* @returns {Promise<void>} Resolves once drained, stopped, and pruned.
|
|
615
664
|
*/
|
|
616
|
-
async drainAndPrune(release) {
|
|
665
|
+
async drainAndPrune(release, config = this.config) {
|
|
617
666
|
try {
|
|
618
|
-
await release.drainAndStop(
|
|
667
|
+
await release.drainAndStop(config.proxy.drainTimeoutMs, config)
|
|
619
668
|
this.logger("release drained", {releaseId: release.releaseId})
|
|
620
669
|
} catch (error) {
|
|
621
670
|
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
|
|
@@ -652,9 +701,7 @@ export default class RollbridgeDaemon {
|
|
|
652
701
|
if (!this.statePath || this.stopping) return
|
|
653
702
|
|
|
654
703
|
const statePath = this.statePath
|
|
655
|
-
|
|
656
|
-
// this daemon's own managed state, and is recomputed from the persisted processes on restart.
|
|
657
|
-
const {orphans: _orphans, ...status} = this.status()
|
|
704
|
+
const status = this.status()
|
|
658
705
|
const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
|
|
659
706
|
|
|
660
707
|
// Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
|
|
@@ -706,17 +753,24 @@ export default class RollbridgeDaemon {
|
|
|
706
753
|
this.proxy.close()
|
|
707
754
|
await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
|
|
708
755
|
await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
|
|
756
|
+
await Promise.allSettled([...this.startingReleases].map((release) => release.stop()))
|
|
709
757
|
await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
|
|
710
758
|
await this.closeServer(this.proxyServer)
|
|
711
759
|
await this.closeServer(this.controlServer)
|
|
712
760
|
await fs.rm(this.config.control.path, {force: true})
|
|
713
761
|
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
762
|
+
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
|
763
|
+
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
764
|
+
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
717
765
|
if (this.statePath) {
|
|
718
766
|
if (this.pendingWrite) await this.pendingWrite
|
|
719
|
-
|
|
767
|
+
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
768
|
+
|
|
769
|
+
if (orphans.length > 0) {
|
|
770
|
+
await writeState(this.statePath, {activeReleaseId: null, orphans, releases: [], services: [], singletons: []})
|
|
771
|
+
} else {
|
|
772
|
+
await clearState(this.statePath)
|
|
773
|
+
}
|
|
720
774
|
}
|
|
721
775
|
}
|
|
722
776
|
|
package/src/release-group.js
CHANGED
|
@@ -40,8 +40,9 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
40
40
|
* @param {string} args.releasePath - Release path.
|
|
41
41
|
* @param {string | undefined} args.revision - Revision.
|
|
42
42
|
* @param {Record<string, number>} [args.servicePorts] - Ports already owned by daemon-wide services.
|
|
43
|
+
* @param {() => boolean} [args.shouldStart] - Whether bootstrap may create another process.
|
|
43
44
|
*/
|
|
44
|
-
constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}}) {
|
|
45
|
+
constructor({config, logger, releaseId, releasePath, revision, servicePorts = {}, shouldStart = () => true}) {
|
|
45
46
|
super()
|
|
46
47
|
|
|
47
48
|
this.config = config
|
|
@@ -57,6 +58,7 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
57
58
|
this.nonBlockingDrainIds = /** @type {Set<string>} */ (new Set())
|
|
58
59
|
this.ports = /** @type {Record<string, number>} */ ({})
|
|
59
60
|
this.servicePorts = servicePorts
|
|
61
|
+
this.shouldStart = shouldStart
|
|
60
62
|
this.portsAllocated = false
|
|
61
63
|
this.drainStartedAt = /** @type {string | undefined} */ (undefined)
|
|
62
64
|
this.activatedAt = /** @type {string | undefined} */ (undefined)
|
|
@@ -72,6 +74,8 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
72
74
|
|
|
73
75
|
for (const processConfig of this.releaseProcessStartOrder()) {
|
|
74
76
|
for (let index = 0; index < processConfig.replicas; index += 1) {
|
|
77
|
+
if (!this.shouldStart()) throw new Error("Rollbridge is shutting down")
|
|
78
|
+
|
|
75
79
|
const instanceId = replicaInstanceId(processConfig, index)
|
|
76
80
|
const processInstance = this.buildProcess(processConfig, {count: processConfig.replicas, index, instanceId})
|
|
77
81
|
|
|
@@ -87,6 +91,8 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
87
91
|
host: this.config.proxy.upstreamHost,
|
|
88
92
|
port: this.ports[processConfig.id]
|
|
89
93
|
})
|
|
94
|
+
|
|
95
|
+
if (!this.shouldStart()) throw new Error("Rollbridge is shutting down")
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
} catch (error) {
|
|
@@ -232,6 +238,34 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
232
238
|
})
|
|
233
239
|
}
|
|
234
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Applies refreshed process definitions before retiring this release.
|
|
243
|
+
* @param {import("./config.js").RollbridgeConfig} config - Current deployment config.
|
|
244
|
+
* @returns {void}
|
|
245
|
+
*/
|
|
246
|
+
refreshProcessDefinitions(config) {
|
|
247
|
+
this.config = config
|
|
248
|
+
this.handoffServiceIds.clear()
|
|
249
|
+
this.nonBlockingDrainIds.clear()
|
|
250
|
+
|
|
251
|
+
for (const processConfig of config.processes) {
|
|
252
|
+
const instances = this.getProcesses(processConfig.id)
|
|
253
|
+
|
|
254
|
+
for (let index = 0; index < instances.length; index += 1) {
|
|
255
|
+
const instance = instances[index]
|
|
256
|
+
const nextDefinition = this.buildProcess(processConfig, {
|
|
257
|
+
count: processConfig.replicas,
|
|
258
|
+
index,
|
|
259
|
+
instanceId: instance.id
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
instance.process.updateDefinition(nextDefinition)
|
|
263
|
+
if (processConfig.policy === "service" && processConfig.deployStrategy === "handoff") this.handoffServiceIds.add(instance.id)
|
|
264
|
+
if (processConfig.nonBlockingDrain) this.nonBlockingDrainIds.add(instance.id)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
235
269
|
/**
|
|
236
270
|
* @param {import("./config.js").ProcessConfig} processConfig - Process config.
|
|
237
271
|
* @param {{count: number, index: number}} replica - Replica index and total count.
|
|
@@ -325,13 +359,15 @@ export default class ReleaseGroup extends EventEmitter {
|
|
|
325
359
|
/**
|
|
326
360
|
* Starts draining and stops once existing connections close or timeout.
|
|
327
361
|
* @param {number} timeoutMs - Drain timeout.
|
|
362
|
+
* @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
|
|
328
363
|
* @returns {Promise<void>} Resolves when stopped.
|
|
329
364
|
*/
|
|
330
|
-
async drainAndStop(timeoutMs) {
|
|
365
|
+
async drainAndStop(timeoutMs, config = this.config) {
|
|
331
366
|
if (this.state === "stopped") return
|
|
332
367
|
|
|
333
368
|
this.state = "draining"
|
|
334
369
|
this.drainStartedAt = new Date().toISOString()
|
|
370
|
+
this.refreshProcessDefinitions(config)
|
|
335
371
|
|
|
336
372
|
// Stop nonBlockingDrain processes (e.g. job workers) immediately and in the background, so
|
|
337
373
|
// their lifecycle drain runs as soon as the release is retired — in parallel with the
|
package/src/state-store.js
CHANGED
|
@@ -86,6 +86,14 @@ export function liveProcesses(state, alive = isProcessAlive) {
|
|
|
86
86
|
try {
|
|
87
87
|
const snapshot = /** @type {import("./daemon.js").DaemonStatus} */ (state)
|
|
88
88
|
|
|
89
|
+
if (Array.isArray(snapshot.orphans)) {
|
|
90
|
+
for (const orphan of snapshot.orphans) {
|
|
91
|
+
if (typeof orphan.id === "string" && typeof orphan.pid === "number" && (typeof orphan.releaseId === "string" || orphan.releaseId === null) && alive(orphan.pid)) {
|
|
92
|
+
live.push({id: orphan.id, pid: orphan.pid, releaseId: orphan.releaseId})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
for (const release of snapshot.releases) {
|
|
90
98
|
for (const process of release.processes) {
|
|
91
99
|
if (typeof process.pid === "number" && alive(process.pid)) live.push({id: process.id, pid: process.pid, releaseId: release.releaseId})
|
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()
|