rollbridge 0.1.17 → 0.1.19
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 +16 -1
- package/docs/cli.md +36 -2
- package/package.json +1 -1
- package/src/cli.js +10 -4
- package/src/daemon.js +120 -24
- package/test/daemon-bootstrap.test.js +72 -5
- package/test/rollbridge.test.js +1 -0
- package/test/shutdown-completion.test.js +290 -0
package/README.md
CHANGED
|
@@ -408,7 +408,8 @@ the foreground (for example from a boot-time service manager):
|
|
|
408
408
|
```bash
|
|
409
409
|
rollbridge daemon --config /srv/ticket-server/rollbridge.js \
|
|
410
410
|
--release-path /srv/ticket-server/releases/20260813090000/ticket-server \
|
|
411
|
-
--release-id 20260813090000 --revision abc123
|
|
411
|
+
--release-id 20260813090000 --revision abc123 \
|
|
412
|
+
--boot-attestation sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
412
413
|
```
|
|
413
414
|
|
|
414
415
|
The four bootstrap inputs are all-or-nothing and use absolute config/release
|
|
@@ -419,6 +420,14 @@ persisted processes from a previous daemon are reported as orphans and are never
|
|
|
419
420
|
recovered or killed implicitly; their live PID records remain in `statePath` for
|
|
420
421
|
explicit recovery.
|
|
421
422
|
|
|
423
|
+
External supervisors may add `--boot-attestation` with exactly `sha256:` plus
|
|
424
|
+
64 lowercase hexadecimal characters. After successful activation, `rollbridge
|
|
425
|
+
status` echoes the opaque, non-secret value under `bootstrap.attestation`
|
|
426
|
+
alongside the exact release id/path/revision. Listener-only and detached ensured
|
|
427
|
+
daemons omit `bootstrap`, so a supervisor can distinguish a newly accepted
|
|
428
|
+
foreground owner from a stale daemon without Rollbridge interpreting the token.
|
|
429
|
+
See [`docs/cli.md`](docs/cli.md#daemon) for the complete contract.
|
|
430
|
+
|
|
422
431
|
Start the daemon only when it is not already running:
|
|
423
432
|
|
|
424
433
|
```bash
|
|
@@ -500,6 +509,12 @@ Shut down the daemon and managed processes:
|
|
|
500
509
|
rollbridge shutdown --config rollbridge.js
|
|
501
510
|
```
|
|
502
511
|
|
|
512
|
+
A successful shutdown response is emitted only after the targeted control
|
|
513
|
+
endpoint has stopped accepting connections and been removed, owned processes
|
|
514
|
+
and the proxy have stopped, and persistent state cleanup has finished. It is
|
|
515
|
+
therefore safe to start or ensure a replacement daemon immediately, without a
|
|
516
|
+
delay or retry loop. Cleanup failure or an already-missing daemon exits non-zero.
|
|
517
|
+
|
|
503
518
|
Prepare a first Rollbridge deploy by recovering Rollbridge-managed orphans and
|
|
504
519
|
stopping configured legacy processes:
|
|
505
520
|
|
package/docs/cli.md
CHANGED
|
@@ -24,7 +24,8 @@ process-policy details.
|
|
|
24
24
|
|
|
25
25
|
```
|
|
26
26
|
rollbridge daemon [--config <path>]
|
|
27
|
-
[--release-path <path> --release-id <id> --revision <sha>
|
|
27
|
+
[--release-path <path> --release-id <id> --revision <sha>
|
|
28
|
+
[--boot-attestation <sha256:digest>]]
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
Runs the supervisor in the foreground: binds the stable proxy port and the
|
|
@@ -53,6 +54,13 @@ release. `statePath` entries from a previous daemon remain advisory orphans:
|
|
|
53
54
|
bootstrap never runs recovery and never signals those processes, and retains
|
|
54
55
|
their live PID records in `statePath` for explicit recovery.
|
|
55
56
|
|
|
57
|
+
`--boot-attestation` is an optional, non-secret opaque ownership token for an
|
|
58
|
+
external supervisor. Its canonical format is exactly `sha256:` followed by 64
|
|
59
|
+
lowercase hexadecimal characters. It is accepted only with the complete
|
|
60
|
+
known-release bootstrap tuple above and is never accepted by `ensure-daemon`.
|
|
61
|
+
After successful activation, `status` echoes it unchanged in the bootstrap
|
|
62
|
+
identity. Rollbridge does not calculate or interpret the digest.
|
|
63
|
+
|
|
56
64
|
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
57
65
|
and waits for control-socket deployments.
|
|
58
66
|
|
|
@@ -174,6 +182,24 @@ Memory-supervised processes also report `rssBytes`, `memoryRestarts`,
|
|
|
174
182
|
its runtime `format`, package `version`, content `digest`, and absolute `path`.
|
|
175
183
|
`ensure-daemon` uses this attestation before reusing a responsive daemon.
|
|
176
184
|
|
|
185
|
+
A foreground known-release daemon also reports the exact CLI bootstrap identity:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"bootstrap": {
|
|
190
|
+
"releaseId": "20260813090000",
|
|
191
|
+
"releasePath": "/srv/app/releases/20260813090000/app",
|
|
192
|
+
"revision": "abc123",
|
|
193
|
+
"attestation": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`attestation` is omitted when the optional argument was not supplied. Ordinary
|
|
199
|
+
listener-only daemons and detached daemons created by `ensure-daemon` omit the
|
|
200
|
+
entire `bootstrap` object. External supervisors can therefore distinguish two
|
|
201
|
+
otherwise identical foreground boots by comparing the opaque attestation.
|
|
202
|
+
|
|
177
203
|
When [`statePath`](config.md#statepath) is configured, status also includes an
|
|
178
204
|
`orphans` array: managed processes from a **previous** daemon that are still
|
|
179
205
|
alive (`id`, `pid`, `releaseId`) — for example after the daemon restarted but its
|
|
@@ -266,7 +292,15 @@ rollbridge shutdown [--config <path>]
|
|
|
266
292
|
|
|
267
293
|
Stops all managed processes (services, singletons, and releases), closes the
|
|
268
294
|
proxy and control socket, removes the socket file, and prints
|
|
269
|
-
`{"status": "success", "message": "shutdown"}`.
|
|
295
|
+
`{"status": "success", "message": "shutdown"}`. The success response is a
|
|
296
|
+
completion signal, not an early acknowledgement: before sending it, Rollbridge
|
|
297
|
+
stops accepting new control connections, removes the targeted socket, finishes
|
|
298
|
+
owned-process and proxy cleanup, and finalizes persistent state. A caller may
|
|
299
|
+
immediately start or ensure a replacement daemon after the command returns.
|
|
300
|
+
|
|
301
|
+
If cleanup fails, the command exits non-zero with the daemon's error instead of
|
|
302
|
+
reporting success. Calling `shutdown` when no daemon owns the configured control
|
|
303
|
+
socket also remains an explicit connection error.
|
|
270
304
|
|
|
271
305
|
## `validate`
|
|
272
306
|
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -35,12 +35,13 @@ export async function runCli(argv) {
|
|
|
35
35
|
.option("--release-path <path>", "Bootstrap release path (requires --config, --release-id, and --revision)")
|
|
36
36
|
.option("--release-id <id>", "Bootstrap release id (requires --config, --release-path, and --revision)")
|
|
37
37
|
.option("--revision <sha>", "Bootstrap revision (requires --config, --release-path, and --release-id)")
|
|
38
|
+
.option("--boot-attestation <digest>", "Opaque bootstrap ownership attestation (requires the complete bootstrap release tuple)")
|
|
38
39
|
.action(async (options) => {
|
|
39
40
|
const bootstrap = await validateDaemonBootstrapOptions(options)
|
|
40
41
|
const configPath = await resolveConfigPath(options.config)
|
|
41
42
|
const config = await loadConfig(configPath)
|
|
42
43
|
const runtime = await loadDaemonRuntimeIdentity(process.env.ROLLBRIDGE_DAEMON_RUNTIME_MANIFEST)
|
|
43
|
-
const daemon = new RollbridgeDaemon({config, configPath, runtime})
|
|
44
|
+
const daemon = new RollbridgeDaemon({bootstrap, config, configPath, runtime})
|
|
44
45
|
|
|
45
46
|
await daemon.start({exposeControl: !bootstrap})
|
|
46
47
|
|
|
@@ -720,13 +721,17 @@ async function validateConfigFile(configPath) {
|
|
|
720
721
|
/**
|
|
721
722
|
* Validates the daemon's optional all-or-nothing bootstrap release interface before
|
|
722
723
|
* config loading or listener startup.
|
|
723
|
-
* @param {{config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
|
|
724
|
-
* @returns {Promise<{releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
|
|
724
|
+
* @param {{bootAttestation?: string, config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
|
|
725
|
+
* @returns {Promise<{attestation?: string, releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
|
|
725
726
|
*/
|
|
726
727
|
async function validateDaemonBootstrapOptions(options) {
|
|
727
728
|
const bootstrapValues = [options.releasePath, options.releaseId, options.revision]
|
|
728
729
|
const bootstrapRequested = bootstrapValues.some((value) => value !== undefined)
|
|
729
730
|
|
|
731
|
+
if (options.bootAttestation !== undefined && !bootstrapRequested) {
|
|
732
|
+
throw new Error("Daemon --boot-attestation is accepted only with --config, --release-path, --release-id, and --revision.")
|
|
733
|
+
}
|
|
734
|
+
|
|
730
735
|
if (!bootstrapRequested) return undefined
|
|
731
736
|
|
|
732
737
|
if (!options.config || bootstrapValues.some((value) => value === undefined)) {
|
|
@@ -744,6 +749,7 @@ async function validateDaemonBootstrapOptions(options) {
|
|
|
744
749
|
|
|
745
750
|
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.")
|
|
746
751
|
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.")
|
|
752
|
+
if (options.bootAttestation !== undefined && !/^sha256:[a-f0-9]{64}$/.test(options.bootAttestation)) throw new Error("Daemon bootstrap --boot-attestation must use the canonical sha256:<64 lowercase hex> format.")
|
|
747
753
|
|
|
748
754
|
let releaseStat
|
|
749
755
|
|
|
@@ -757,7 +763,7 @@ async function validateDaemonBootstrapOptions(options) {
|
|
|
757
763
|
|
|
758
764
|
if (!releaseStat.isDirectory()) throw new Error("Daemon bootstrap --release-path must name a directory.")
|
|
759
765
|
|
|
760
|
-
return {releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
|
|
766
|
+
return {attestation: options.bootAttestation, releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
|
|
761
767
|
}
|
|
762
768
|
|
|
763
769
|
/**
|
package/src/daemon.js
CHANGED
|
@@ -17,19 +17,22 @@ const STATE_PERSIST_INTERVAL_MS = 5000
|
|
|
17
17
|
/**
|
|
18
18
|
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
19
19
|
* @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
|
|
20
|
+
* @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
|
|
20
21
|
* @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
|
|
21
|
-
* @typedef {{activeReleaseId: string | null, application: string, control: import("./config.js").ControlConfig, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
22
|
+
* @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
25
|
export default class RollbridgeDaemon {
|
|
25
26
|
/**
|
|
26
27
|
* @param {object} args - Options.
|
|
28
|
+
* @param {BootstrapIdentity} [args.bootstrap] - Immutable known-release foreground bootstrap identity.
|
|
27
29
|
* @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
|
|
28
30
|
* @param {string} [args.configPath] - Config file path to reload before deploys.
|
|
29
31
|
* @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
|
|
30
32
|
* @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} [args.runtime] - Immutable daemon runtime identity.
|
|
31
33
|
*/
|
|
32
|
-
constructor({config, configPath, logger, runtime}) {
|
|
34
|
+
constructor({bootstrap, config, configPath, logger, runtime}) {
|
|
35
|
+
this.bootstrap = bootstrap ? {...bootstrap} : undefined
|
|
33
36
|
this.config = config
|
|
34
37
|
this.configPath = configPath
|
|
35
38
|
this.runtime = runtime
|
|
@@ -53,11 +56,14 @@ export default class RollbridgeDaemon {
|
|
|
53
56
|
this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
|
|
54
57
|
this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
|
|
55
58
|
this.controlServer = /** @type {net.Server | undefined} */ (undefined)
|
|
59
|
+
this.controlSockets = /** @type {Set<net.Socket>} */ (new Set())
|
|
56
60
|
this.proxyPort = /** @type {number | undefined} */ (undefined)
|
|
57
61
|
this.stopping = false
|
|
58
62
|
this.statePath = config.statePath
|
|
59
63
|
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
60
64
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
65
|
+
this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
66
|
+
this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
61
67
|
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
62
68
|
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
63
69
|
// startup and surfaced in status(). The daemon cannot re-manage them, only report them.
|
|
@@ -244,9 +250,17 @@ export default class RollbridgeDaemon {
|
|
|
244
250
|
* @returns {void}
|
|
245
251
|
*/
|
|
246
252
|
handleControlSocket(socket) {
|
|
253
|
+
this.controlSockets.add(socket)
|
|
247
254
|
socket.setEncoding("utf8")
|
|
248
255
|
let buffer = ""
|
|
249
256
|
|
|
257
|
+
socket.once("close", () => this.controlSockets.delete(socket))
|
|
258
|
+
socket.on("error", (error) => {
|
|
259
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null
|
|
260
|
+
|
|
261
|
+
this.logger("control connection error", {code, error: error.message})
|
|
262
|
+
})
|
|
263
|
+
|
|
250
264
|
socket.on("data", (chunk) => {
|
|
251
265
|
buffer += chunk
|
|
252
266
|
let newlineIndex = buffer.indexOf("\n")
|
|
@@ -266,22 +280,34 @@ export default class RollbridgeDaemon {
|
|
|
266
280
|
* @returns {void}
|
|
267
281
|
*/
|
|
268
282
|
handleControlLine(line, socket) {
|
|
269
|
-
|
|
270
|
-
|
|
283
|
+
const closesConnection = isShutdownControlLine(line)
|
|
284
|
+
const respond = (/** @type {Record<string, JsonValue>} */ response) => {
|
|
285
|
+
const payload = `${JSON.stringify(response)}\n`
|
|
286
|
+
|
|
287
|
+
if (closesConnection) {
|
|
288
|
+
socket.end(payload, () => socket.destroy())
|
|
289
|
+
} else if (!socket.destroyed) {
|
|
290
|
+
socket.write(payload)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.executeControlLine(line, socket)
|
|
295
|
+
.then((response) => respond({status: "success", ...response}))
|
|
271
296
|
.catch((error) => {
|
|
272
297
|
this.logger("command failed", {error: error instanceof Error ? error.message : String(error)})
|
|
273
|
-
|
|
298
|
+
respond({
|
|
274
299
|
error: error instanceof Error ? error.message : String(error),
|
|
275
300
|
status: "error"
|
|
276
|
-
})
|
|
301
|
+
})
|
|
277
302
|
})
|
|
278
303
|
}
|
|
279
304
|
|
|
280
305
|
/**
|
|
281
306
|
* @param {string} line - JSON command line.
|
|
307
|
+
* @param {net.Socket} [controlSocket] - Requesting control connection, used only for shutdown completion.
|
|
282
308
|
* @returns {Promise<Record<string, JsonValue>>} Command response.
|
|
283
309
|
*/
|
|
284
|
-
async executeControlLine(line) {
|
|
310
|
+
async executeControlLine(line, controlSocket) {
|
|
285
311
|
const command = JSON.parse(line)
|
|
286
312
|
|
|
287
313
|
if (!command || typeof command !== "object") {
|
|
@@ -324,11 +350,10 @@ export default class RollbridgeDaemon {
|
|
|
324
350
|
}
|
|
325
351
|
|
|
326
352
|
if (commandName === "shutdown") {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
})
|
|
353
|
+
// Stop accepting new control connections before cleanup, but keep this requesting
|
|
354
|
+
// connection open as the completion channel. Waiting for all control connections here
|
|
355
|
+
// would deadlock: server.close() includes the socket awaiting this response.
|
|
356
|
+
await this.shutdown({completionSocket: controlSocket, waitForControlConnections: false})
|
|
332
357
|
|
|
333
358
|
return {message: "shutdown"}
|
|
334
359
|
}
|
|
@@ -760,30 +785,56 @@ export default class RollbridgeDaemon {
|
|
|
760
785
|
}
|
|
761
786
|
}
|
|
762
787
|
|
|
763
|
-
/**
|
|
764
|
-
|
|
765
|
-
|
|
788
|
+
/**
|
|
789
|
+
* Stops proxy, control socket, and child processes.
|
|
790
|
+
* @param {{completionSocket?: net.Socket, waitForControlConnections?: boolean}} [options] - Shutdown connection behavior.
|
|
791
|
+
* @returns {Promise<void>} Resolves when owned resources are stopped (and, by default, control connections close).
|
|
792
|
+
*/
|
|
793
|
+
async shutdown({completionSocket, waitForControlConnections = true} = {}) {
|
|
794
|
+
if (!this.shutdownPromise) this.shutdownPromise = this.performShutdown(completionSocket)
|
|
795
|
+
|
|
796
|
+
await this.shutdownPromise
|
|
797
|
+
if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
|
|
798
|
+
}
|
|
766
799
|
|
|
800
|
+
/**
|
|
801
|
+
* @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
|
|
802
|
+
* @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
|
|
803
|
+
*/
|
|
804
|
+
async performShutdown(completionSocket) {
|
|
767
805
|
this.stopping = true
|
|
806
|
+
const cleanupErrors = /** @type {Error[]} */ ([])
|
|
807
|
+
|
|
808
|
+
// server.close() stops new connections synchronously. Unlink immediately afterward so a
|
|
809
|
+
// replacement can bind as soon as cleanup completes; existing connections remain usable for
|
|
810
|
+
// the shutdown completion/error response.
|
|
811
|
+
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
812
|
+
|
|
813
|
+
for (const socket of this.controlSockets) {
|
|
814
|
+
if (socket !== completionSocket) socket.destroy()
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
await captureShutdownError(cleanupErrors, "control socket unlink", () => this.removeControlSocket())
|
|
768
818
|
|
|
769
819
|
if (this.persistTimer) {
|
|
770
820
|
clearInterval(this.persistTimer)
|
|
771
821
|
this.persistTimer = undefined
|
|
772
822
|
}
|
|
773
823
|
|
|
774
|
-
this.proxy.close()
|
|
775
|
-
await Promise.allSettled([
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
await
|
|
824
|
+
await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
|
|
825
|
+
const stopResults = await Promise.allSettled([
|
|
826
|
+
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
827
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
828
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
829
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
830
|
+
])
|
|
831
|
+
await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
|
|
782
832
|
|
|
783
833
|
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
|
784
834
|
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
785
835
|
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
786
|
-
|
|
836
|
+
await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
|
|
837
|
+
if (!this.statePath) return
|
|
787
838
|
if (this.pendingWrite) await this.pendingWrite
|
|
788
839
|
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
789
840
|
|
|
@@ -792,7 +843,20 @@ export default class RollbridgeDaemon {
|
|
|
792
843
|
} else {
|
|
793
844
|
await clearState(this.statePath)
|
|
794
845
|
}
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
const stopErrors = stopResults.filter((result) => result.status === "rejected").map((result) => result.reason)
|
|
849
|
+
|
|
850
|
+
if (stopErrors.length > 0) {
|
|
851
|
+
cleanupErrors.push(new AggregateError(stopErrors, `Shutdown failed to stop ${stopErrors.length} owned resource${stopErrors.length === 1 ? "" : "s"}.`))
|
|
795
852
|
}
|
|
853
|
+
|
|
854
|
+
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, cleanupErrors.map((error) => error.message).join("; "))
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** @returns {Promise<void>} Removes the configured control socket path. */
|
|
858
|
+
async removeControlSocket() {
|
|
859
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
796
860
|
}
|
|
797
861
|
|
|
798
862
|
/**
|
|
@@ -820,6 +884,7 @@ export default class RollbridgeDaemon {
|
|
|
820
884
|
return {
|
|
821
885
|
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
822
886
|
application: this.config.application,
|
|
887
|
+
bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
|
|
823
888
|
control: {...this.config.control},
|
|
824
889
|
daemonRuntime: this.runtime ? {...this.runtime} : undefined,
|
|
825
890
|
orphans: [...this.orphans],
|
|
@@ -852,6 +917,37 @@ function stringOrUndefined(value) {
|
|
|
852
917
|
return value
|
|
853
918
|
}
|
|
854
919
|
|
|
920
|
+
/**
|
|
921
|
+
* @param {string} line - Raw control line.
|
|
922
|
+
* @returns {boolean} Whether the line requests shutdown and needs a terminal response connection.
|
|
923
|
+
*/
|
|
924
|
+
function isShutdownControlLine(line) {
|
|
925
|
+
try {
|
|
926
|
+
const command = JSON.parse(line)
|
|
927
|
+
|
|
928
|
+
return Boolean(command && typeof command === "object" && command.command === "shutdown")
|
|
929
|
+
} catch {
|
|
930
|
+
return false
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Runs one shutdown cleanup step and records a labeled failure without skipping later cleanup.
|
|
936
|
+
* @param {Error[]} errors - Accumulated cleanup errors.
|
|
937
|
+
* @param {string} label - Non-secret cleanup step name.
|
|
938
|
+
* @param {() => Promise<void>} operation - Cleanup operation.
|
|
939
|
+
* @returns {Promise<void>} Resolves after the operation succeeds or its failure is recorded.
|
|
940
|
+
*/
|
|
941
|
+
async function captureShutdownError(errors, label, operation) {
|
|
942
|
+
try {
|
|
943
|
+
await operation()
|
|
944
|
+
} catch (error) {
|
|
945
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
946
|
+
|
|
947
|
+
errors.push(new Error(`${label} failed: ${reason}`, {cause: error}))
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
855
951
|
const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
|
|
856
952
|
|
|
857
953
|
/**
|
|
@@ -14,6 +14,8 @@ import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state
|
|
|
14
14
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
15
15
|
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
16
16
|
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
17
|
+
const firstAttestation = `sha256:${"a".repeat(64)}`
|
|
18
|
+
const secondAttestation = `sha256:${"b".repeat(64)}`
|
|
17
19
|
|
|
18
20
|
test("daemon bootstrap requires complete, safe, absolute inputs before binding listeners", async (t) => {
|
|
19
21
|
const cases = [
|
|
@@ -23,7 +25,11 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
|
|
|
23
25
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
|
|
24
26
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
|
|
25
27
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
|
|
26
|
-
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
|
|
28
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/},
|
|
29
|
+
{args: ["--config", "CONFIG", "--boot-attestation", firstAttestation], message: /accepted only with/},
|
|
30
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha256:${"A".repeat(64)}`], message: /--boot-attestation/},
|
|
31
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha512:${"a".repeat(64)}`], message: /--boot-attestation/},
|
|
32
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123", "--boot-attestation", `sha256:${"a".repeat(63)}`], message: /--boot-attestation/}
|
|
27
33
|
]
|
|
28
34
|
|
|
29
35
|
for (const testCase of cases) {
|
|
@@ -43,6 +49,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
|
|
|
43
49
|
assert.notEqual(result.code, 0)
|
|
44
50
|
assert.match(result.stderr, testCase.message)
|
|
45
51
|
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
52
|
+
await assert.rejects(() => fs.stat(fixture.startedPath), {code: "ENOENT"})
|
|
46
53
|
} finally {
|
|
47
54
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
48
55
|
}
|
|
@@ -52,7 +59,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
|
|
|
52
59
|
|
|
53
60
|
test("daemon bootstrap activates the exact release through the foreground daemon", async () => {
|
|
54
61
|
const fixture = await createFixture()
|
|
55
|
-
const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
|
|
62
|
+
const child = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "release-42", revision: "abc123"})
|
|
56
63
|
|
|
57
64
|
try {
|
|
58
65
|
await waitForLog(child, "control socket listening")
|
|
@@ -61,6 +68,12 @@ test("daemon bootstrap activates the exact release through the foreground daemon
|
|
|
61
68
|
|
|
62
69
|
assert.equal(activeRelease.releasePath, fixture.root)
|
|
63
70
|
assert.equal(activeRelease.revision, "abc123")
|
|
71
|
+
assert.deepEqual(status.bootstrap, {
|
|
72
|
+
attestation: firstAttestation,
|
|
73
|
+
releaseId: "release-42",
|
|
74
|
+
releasePath: fixture.root,
|
|
75
|
+
revision: "abc123"
|
|
76
|
+
})
|
|
64
77
|
assert.ok(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")
|
|
65
78
|
assert.equal((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim(), "release-42")
|
|
66
79
|
|
|
@@ -109,6 +122,7 @@ test("plain daemon startup remains listener-only with no active release", async
|
|
|
109
122
|
|
|
110
123
|
assert.equal(status.activeReleaseId, null)
|
|
111
124
|
assert.deepEqual(status.releases, [])
|
|
125
|
+
assert.equal(status.bootstrap, undefined)
|
|
112
126
|
|
|
113
127
|
child.kill("SIGTERM")
|
|
114
128
|
assert.equal((await once(child, "exit"))[0], 0)
|
|
@@ -118,6 +132,47 @@ test("plain daemon startup remains listener-only with no active release", async
|
|
|
118
132
|
}
|
|
119
133
|
})
|
|
120
134
|
|
|
135
|
+
test("ensure-daemon rejects boot attestation instead of inheriting foreground identity", async () => {
|
|
136
|
+
const fixture = await createFixture()
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const result = await runRollbridge(["ensure-daemon", "--config", fixture.configPath, "--boot-attestation", firstAttestation])
|
|
140
|
+
|
|
141
|
+
assert.notEqual(result.code, 0)
|
|
142
|
+
assert.match(result.stderr, /unknown option '--boot-attestation'/)
|
|
143
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
144
|
+
await assert.rejects(() => fs.stat(fixture.startedPath), {code: "ENOENT"})
|
|
145
|
+
} finally {
|
|
146
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("otherwise identical foreground boots remain distinguishable by attestation", async () => {
|
|
151
|
+
const fixture = await createFixture()
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const attestations = []
|
|
155
|
+
|
|
156
|
+
for (const attestation of [firstAttestation, secondAttestation]) {
|
|
157
|
+
const child = spawnDaemon(fixture, {attestation, releaseId: "same-release", revision: "same-revision"})
|
|
158
|
+
|
|
159
|
+
await waitForLog(child, "control socket listening")
|
|
160
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
161
|
+
|
|
162
|
+
assert.equal(status.activeReleaseId, "same-release")
|
|
163
|
+
assert.ok(status.bootstrap && typeof status.bootstrap === "object" && !Array.isArray(status.bootstrap))
|
|
164
|
+
attestations.push(status.bootstrap.attestation)
|
|
165
|
+
|
|
166
|
+
child.kill("SIGTERM")
|
|
167
|
+
assert.equal((await once(child, "exit"))[0], 0)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
assert.deepEqual(attestations, [firstAttestation, secondAttestation])
|
|
171
|
+
} finally {
|
|
172
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
121
176
|
test("SIGTERM during bootstrap activation follows the daemon shutdown path", async () => {
|
|
122
177
|
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 60000})
|
|
123
178
|
const started = waitForFile(fixture.startedPath)
|
|
@@ -352,11 +407,15 @@ async function waitForFile(filePath) {
|
|
|
352
407
|
|
|
353
408
|
/**
|
|
354
409
|
* @param {{configPath: string, root: string}} fixture - Fixture paths.
|
|
355
|
-
* @param {{releaseId: string, revision: string}} release - Bootstrap metadata.
|
|
410
|
+
* @param {{attestation?: string, releaseId: string, revision: string}} release - Bootstrap metadata.
|
|
356
411
|
* @returns {import("node:child_process").ChildProcessWithoutNullStreams} Spawned daemon.
|
|
357
412
|
*/
|
|
358
413
|
function spawnDaemon(fixture, release) {
|
|
359
|
-
|
|
414
|
+
const args = [binPath, "daemon", "--config", fixture.configPath, "--release-path", fixture.root, "--release-id", release.releaseId, "--revision", release.revision]
|
|
415
|
+
|
|
416
|
+
if (release.attestation) args.push("--boot-attestation", release.attestation)
|
|
417
|
+
|
|
418
|
+
return spawn(process.execPath, args, {stdio: ["pipe", "pipe", "pipe"]})
|
|
360
419
|
}
|
|
361
420
|
|
|
362
421
|
/**
|
|
@@ -364,7 +423,15 @@ function spawnDaemon(fixture, release) {
|
|
|
364
423
|
* @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
|
|
365
424
|
*/
|
|
366
425
|
async function runDaemon(args) {
|
|
367
|
-
|
|
426
|
+
return await runRollbridge(["daemon", ...args])
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {string[]} args - Rollbridge command and arguments.
|
|
431
|
+
* @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
|
|
432
|
+
*/
|
|
433
|
+
async function runRollbridge(args) {
|
|
434
|
+
const child = spawn(process.execPath, [binPath, ...args], {stdio: ["ignore", "pipe", "pipe"]})
|
|
368
435
|
let output = ""
|
|
369
436
|
let stderr = ""
|
|
370
437
|
|
package/test/rollbridge.test.js
CHANGED
|
@@ -1161,6 +1161,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1161
1161
|
const proxy = /** @type {{port: number}} */ (status.proxy)
|
|
1162
1162
|
|
|
1163
1163
|
assert.equal(status.activeReleaseId, "ensured-v1")
|
|
1164
|
+
assert.equal(status.bootstrap, undefined)
|
|
1164
1165
|
assert.match(await fs.readFile(pidPath, "utf8"), /\d+/)
|
|
1165
1166
|
assert.equal(await fetchTextFromPort(proxy.port, "/release"), "ensured-v1")
|
|
1166
1167
|
} finally {
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict"
|
|
4
|
+
import {spawn} from "node:child_process"
|
|
5
|
+
import {once} from "node:events"
|
|
6
|
+
import fs from "node:fs/promises"
|
|
7
|
+
import net from "node:net"
|
|
8
|
+
import os from "node:os"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import test from "node:test"
|
|
11
|
+
import {fileURLToPath} from "node:url"
|
|
12
|
+
import {normalizeConfig} from "../src/config.js"
|
|
13
|
+
import {sendControlCommand} from "../src/control-client.js"
|
|
14
|
+
import RollbridgeDaemon from "../src/daemon.js"
|
|
15
|
+
import {isProcessAlive} from "../src/state-store.js"
|
|
16
|
+
|
|
17
|
+
const dummyAppPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "dummy-app.js")
|
|
18
|
+
|
|
19
|
+
test("shutdown response waits for endpoint and owned-process cleanup before immediate replacement", async () => {
|
|
20
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-completion-"))
|
|
21
|
+
const socketPath = path.join(root, "control.sock")
|
|
22
|
+
const unrelatedSocketPath = path.join(root, "unrelated.sock")
|
|
23
|
+
const gatePath = path.join(root, "shutdown.fifo")
|
|
24
|
+
const stoppingPath = path.join(root, "stopping")
|
|
25
|
+
const gate = spawn("mkfifo", [gatePath])
|
|
26
|
+
|
|
27
|
+
assert.equal((await once(gate, "exit"))[0], 0)
|
|
28
|
+
|
|
29
|
+
const config = buildConfig(socketPath, {
|
|
30
|
+
companion: {
|
|
31
|
+
command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify("setInterval(() => {}, 1000)")}`,
|
|
32
|
+
id: "worker",
|
|
33
|
+
lifecycle: {drainTimeoutMs: 0, quietCommand: `printf stopping > ${JSON.stringify(stoppingPath)}; read released < ${JSON.stringify(gatePath)}`},
|
|
34
|
+
policy: "companion"
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
const unrelatedConfig = buildConfig(unrelatedSocketPath)
|
|
38
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
39
|
+
const unrelated = new RollbridgeDaemon({config: unrelatedConfig, logger: () => {}})
|
|
40
|
+
let idleTarget = /** @type {net.Socket | undefined} */ (undefined)
|
|
41
|
+
let idleUnrelated = /** @type {net.Socket | undefined} */ (undefined)
|
|
42
|
+
let replacement
|
|
43
|
+
let gateReleased = false
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await daemon.start()
|
|
47
|
+
await unrelated.start()
|
|
48
|
+
idleTarget = net.createConnection(socketPath)
|
|
49
|
+
idleUnrelated = net.createConnection(unrelatedSocketPath)
|
|
50
|
+
await Promise.all([once(idleTarget, "connect"), once(idleUnrelated, "connect")])
|
|
51
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
52
|
+
|
|
53
|
+
const workerPid = daemon.activeRelease?.getProcess("worker")?.pid
|
|
54
|
+
|
|
55
|
+
assert.equal(typeof workerPid, "number")
|
|
56
|
+
|
|
57
|
+
const stopping = waitForFile(stoppingPath)
|
|
58
|
+
let shutdownResolved = false
|
|
59
|
+
const shutdown = sendControlCommand({command: {command: "shutdown"}, path: socketPath})
|
|
60
|
+
.then((response) => {
|
|
61
|
+
shutdownResolved = true
|
|
62
|
+
return response
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
await stopping
|
|
66
|
+
|
|
67
|
+
let oldEndpointAccepted = true
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await sendControlCommand({command: {command: "status"}, path: socketPath})
|
|
71
|
+
} catch {
|
|
72
|
+
oldEndpointAccepted = false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const resolvedDuringStop = shutdownResolved
|
|
76
|
+
const processAliveDuringStop = isProcessAlive(/** @type {number} */ (workerPid))
|
|
77
|
+
const idleTargetClosedDuringStop = idleTarget.destroyed
|
|
78
|
+
const idleUnrelatedClosedDuringStop = idleUnrelated.destroyed
|
|
79
|
+
|
|
80
|
+
// Ensure the RED path cannot leave an idle client handle blocking test cleanup.
|
|
81
|
+
idleTarget.destroy()
|
|
82
|
+
|
|
83
|
+
await fs.writeFile(gatePath, "continue\n")
|
|
84
|
+
gateReleased = true
|
|
85
|
+
|
|
86
|
+
const response = await shutdown
|
|
87
|
+
|
|
88
|
+
assert.equal(shutdownResolved, true)
|
|
89
|
+
assert.equal(resolvedDuringStop, false, "shutdown must not acknowledge while an owned process is still stopping")
|
|
90
|
+
assert.equal(oldEndpointAccepted, false, "the targeted endpoint must stop accepting new commands before cleanup")
|
|
91
|
+
assert.equal(processAliveDuringStop, true, "the fixture must hold shutdown while its owned process is alive")
|
|
92
|
+
assert.equal(idleTargetClosedDuringStop, true, "an idle accepted client must be closed when the targeted endpoint retires")
|
|
93
|
+
assert.equal(idleUnrelatedClosedDuringStop, false, "an unrelated daemon's accepted clients must remain untouched")
|
|
94
|
+
assert.deepEqual(response, {message: "shutdown", status: "success"})
|
|
95
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
96
|
+
assert.equal(isProcessAlive(/** @type {number} */ (workerPid)), false)
|
|
97
|
+
|
|
98
|
+
// A different daemon remains reachable; shutdown is scoped to the targeted control endpoint.
|
|
99
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: unrelatedSocketPath})).application, "shutdown-unrelated")
|
|
100
|
+
|
|
101
|
+
// Replacement starts immediately, with no polling or retry between truthful ACK and bind.
|
|
102
|
+
replacement = new RollbridgeDaemon({config, logger: () => {}})
|
|
103
|
+
await replacement.start()
|
|
104
|
+
assert.equal((await sendControlCommand({command: {command: "status"}, path: socketPath})).application, "shutdown-target")
|
|
105
|
+
} finally {
|
|
106
|
+
if (!gateReleased) {
|
|
107
|
+
await fs.writeFile(gatePath, "continue\n").catch(() => {})
|
|
108
|
+
}
|
|
109
|
+
idleTarget?.destroy()
|
|
110
|
+
idleUnrelated?.destroy()
|
|
111
|
+
if (replacement) await replacement.shutdown()
|
|
112
|
+
await daemon.shutdown()
|
|
113
|
+
await unrelated.shutdown()
|
|
114
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("control socket unlink failure is reported only after owned cleanup completes", async () => {
|
|
119
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-unlink-failure-"))
|
|
120
|
+
const socketPath = path.join(root, "control.sock")
|
|
121
|
+
const statePath = path.join(root, "state.json")
|
|
122
|
+
const config = normalizeConfig({...rawConfig(socketPath), statePath})
|
|
123
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await daemon.start()
|
|
127
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
128
|
+
if (daemon.pendingWrite) await daemon.pendingWrite
|
|
129
|
+
|
|
130
|
+
const webPid = daemon.activeRelease?.getProcess("web")?.pid
|
|
131
|
+
const proxyPort = daemon.getProxyPort()
|
|
132
|
+
|
|
133
|
+
assert.equal(typeof webPid, "number")
|
|
134
|
+
assert.equal(typeof proxyPort, "number")
|
|
135
|
+
|
|
136
|
+
daemon.removeControlSocket = async () => { throw new Error("injected unlink failure") }
|
|
137
|
+
|
|
138
|
+
await assert.rejects(
|
|
139
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
140
|
+
/control socket unlink failed: injected unlink failure/
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
assert.equal(isProcessAlive(/** @type {number} */ (webPid)), false, "unlink failure must not strand an owned process")
|
|
144
|
+
await assert.rejects(() => fetch(`http://127.0.0.1:${proxyPort}/ping`))
|
|
145
|
+
await assert.rejects(() => fs.stat(statePath), {code: "ENOENT"})
|
|
146
|
+
} finally {
|
|
147
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test("direct shutdown closes idle accepted clients and converges", async () => {
|
|
152
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-direct-shutdown-idle-"))
|
|
153
|
+
const socketPath = path.join(root, "control.sock")
|
|
154
|
+
const daemon = new RollbridgeDaemon({config: buildConfig(socketPath), logger: () => {}})
|
|
155
|
+
let idle = /** @type {net.Socket | undefined} */ (undefined)
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
await daemon.start()
|
|
159
|
+
idle = net.createConnection(socketPath)
|
|
160
|
+
await once(idle, "connect")
|
|
161
|
+
const idleClosed = once(idle, "close")
|
|
162
|
+
|
|
163
|
+
await daemon.shutdown()
|
|
164
|
+
await idleClosed
|
|
165
|
+
|
|
166
|
+
assert.equal(idle.destroyed, true)
|
|
167
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
168
|
+
} finally {
|
|
169
|
+
idle?.destroy()
|
|
170
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("shutdown reports cleanup failure and still retires the targeted endpoint", async () => {
|
|
175
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-failure-"))
|
|
176
|
+
const socketPath = path.join(root, "control.sock")
|
|
177
|
+
const statePath = path.join(root, "state-directory")
|
|
178
|
+
|
|
179
|
+
await fs.mkdir(statePath)
|
|
180
|
+
|
|
181
|
+
const config = normalizeConfig({
|
|
182
|
+
...rawConfig(socketPath),
|
|
183
|
+
statePath
|
|
184
|
+
})
|
|
185
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
await daemon.start()
|
|
189
|
+
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
192
|
+
/directory|EISDIR/i
|
|
193
|
+
)
|
|
194
|
+
await assert.rejects(() => fs.stat(socketPath), {code: "ENOENT"})
|
|
195
|
+
} finally {
|
|
196
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test("shutdown does not turn an owned-resource stop rejection into success", async () => {
|
|
201
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-stop-failure-"))
|
|
202
|
+
const socketPath = path.join(root, "control.sock")
|
|
203
|
+
const config = buildConfig(socketPath)
|
|
204
|
+
const daemon = new RollbridgeDaemon({config, logger: () => {}})
|
|
205
|
+
let restoreStop
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
await daemon.start()
|
|
209
|
+
await daemon.deploy({releaseId: "v1", releasePath: root, revision: "v1"})
|
|
210
|
+
|
|
211
|
+
const release = daemon.activeRelease
|
|
212
|
+
|
|
213
|
+
assert.ok(release)
|
|
214
|
+
const originalStop = release.stop.bind(release)
|
|
215
|
+
|
|
216
|
+
restoreStop = originalStop
|
|
217
|
+
release.stop = async () => { throw new Error("owned release stop failed") }
|
|
218
|
+
|
|
219
|
+
await assert.rejects(
|
|
220
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
221
|
+
/Shutdown failed to stop 1 owned resource/
|
|
222
|
+
)
|
|
223
|
+
} finally {
|
|
224
|
+
if (restoreStop) await restoreStop()
|
|
225
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("shutdown of an already-stopped endpoint fails explicitly", async () => {
|
|
230
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-shutdown-missing-"))
|
|
231
|
+
const socketPath = path.join(root, "missing.sock")
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await assert.rejects(
|
|
235
|
+
() => sendControlCommand({command: {command: "shutdown"}, path: socketPath}),
|
|
236
|
+
(error) => Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
237
|
+
)
|
|
238
|
+
} finally {
|
|
239
|
+
await fs.rm(root, {force: true, recursive: true})
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {string} socketPath - Control socket path.
|
|
245
|
+
* @param {{companion?: Record<string, import("../src/json.js").JsonValue>}} [options] - Optional companion process.
|
|
246
|
+
* @returns {import("../src/config.js").RollbridgeConfig} Normalized config.
|
|
247
|
+
*/
|
|
248
|
+
function buildConfig(socketPath, {companion} = {}) {
|
|
249
|
+
return normalizeConfig({
|
|
250
|
+
...rawConfig(socketPath),
|
|
251
|
+
...(companion ? {processes: [companion, ...rawConfig(socketPath).processes]} : {})
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} socketPath - Control socket path.
|
|
257
|
+
* @returns {{application: string, control: {path: string}, processes: Record<string, import("../src/json.js").JsonValue>[], proxy: {forceStopTimeoutMs: number, host: string, port: number}}} Raw config.
|
|
258
|
+
*/
|
|
259
|
+
function rawConfig(socketPath) {
|
|
260
|
+
return {
|
|
261
|
+
application: socketPath.endsWith("unrelated.sock") ? "shutdown-unrelated" : "shutdown-target",
|
|
262
|
+
control: {path: socketPath},
|
|
263
|
+
processes: [{
|
|
264
|
+
command: `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`,
|
|
265
|
+
health: {intervalMs: 25, path: "/ping", timeoutMs: 3000},
|
|
266
|
+
id: "web",
|
|
267
|
+
policy: "proxied",
|
|
268
|
+
port: {from: 0, to: 0}
|
|
269
|
+
}],
|
|
270
|
+
proxy: {forceStopTimeoutMs: 1000, host: "127.0.0.1", port: 0}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {string} filePath - File to await without polling.
|
|
276
|
+
* @returns {Promise<void>} Resolves when the file appears.
|
|
277
|
+
*/
|
|
278
|
+
async function waitForFile(filePath) {
|
|
279
|
+
const watcher = fs.watch(path.dirname(filePath))
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
for await (const event of watcher) {
|
|
283
|
+
if (event.filename === path.basename(filePath)) return
|
|
284
|
+
}
|
|
285
|
+
} finally {
|
|
286
|
+
await watcher.return?.()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
throw new Error(`Watcher ended before ${filePath} appeared`)
|
|
290
|
+
}
|