rollbridge 0.1.12 → 0.1.14
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 +17 -0
- package/docs/cli.md +24 -0
- package/package.json +1 -1
- package/src/cli.js +60 -1
- package/src/daemon.js +46 -10
- package/src/release-group.js +7 -1
- package/src/state-store.js +8 -0
- package/test/daemon-bootstrap.test.js +396 -0
- package/test/fixtures/dummy-app.js +25 -2
- package/test/package-metadata.test.js +39 -0
package/README.md
CHANGED
|
@@ -399,6 +399,23 @@ 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, activates the release through the normal
|
|
413
|
+
deploy path, then exposes the control socket and stays foreground. A failed
|
|
414
|
+
activation stops only processes started by that attempt and exits non-zero;
|
|
415
|
+
persisted processes from a previous daemon are reported as orphans and are never
|
|
416
|
+
recovered or killed implicitly; their live PID records remain in `statePath` for
|
|
417
|
+
explicit recovery.
|
|
418
|
+
|
|
402
419
|
Start the daemon only when it is not already running:
|
|
403
420
|
|
|
404
421
|
```bash
|
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,29 @@ 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 proxy and activates that
|
|
39
|
+
exact release through the normal deploy path: services,
|
|
40
|
+
companions, the proxied process and health check, traffic switching, singletons,
|
|
41
|
+
and service-template refresh. Only after activation succeeds does it expose the
|
|
42
|
+
control socket, preventing another deployment from overlapping bootstrap. It
|
|
43
|
+
then remains in the foreground with the normal signal behavior.
|
|
44
|
+
|
|
45
|
+
Bootstrap paths must be absolute and normalized, the release path must be an
|
|
46
|
+
accessible directory, and release id/revision values accept letters, numbers,
|
|
47
|
+
dots, underscores, and hyphens (maximum 200 characters, beginning with a letter
|
|
48
|
+
or number). Supplying only some bootstrap options, or an invalid value, exits
|
|
49
|
+
non-zero before listeners start. Activation failure emits a structured
|
|
50
|
+
`bootstrap activation failed` event, cleans up processes owned by that attempt,
|
|
51
|
+
and exits non-zero without exposing the control socket or inventing an active
|
|
52
|
+
release. `statePath` entries from a previous daemon remain advisory orphans:
|
|
53
|
+
bootstrap never runs recovery and never signals those processes, and retains
|
|
54
|
+
their live PID records in `statePath` for explicit recovery.
|
|
55
|
+
|
|
56
|
+
With no release options, daemon behavior is unchanged: it starts listener-only
|
|
57
|
+
and waits for control-socket deployments.
|
|
58
|
+
|
|
35
59
|
## `ensure-daemon`
|
|
36
60
|
|
|
37
61
|
```
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -30,12 +30,16 @@ 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
40
|
const daemon = new RollbridgeDaemon({config, configPath})
|
|
37
41
|
|
|
38
|
-
await daemon.start()
|
|
42
|
+
await daemon.start({exposeControl: !bootstrap})
|
|
39
43
|
|
|
40
44
|
const shutdown = async () => {
|
|
41
45
|
await daemon.shutdown()
|
|
@@ -44,6 +48,18 @@ 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
|
+
await daemon.exposeControl()
|
|
56
|
+
} catch {
|
|
57
|
+
daemon.logger("bootstrap activation failed", {releaseId: bootstrap.releaseId, status: "error"})
|
|
58
|
+
await daemon.shutdown()
|
|
59
|
+
process.exitCode = 1
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
}
|
|
47
63
|
})
|
|
48
64
|
|
|
49
65
|
program
|
|
@@ -696,6 +712,49 @@ async function validateConfigFile(configPath) {
|
|
|
696
712
|
}
|
|
697
713
|
}
|
|
698
714
|
|
|
715
|
+
/**
|
|
716
|
+
* Validates the daemon's optional all-or-nothing bootstrap release interface before
|
|
717
|
+
* config loading or listener startup.
|
|
718
|
+
* @param {{config?: string, releaseId?: string, releasePath?: string, revision?: string}} options - Daemon CLI options.
|
|
719
|
+
* @returns {Promise<{releaseId: string, releasePath: string, revision: string} | undefined>} Validated bootstrap metadata.
|
|
720
|
+
*/
|
|
721
|
+
async function validateDaemonBootstrapOptions(options) {
|
|
722
|
+
const bootstrapValues = [options.releasePath, options.releaseId, options.revision]
|
|
723
|
+
const bootstrapRequested = bootstrapValues.some((value) => value !== undefined)
|
|
724
|
+
|
|
725
|
+
if (!bootstrapRequested) return undefined
|
|
726
|
+
|
|
727
|
+
if (!options.config || bootstrapValues.some((value) => value === undefined)) {
|
|
728
|
+
throw new Error("Daemon bootstrap options --config, --release-path, --release-id, and --revision must be provided together.")
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (!path.isAbsolute(options.config)) throw new Error("Daemon bootstrap --config must be an absolute path.")
|
|
732
|
+
if (path.normalize(options.config) !== options.config) throw new Error("Daemon bootstrap --config must be normalized and must not contain unsafe traversal segments.")
|
|
733
|
+
if (!path.isAbsolute(/** @type {string} */ (options.releasePath))) throw new Error("Daemon bootstrap --release-path must be an absolute path.")
|
|
734
|
+
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.")
|
|
735
|
+
|
|
736
|
+
const releaseId = /** @type {string} */ (options.releaseId)
|
|
737
|
+
const revision = /** @type {string} */ (options.revision)
|
|
738
|
+
const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/
|
|
739
|
+
|
|
740
|
+
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.")
|
|
741
|
+
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.")
|
|
742
|
+
|
|
743
|
+
let releaseStat
|
|
744
|
+
|
|
745
|
+
try {
|
|
746
|
+
releaseStat = await fsPromises.stat(/** @type {string} */ (options.releasePath))
|
|
747
|
+
} catch (error) {
|
|
748
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
749
|
+
|
|
750
|
+
throw new Error(`Daemon bootstrap --release-path is not accessible: ${reason}`, {cause: error})
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if (!releaseStat.isDirectory()) throw new Error("Daemon bootstrap --release-path must name a directory.")
|
|
754
|
+
|
|
755
|
+
return {releaseId, releasePath: /** @type {string} */ (options.releasePath), revision}
|
|
756
|
+
}
|
|
757
|
+
|
|
699
758
|
/**
|
|
700
759
|
* Starts a daemon when needed and waits until it accepts status commands.
|
|
701
760
|
* @param {object} args - Options.
|
package/src/daemon.js
CHANGED
|
@@ -56,6 +56,7 @@ export default class RollbridgeDaemon {
|
|
|
56
56
|
this.statePath = config.statePath
|
|
57
57
|
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
58
58
|
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
59
|
+
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
59
60
|
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
60
61
|
// startup and surfaced in status(). The daemon cannot re-manage them, only report them.
|
|
61
62
|
this.orphans = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
|
|
@@ -63,11 +64,29 @@ export default class RollbridgeDaemon {
|
|
|
63
64
|
this.proxy.on("error", (error, req, res) => this.onProxyError(error, req, res))
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Starts daemon listeners.
|
|
69
|
+
* @param {{exposeControl?: boolean}} [options] - Whether to expose the control socket immediately.
|
|
70
|
+
* @returns {Promise<void>} Resolves when the requested listeners are ready.
|
|
71
|
+
*/
|
|
72
|
+
async start({exposeControl = true} = {}) {
|
|
68
73
|
await this.reportOrphans()
|
|
69
74
|
await this.startProxy()
|
|
75
|
+
if (exposeControl) await this.exposeControl()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** @returns {Promise<void>} Exposes control commands and begins periodic state persistence. */
|
|
79
|
+
async exposeControl() {
|
|
80
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
81
|
+
|
|
70
82
|
await this.startControlServer()
|
|
83
|
+
|
|
84
|
+
if (this.stopping) {
|
|
85
|
+
await this.closeServer(this.controlServer)
|
|
86
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
87
|
+
throw new Error("Rollbridge is shutting down")
|
|
88
|
+
}
|
|
89
|
+
|
|
71
90
|
this.startStatePersistence()
|
|
72
91
|
}
|
|
73
92
|
|
|
@@ -334,19 +353,26 @@ export default class RollbridgeDaemon {
|
|
|
334
353
|
releaseId: newReleaseId,
|
|
335
354
|
releasePath,
|
|
336
355
|
revision,
|
|
337
|
-
servicePorts: this.servicePorts
|
|
356
|
+
servicePorts: this.servicePorts,
|
|
357
|
+
shouldStart: () => !this.stopping
|
|
338
358
|
})
|
|
339
359
|
|
|
340
360
|
this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
|
|
341
361
|
const startedServices = /** @type {string[]} */ ([])
|
|
342
362
|
|
|
363
|
+
this.startingReleases.add(release)
|
|
364
|
+
|
|
343
365
|
try {
|
|
344
366
|
await this.ensureServices(release, startedServices)
|
|
345
367
|
await release.start()
|
|
368
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
346
369
|
} catch (error) {
|
|
347
370
|
this.logger("deploy failed", {error: error instanceof Error ? error.message : String(error), releaseId: newReleaseId})
|
|
371
|
+
await release.stop()
|
|
348
372
|
await this.stopStartedServices(startedServices)
|
|
349
373
|
throw error
|
|
374
|
+
} finally {
|
|
375
|
+
this.startingReleases.delete(release)
|
|
350
376
|
}
|
|
351
377
|
|
|
352
378
|
const previousRelease = this.activeRelease
|
|
@@ -467,6 +493,7 @@ export default class RollbridgeDaemon {
|
|
|
467
493
|
await release.allocatePorts()
|
|
468
494
|
|
|
469
495
|
for (const processConfig of release.config.processes) {
|
|
496
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
470
497
|
if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
|
|
471
498
|
if (this.services.has(processConfig.id)) continue
|
|
472
499
|
|
|
@@ -486,6 +513,8 @@ export default class RollbridgeDaemon {
|
|
|
486
513
|
delete this.servicePorts[processConfig.id]
|
|
487
514
|
throw error
|
|
488
515
|
}
|
|
516
|
+
|
|
517
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
489
518
|
}
|
|
490
519
|
}
|
|
491
520
|
|
|
@@ -532,12 +561,14 @@ export default class RollbridgeDaemon {
|
|
|
532
561
|
*/
|
|
533
562
|
async replaceSingletons(release) {
|
|
534
563
|
for (const processConfig of this.config.processes) {
|
|
564
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
535
565
|
if (processConfig.policy !== "singleton") continue
|
|
536
566
|
|
|
537
567
|
const previous = this.singletons.get(processConfig.id)
|
|
538
568
|
|
|
539
569
|
if (previous) {
|
|
540
570
|
await previous.stop()
|
|
571
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
541
572
|
}
|
|
542
573
|
|
|
543
574
|
const singleton = release.buildProcess(processConfig)
|
|
@@ -688,9 +719,7 @@ export default class RollbridgeDaemon {
|
|
|
688
719
|
if (!this.statePath || this.stopping) return
|
|
689
720
|
|
|
690
721
|
const statePath = this.statePath
|
|
691
|
-
|
|
692
|
-
// this daemon's own managed state, and is recomputed from the persisted processes on restart.
|
|
693
|
-
const {orphans: _orphans, ...status} = this.status()
|
|
722
|
+
const status = this.status()
|
|
694
723
|
const snapshot = {...status, events: this.eventLog.recent(), persistedAt: new Date().toISOString()}
|
|
695
724
|
|
|
696
725
|
// Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
|
|
@@ -742,17 +771,24 @@ export default class RollbridgeDaemon {
|
|
|
742
771
|
this.proxy.close()
|
|
743
772
|
await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
|
|
744
773
|
await Promise.allSettled([...this.singletons.values()].map((processInstance) => processInstance.stop()))
|
|
774
|
+
await Promise.allSettled([...this.startingReleases].map((release) => release.stop()))
|
|
745
775
|
await Promise.allSettled([...this.releases.values()].map((release) => release.stop()))
|
|
746
776
|
await this.closeServer(this.proxyServer)
|
|
747
777
|
await this.closeServer(this.controlServer)
|
|
748
778
|
await fs.rm(this.config.control.path, {force: true})
|
|
749
779
|
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
780
|
+
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
|
781
|
+
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
782
|
+
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
753
783
|
if (this.statePath) {
|
|
754
784
|
if (this.pendingWrite) await this.pendingWrite
|
|
755
|
-
|
|
785
|
+
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
786
|
+
|
|
787
|
+
if (orphans.length > 0) {
|
|
788
|
+
await writeState(this.statePath, {activeReleaseId: null, orphans, releases: [], services: [], singletons: []})
|
|
789
|
+
} else {
|
|
790
|
+
await clearState(this.statePath)
|
|
791
|
+
}
|
|
756
792
|
}
|
|
757
793
|
}
|
|
758
794
|
|
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) {
|
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})
|
|
@@ -0,0 +1,396 @@
|
|
|
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 os from "node:os"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import test from "node:test"
|
|
10
|
+
import {fileURLToPath} from "node:url"
|
|
11
|
+
import {sendControlCommand} from "../src/control-client.js"
|
|
12
|
+
import {isProcessAlive, liveProcesses, readState, writeState} from "../src/state-store.js"
|
|
13
|
+
|
|
14
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const binPath = path.join(currentDir, "..", "bin", "rollbridge")
|
|
16
|
+
const dummyAppPath = path.join(currentDir, "fixtures", "dummy-app.js")
|
|
17
|
+
|
|
18
|
+
test("daemon bootstrap requires complete, safe, absolute inputs before binding listeners", async (t) => {
|
|
19
|
+
const cases = [
|
|
20
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
|
|
21
|
+
{args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
|
|
22
|
+
{args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
|
|
23
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "unsafe id", "--revision", "abc123"], message: /--release-id/},
|
|
24
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "unsafe revision"], message: /--revision/}
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
for (const testCase of cases) {
|
|
28
|
+
await t.test(testCase.message.source, async () => {
|
|
29
|
+
const fixture = await createFixture()
|
|
30
|
+
const args = testCase.args.map((arg) => arg === "CONFIG" ? fixture.configPath : arg === "RELEASE" ? fixture.root : arg)
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const result = await runDaemon(args)
|
|
34
|
+
|
|
35
|
+
assert.notEqual(result.code, 0)
|
|
36
|
+
assert.match(result.stderr, testCase.message)
|
|
37
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
38
|
+
} finally {
|
|
39
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("daemon bootstrap activates the exact release through the foreground daemon", async () => {
|
|
46
|
+
const fixture = await createFixture()
|
|
47
|
+
const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await waitForLog(child, "control socket listening")
|
|
51
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
52
|
+
const activeRelease = assertRelease(status, "release-42")
|
|
53
|
+
|
|
54
|
+
assert.equal(activeRelease.releasePath, fixture.root)
|
|
55
|
+
assert.equal(activeRelease.revision, "abc123")
|
|
56
|
+
assert.ok(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")
|
|
57
|
+
assert.equal((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim(), "release-42")
|
|
58
|
+
|
|
59
|
+
child.kill("SIGTERM")
|
|
60
|
+
assert.equal((await once(child, "exit"))[0], 0)
|
|
61
|
+
} finally {
|
|
62
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
63
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test("daemon bootstrap does not expose control deploys until activation completes", async () => {
|
|
68
|
+
const fixture = await createFixture({healthGate: true, healthTimeoutMs: 60000})
|
|
69
|
+
const started = waitForFile(fixture.startedPath)
|
|
70
|
+
const child = spawnDaemon(fixture, {releaseId: "bootstrap-release", revision: "bootstrap123"})
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await started
|
|
74
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
75
|
+
|
|
76
|
+
await fs.writeFile(fixture.healthGatePath, "ready\n")
|
|
77
|
+
await waitForLog(child, "control socket listening")
|
|
78
|
+
|
|
79
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
80
|
+
|
|
81
|
+
assert.equal(status.activeReleaseId, "bootstrap-release")
|
|
82
|
+
assert.ok(Array.isArray(status.releases))
|
|
83
|
+
assert.equal(status.releases.length, 1)
|
|
84
|
+
assertRelease(status, "bootstrap-release")
|
|
85
|
+
|
|
86
|
+
child.kill("SIGTERM")
|
|
87
|
+
assert.equal((await once(child, "exit"))[0], 0)
|
|
88
|
+
} finally {
|
|
89
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
90
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("plain daemon startup remains listener-only with no active release", async () => {
|
|
95
|
+
const fixture = await createFixture()
|
|
96
|
+
const child = spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath], {stdio: ["pipe", "pipe", "pipe"]})
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await waitForLog(child, "control socket listening")
|
|
100
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
101
|
+
|
|
102
|
+
assert.equal(status.activeReleaseId, null)
|
|
103
|
+
assert.deepEqual(status.releases, [])
|
|
104
|
+
|
|
105
|
+
child.kill("SIGTERM")
|
|
106
|
+
assert.equal((await once(child, "exit"))[0], 0)
|
|
107
|
+
} finally {
|
|
108
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
109
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("SIGTERM during bootstrap activation follows the daemon shutdown path", async () => {
|
|
114
|
+
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 60000})
|
|
115
|
+
const started = waitForFile(fixture.startedPath)
|
|
116
|
+
const child = spawnDaemon(fixture, {releaseId: "slow-release", revision: "slow123"})
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const managedPid = Number(await started)
|
|
120
|
+
child.kill("SIGTERM")
|
|
121
|
+
|
|
122
|
+
const [code, signal] = await once(child, "exit")
|
|
123
|
+
|
|
124
|
+
assert.equal(code, 0)
|
|
125
|
+
assert.equal(signal, null)
|
|
126
|
+
assert.equal(await fs.readFile(fixture.stoppedPath, "utf8"), String(managedPid))
|
|
127
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
128
|
+
} finally {
|
|
129
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
130
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("SIGTERM during multi-process bootstrap owns every process started after shutdown begins", async () => {
|
|
135
|
+
const fixture = await createFixture({multiProcessSignal: true})
|
|
136
|
+
const shutdownStarted = waitForLifecycleEvent(fixture.lifecyclePath, (event) => event.event === "shutdown")
|
|
137
|
+
const child = spawnDaemon(fixture, {releaseId: "multi-release", revision: "multi123"})
|
|
138
|
+
let output = ""
|
|
139
|
+
|
|
140
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await shutdownStarted
|
|
144
|
+
await fs.writeFile(fixture.gatePath, "continue\n")
|
|
145
|
+
const [code, signal] = await once(child, "exit")
|
|
146
|
+
const events = (await fs.readFile(fixture.lifecyclePath, "utf8")).trim().split("\n").map((line) => JSON.parse(line))
|
|
147
|
+
const shutdownIndex = events.findIndex((event) => event.event === "shutdown")
|
|
148
|
+
const startedAfterShutdown = events.slice(shutdownIndex + 1).filter((event) => event.event === "started")
|
|
149
|
+
const records = output.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
150
|
+
const recordedPids = new Set(records.filter((record) => record.message === "process started").map((record) => record.data?.pid))
|
|
151
|
+
|
|
152
|
+
assert.equal(code, 0)
|
|
153
|
+
assert.equal(signal, null)
|
|
154
|
+
assert.notEqual(shutdownIndex, -1)
|
|
155
|
+
assert.ok(startedAfterShutdown.length > 0, `fixture must start a later bootstrap process after triggering shutdown: ${JSON.stringify(events)}`)
|
|
156
|
+
assert.deepEqual(startedAfterShutdown.filter((event) => !recordedPids.has(event.pid)), [])
|
|
157
|
+
for (const event of startedAfterShutdown) assert.equal(isProcessAlive(event.pid), false, `expected process ${event.pid} to be stopped before daemon exit`)
|
|
158
|
+
} finally {
|
|
159
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const events = (await fs.readFile(fixture.lifecyclePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
163
|
+
|
|
164
|
+
for (const event of events) {
|
|
165
|
+
if (event.event === "started" && isProcessAlive(event.pid)) process.kill(-event.pid, "SIGKILL")
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// The fixture may exit before creating its lifecycle log.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("failed daemon bootstrap reports a structured failure, cleans its processes, and exits non-zero", async () => {
|
|
176
|
+
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 100})
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const result = await runDaemon([
|
|
180
|
+
"--config", fixture.configPath,
|
|
181
|
+
"--release-path", fixture.root,
|
|
182
|
+
"--release-id", "bad-release",
|
|
183
|
+
"--revision", "bad123"
|
|
184
|
+
])
|
|
185
|
+
const records = result.output.split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
186
|
+
const failure = records.find((record) => record.message === "bootstrap activation failed")
|
|
187
|
+
|
|
188
|
+
assert.notEqual(result.code, 0)
|
|
189
|
+
assert.deepEqual(failure?.data, {releaseId: "bad-release", status: "error"})
|
|
190
|
+
assert.ok(records.some((record) => record.message === "release startup process status" && record.data?.phase === "after cleanup" && record.data?.state === "stopped"))
|
|
191
|
+
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
192
|
+
} finally {
|
|
193
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test("daemon bootstrap reports but does not kill a live process from statePath", async () => {
|
|
198
|
+
const fixture = await createFixture({persistState: true})
|
|
199
|
+
const leftover = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
|
|
200
|
+
|
|
201
|
+
await once(leftover, "spawn")
|
|
202
|
+
await writeState(fixture.statePath, {
|
|
203
|
+
activeReleaseId: "previous",
|
|
204
|
+
releases: [{processes: [{id: "worker", pid: leftover.pid}], releaseId: "previous"}],
|
|
205
|
+
services: [],
|
|
206
|
+
singletons: []
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const child = spawnDaemon(fixture, {releaseId: "recovered", revision: "def456"})
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
await waitForLog(child, "control socket listening")
|
|
213
|
+
const status = await sendControlCommand({command: {command: "status"}, path: fixture.socketPath})
|
|
214
|
+
|
|
215
|
+
assert.ok(leftover.pid !== undefined && isProcessAlive(leftover.pid))
|
|
216
|
+
assert.deepEqual(status.orphans, [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
217
|
+
|
|
218
|
+
child.kill("SIGTERM")
|
|
219
|
+
await once(child, "exit")
|
|
220
|
+
|
|
221
|
+
assert.deepEqual(liveProcesses(await readState(fixture.statePath)), [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
222
|
+
} finally {
|
|
223
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
224
|
+
leftover.kill("SIGKILL")
|
|
225
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("failed daemon bootstrap preserves prior live process records in statePath", async () => {
|
|
230
|
+
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 100, persistState: true})
|
|
231
|
+
const leftover = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {stdio: "ignore"})
|
|
232
|
+
|
|
233
|
+
await once(leftover, "spawn")
|
|
234
|
+
await writeState(fixture.statePath, {
|
|
235
|
+
activeReleaseId: "previous",
|
|
236
|
+
releases: [{processes: [{id: "worker", pid: leftover.pid}], releaseId: "previous"}],
|
|
237
|
+
services: [],
|
|
238
|
+
singletons: []
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const result = await runDaemon([
|
|
243
|
+
"--config", fixture.configPath,
|
|
244
|
+
"--release-path", fixture.root,
|
|
245
|
+
"--release-id", "bad-release",
|
|
246
|
+
"--revision", "bad123"
|
|
247
|
+
])
|
|
248
|
+
|
|
249
|
+
assert.notEqual(result.code, 0)
|
|
250
|
+
assert.deepEqual(liveProcesses(await readState(fixture.statePath)), [{id: "worker", pid: leftover.pid, releaseId: "previous"}])
|
|
251
|
+
} finally {
|
|
252
|
+
leftover.kill("SIGKILL")
|
|
253
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {{healthGate?: boolean, healthPath?: string, healthTimeoutMs?: number, multiProcessSignal?: boolean, persistState?: boolean}} [options] - Fixture options.
|
|
259
|
+
* @returns {Promise<{configPath: string, gatePath: string, healthGatePath: string, lifecyclePath: string, root: string, socketPath: string, startedPath: string, statePath: string, stoppedPath: string}>} Fixture paths.
|
|
260
|
+
*/
|
|
261
|
+
async function createFixture({healthGate = false, healthPath = "/ping", healthTimeoutMs = 1000, multiProcessSignal = false, persistState = false} = {}) {
|
|
262
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-bootstrap-"))
|
|
263
|
+
const socketPath = path.join(root, "control.sock")
|
|
264
|
+
const statePath = path.join(root, "state.json")
|
|
265
|
+
const startedPath = path.join(root, "started.pid")
|
|
266
|
+
const stoppedPath = path.join(root, "stopped.pid")
|
|
267
|
+
const lifecyclePath = path.join(root, "lifecycle.jsonl")
|
|
268
|
+
const gatePath = path.join(root, "continue.fifo")
|
|
269
|
+
const healthGatePath = path.join(root, "health-ready")
|
|
270
|
+
const configPath = path.join(root, "rollbridge.js")
|
|
271
|
+
const command = `${JSON.stringify(process.execPath)} ${JSON.stringify(dummyAppPath)}`
|
|
272
|
+
const lifecycleEnv = {ROLLBRIDGE_TEST_LIFECYCLE_PATH: lifecyclePath}
|
|
273
|
+
const webEnv = {
|
|
274
|
+
ROLLBRIDGE_TEST_STARTED_PATH: startedPath,
|
|
275
|
+
ROLLBRIDGE_TEST_STOPPED_PATH: stoppedPath,
|
|
276
|
+
...(healthGate ? {ROLLBRIDGE_TEST_HEALTH_GATE_PATH: healthGatePath} : {})
|
|
277
|
+
}
|
|
278
|
+
const config = {
|
|
279
|
+
application: "bootstrap-test",
|
|
280
|
+
control: {path: socketPath},
|
|
281
|
+
processes: multiProcessSignal ? [
|
|
282
|
+
{command: `trap '' TERM; printf '%s\\n' '{"event":"shutdown"}' >> ${JSON.stringify(lifecyclePath)}; kill -TERM "$ROLLBRIDGE_TEST_DAEMON_PID"; printf '{"event":"started","pid":%s,"processId":"database","replicaIndex":"0"}\\n' "$$" >> ${JSON.stringify(lifecyclePath)}; read ignored < ${JSON.stringify(gatePath)}`, env: lifecycleEnv, id: "database", policy: "service"},
|
|
283
|
+
{command: `exec ${command}`, env: lifecycleEnv, id: "worker", policy: "companion", replicas: 2},
|
|
284
|
+
{command: `exec ${command}`, env: lifecycleEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}
|
|
285
|
+
] : [{command, env: webEnv, health: {path: healthPath, timeoutMs: healthTimeoutMs}, id: "web", policy: "proxied", port: {from: 0, to: 0}}],
|
|
286
|
+
proxy: {host: "127.0.0.1", port: 0},
|
|
287
|
+
...(persistState ? {statePath} : {})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const setup = multiProcessSignal ? "process.env.ROLLBRIDGE_TEST_DAEMON_PID = String(process.pid)\n" : ""
|
|
291
|
+
|
|
292
|
+
if (multiProcessSignal) {
|
|
293
|
+
const fifo = spawn("mkfifo", [gatePath])
|
|
294
|
+
const [code] = await once(fifo, "exit")
|
|
295
|
+
|
|
296
|
+
assert.equal(code, 0)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await fs.writeFile(configPath, `${setup}module.exports = ${JSON.stringify(config, null, 2)}\n`)
|
|
300
|
+
return {configPath, gatePath, healthGatePath, lifecyclePath, root, socketPath, startedPath, statePath, stoppedPath}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} filePath - JSON-lines event path.
|
|
305
|
+
* @param {(event: Record<string, import("../src/json.js").JsonValue>) => boolean} matches - Event predicate.
|
|
306
|
+
* @returns {Promise<Record<string, import("../src/json.js").JsonValue>>} First matching event.
|
|
307
|
+
*/
|
|
308
|
+
async function waitForLifecycleEvent(filePath, matches) {
|
|
309
|
+
const watcher = fs.watch(path.dirname(filePath))
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
for await (const event of watcher) {
|
|
313
|
+
if (event.filename !== path.basename(filePath)) continue
|
|
314
|
+
|
|
315
|
+
const records = (await fs.readFile(filePath, "utf8")).trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
316
|
+
const match = records.find(matches)
|
|
317
|
+
|
|
318
|
+
if (match) return match
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
await watcher.return?.()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
throw new Error(`File watcher ended before a matching event was written to ${filePath}`)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {string} filePath - File whose creation is the synchronization point.
|
|
329
|
+
* @returns {Promise<string>} File contents once created.
|
|
330
|
+
*/
|
|
331
|
+
async function waitForFile(filePath) {
|
|
332
|
+
const watcher = fs.watch(path.dirname(filePath))
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
for await (const event of watcher) {
|
|
336
|
+
if (event.filename === path.basename(filePath)) return await fs.readFile(filePath, "utf8")
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
await watcher.return?.()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
throw new Error(`File watcher ended before ${filePath} was created`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @param {{configPath: string, root: string}} fixture - Fixture paths.
|
|
347
|
+
* @param {{releaseId: string, revision: string}} release - Bootstrap metadata.
|
|
348
|
+
* @returns {import("node:child_process").ChildProcessWithoutNullStreams} Spawned daemon.
|
|
349
|
+
*/
|
|
350
|
+
function spawnDaemon(fixture, release) {
|
|
351
|
+
return spawn(process.execPath, [binPath, "daemon", "--config", fixture.configPath, "--release-path", fixture.root, "--release-id", release.releaseId, "--revision", release.revision], {stdio: ["pipe", "pipe", "pipe"]})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* @param {string[]} args - Daemon arguments.
|
|
356
|
+
* @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
|
|
357
|
+
*/
|
|
358
|
+
async function runDaemon(args) {
|
|
359
|
+
const child = spawn(process.execPath, [binPath, "daemon", ...args], {stdio: ["ignore", "pipe", "pipe"]})
|
|
360
|
+
let output = ""
|
|
361
|
+
let stderr = ""
|
|
362
|
+
|
|
363
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => { output += chunk })
|
|
364
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk })
|
|
365
|
+
const [code] = await once(child, "exit")
|
|
366
|
+
|
|
367
|
+
return {code, output, stderr}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* @param {import("node:child_process").ChildProcessWithoutNullStreams} child - Spawned daemon.
|
|
372
|
+
* @param {string} message - Structured log message to await.
|
|
373
|
+
* @returns {Promise<void>} Resolves after the message is observed.
|
|
374
|
+
*/
|
|
375
|
+
async function waitForLog(child, message) {
|
|
376
|
+
child.stdout.setEncoding("utf8")
|
|
377
|
+
|
|
378
|
+
for await (const chunk of child.stdout) {
|
|
379
|
+
if (String(chunk).includes(`"message":"${message}"`)) return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
throw new Error(`Daemon exited before logging ${message}`)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {Record<string, import("../src/json.js").JsonValue>} status - Daemon status.
|
|
387
|
+
* @param {string} releaseId - Expected release id.
|
|
388
|
+
* @returns {Record<string, import("../src/json.js").JsonValue>} Matching release status.
|
|
389
|
+
*/
|
|
390
|
+
function assertRelease(status, releaseId) {
|
|
391
|
+
assert.ok(Array.isArray(status.releases))
|
|
392
|
+
const release = status.releases.find((candidate) => candidate && typeof candidate === "object" && "releaseId" in candidate && candidate.releaseId === releaseId)
|
|
393
|
+
|
|
394
|
+
assert.ok(release && typeof release === "object" && !Array.isArray(release))
|
|
395
|
+
return release
|
|
396
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import crypto from "node:crypto"
|
|
4
|
+
import fs from "node:fs"
|
|
4
5
|
import http from "node:http"
|
|
5
6
|
|
|
6
7
|
const port = Number(process.env.ROLLBRIDGE_PORT)
|
|
@@ -8,9 +9,13 @@ const releaseId = process.env.ROLLBRIDGE_RELEASE_ID || "unknown"
|
|
|
8
9
|
const healthFails = releaseId.includes("bad")
|
|
9
10
|
const sockets = new Set()
|
|
10
11
|
|
|
12
|
+
if (process.env.ROLLBRIDGE_TEST_STARTED_PATH) {
|
|
13
|
+
fs.writeFileSync(process.env.ROLLBRIDGE_TEST_STARTED_PATH, String(process.pid))
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
const server = http.createServer((request, response) => {
|
|
12
17
|
if (request.url === "/ping") {
|
|
13
|
-
if (healthFails) {
|
|
18
|
+
if (healthFails || (process.env.ROLLBRIDGE_TEST_HEALTH_GATE_PATH && !fs.existsSync(process.env.ROLLBRIDGE_TEST_HEALTH_GATE_PATH))) {
|
|
14
19
|
response.writeHead(500, {"Content-Type": "application/json"})
|
|
15
20
|
response.end(JSON.stringify({message: "bad release"}))
|
|
16
21
|
return
|
|
@@ -59,6 +64,14 @@ server.on("upgrade", (request, socket) => {
|
|
|
59
64
|
})
|
|
60
65
|
|
|
61
66
|
process.on("SIGTERM", () => {
|
|
67
|
+
if (process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH) {
|
|
68
|
+
fs.appendFileSync(process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH, `${JSON.stringify({event: "stopped", pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID, replicaIndex: process.env.ROLLBRIDGE_REPLICA_INDEX})}\n`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.env.ROLLBRIDGE_TEST_STOPPED_PATH) {
|
|
72
|
+
fs.writeFileSync(process.env.ROLLBRIDGE_TEST_STOPPED_PATH, String(process.pid))
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
server.close(() => process.exit(0))
|
|
63
76
|
|
|
64
77
|
if (sockets.size === 0) {
|
|
@@ -66,4 +79,14 @@ process.on("SIGTERM", () => {
|
|
|
66
79
|
}
|
|
67
80
|
})
|
|
68
81
|
|
|
69
|
-
|
|
82
|
+
const recordStarted = () => {
|
|
83
|
+
if (process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH) {
|
|
84
|
+
fs.appendFileSync(process.env.ROLLBRIDGE_TEST_LIFECYCLE_PATH, `${JSON.stringify({event: "started", pid: process.pid, processId: process.env.ROLLBRIDGE_PROCESS_ID, replicaIndex: process.env.ROLLBRIDGE_REPLICA_INDEX})}\n`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (process.env.ROLLBRIDGE_PORT) {
|
|
89
|
+
server.listen(port, "127.0.0.1", recordStarted)
|
|
90
|
+
} else {
|
|
91
|
+
recordStarted()
|
|
92
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import assert from "node:assert/strict"
|
|
4
|
+
import {execFile} from "node:child_process"
|
|
4
5
|
import fs from "node:fs/promises"
|
|
6
|
+
import os from "node:os"
|
|
5
7
|
import path from "node:path"
|
|
6
8
|
import test from "node:test"
|
|
7
9
|
import {fileURLToPath} from "node:url"
|
|
10
|
+
import {promisify} from "node:util"
|
|
8
11
|
|
|
9
12
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
13
|
+
const execFileAsync = promisify(execFile)
|
|
10
14
|
|
|
11
15
|
test("package.json declares publish metadata", async () => {
|
|
12
16
|
const pkg = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8"))
|
|
@@ -27,3 +31,38 @@ test("a LICENSE file matching the declared license exists", async () => {
|
|
|
27
31
|
assert.match(license, /MIT License/)
|
|
28
32
|
assert.match(license, /Copyright \(c\) \d{4} kaspernj/)
|
|
29
33
|
})
|
|
34
|
+
|
|
35
|
+
test("package manifest excludes unexpected operational and coverage files", async (t) => {
|
|
36
|
+
const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "rollbridge-pack-"))
|
|
37
|
+
t.after(() => fs.rm(fixtureRoot, {force: true, recursive: true}))
|
|
38
|
+
|
|
39
|
+
await fs.cp(repoRoot, fixtureRoot, {
|
|
40
|
+
filter: (source) => {
|
|
41
|
+
const relative = path.relative(repoRoot, source)
|
|
42
|
+
return relative !== ".git" && relative !== "node_modules" && relative !== "tmp"
|
|
43
|
+
},
|
|
44
|
+
recursive: true,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const unexpectedTmpPath = path.join(fixtureRoot, "tmp", "worker-control", "unexpected-transcript.jsonl")
|
|
48
|
+
const unexpectedCoveragePath = path.join(fixtureRoot, "coverage", "unexpected.txt")
|
|
49
|
+
await Promise.all([
|
|
50
|
+
fs.mkdir(path.dirname(unexpectedTmpPath), {recursive: true}),
|
|
51
|
+
fs.mkdir(path.dirname(unexpectedCoveragePath), {recursive: true}),
|
|
52
|
+
])
|
|
53
|
+
await Promise.all([
|
|
54
|
+
fs.writeFile(unexpectedTmpPath, '{"operational":"state"}\n'),
|
|
55
|
+
fs.writeFile(unexpectedCoveragePath, "unexpected coverage output\n"),
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
const {stdout} = await execFileAsync("npm", ["pack", "--dry-run", "--json"], {cwd: fixtureRoot})
|
|
59
|
+
/** @type {Array<{path: string}>} */
|
|
60
|
+
const packageFiles = JSON.parse(stdout)[0].files
|
|
61
|
+
const packagePaths = packageFiles.map((file) => file.path)
|
|
62
|
+
|
|
63
|
+
for (const requiredPath of ["LICENSE", "README.md", "bin/rollbridge", "package.json", "src/cli.js"]) {
|
|
64
|
+
assert.ok(packagePaths.includes(requiredPath), `expected package to include ${requiredPath}`)
|
|
65
|
+
}
|
|
66
|
+
assert.ok(!packagePaths.some((packagePath) => packagePath === "tmp" || packagePath.startsWith("tmp/")))
|
|
67
|
+
assert.ok(!packagePaths.some((packagePath) => packagePath === "coverage" || packagePath.startsWith("coverage/")))
|
|
68
|
+
})
|