rollbridge 0.1.16 → 0.1.18
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 -5
- package/docs/cli.md +27 -1
- package/docs/config.md +7 -4
- package/docs/logging.md +6 -0
- package/package.json +1 -1
- package/src/cli.js +10 -4
- package/src/daemon.js +33 -4
- package/test/daemon-bootstrap.test.js +81 -6
- package/test/managed-process.test.js +9 -0
- package/test/rollbridge.test.js +34 -2
package/README.md
CHANGED
|
@@ -162,10 +162,13 @@ owns cleaning up on-disk release directories.
|
|
|
162
162
|
releaseRetention: {keep: 5, maxAgeMs: 86400000}
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
Set `statePath` to have the daemon persist
|
|
166
|
-
releases, process pids, counters, recent events).
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
Set `statePath` to have the daemon persist secret-safe recovery state to a file
|
|
166
|
+
(active/draining releases, process pids, counters, sanitized recent events).
|
|
167
|
+
Commands, environment mappings, child command lines, and captured process output
|
|
168
|
+
are deliberately excluded; those diagnostics remain available through the live
|
|
169
|
+
status/log/event APIs. On the next startup Rollbridge reads any leftover file and
|
|
170
|
+
reports managed processes still alive from a daemon that didn't shut down cleanly
|
|
171
|
+
— advisory orphan detection. After a crash, run
|
|
169
172
|
`rollbridge recover` to list those leftovers and `rollbridge recover --force` to
|
|
170
173
|
stop them before restarting the daemon. A clean `shutdown` removes the file. See
|
|
171
174
|
[`docs/config.md`](docs/config.md#statepath).
|
|
@@ -405,7 +408,8 @@ the foreground (for example from a boot-time service manager):
|
|
|
405
408
|
```bash
|
|
406
409
|
rollbridge daemon --config /srv/ticket-server/rollbridge.js \
|
|
407
410
|
--release-path /srv/ticket-server/releases/20260813090000/ticket-server \
|
|
408
|
-
--release-id 20260813090000 --revision abc123
|
|
411
|
+
--release-id 20260813090000 --revision abc123 \
|
|
412
|
+
--boot-attestation sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
|
|
409
413
|
```
|
|
410
414
|
|
|
411
415
|
The four bootstrap inputs are all-or-nothing and use absolute config/release
|
|
@@ -416,6 +420,14 @@ persisted processes from a previous daemon are reported as orphans and are never
|
|
|
416
420
|
recovered or killed implicitly; their live PID records remain in `statePath` for
|
|
417
421
|
explicit recovery.
|
|
418
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
|
+
|
|
419
431
|
Start the daemon only when it is not already running:
|
|
420
432
|
|
|
421
433
|
```bash
|
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
|
package/docs/config.md
CHANGED
|
@@ -90,10 +90,13 @@ release records; the deploy tool still owns on-disk release directories.
|
|
|
90
90
|
|
|
91
91
|
## `statePath`
|
|
92
92
|
|
|
93
|
-
When set, the daemon persists a state snapshot — the active and
|
|
94
|
-
releases, each managed process's metadata (including pid),
|
|
95
|
-
recent events — to this file (atomically, on
|
|
96
|
-
|
|
93
|
+
When set, the daemon persists a secret-safe state snapshot — the active and
|
|
94
|
+
draining releases, each managed process's recovery metadata (including pid),
|
|
95
|
+
restart counters, and recent structured events — to this file (atomically, on
|
|
96
|
+
changes and every few seconds). Process commands, working directories,
|
|
97
|
+
environment mappings, child command lines, and retained stdout/stderr are never
|
|
98
|
+
persisted. They remain available from the live `status`, `logs`, and `events`
|
|
99
|
+
APIs while the daemon is running. On a clean `shutdown` the file is removed.
|
|
97
100
|
|
|
98
101
|
On the **next startup**, the daemon reads any leftover file and reports managed
|
|
99
102
|
processes whose pids are still alive — likely orphans from a daemon that crashed
|
package/docs/logging.md
CHANGED
|
@@ -36,6 +36,12 @@ events. It is distinct from the two in-memory views:
|
|
|
36
36
|
- `rollbridge events` — the recent structured daemon event history (the most
|
|
37
37
|
recent 1000 events), the same events written to the log file.
|
|
38
38
|
|
|
39
|
+
When `statePath` is configured, its recovery snapshot is intentionally not a
|
|
40
|
+
log archive: process commands, environment mappings, child command lines, and
|
|
41
|
+
captured stdout/stderr are excluded. Use the live APIs above or the configured
|
|
42
|
+
daemon log for those diagnostics, and protect that log according to the
|
|
43
|
+
sensitivity of application output.
|
|
44
|
+
|
|
39
45
|
Both are cleared when the daemon restarts; the log file persists.
|
|
40
46
|
|
|
41
47
|
## Rotation
|
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
|
|
@@ -721,8 +724,9 @@ export default class RollbridgeDaemon {
|
|
|
721
724
|
if (!this.statePath || this.stopping) return
|
|
722
725
|
|
|
723
726
|
const statePath = this.statePath
|
|
724
|
-
const status = this.status()
|
|
725
|
-
const
|
|
727
|
+
const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
|
|
728
|
+
const events = secretSafeStateValue(this.eventLog.recent())
|
|
729
|
+
const snapshot = {...status, events, persistedAt: new Date().toISOString()}
|
|
726
730
|
|
|
727
731
|
// Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
|
|
728
732
|
// clearing the file — otherwise a write started before shutdown could recreate it afterward.
|
|
@@ -819,6 +823,7 @@ export default class RollbridgeDaemon {
|
|
|
819
823
|
return {
|
|
820
824
|
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
821
825
|
application: this.config.application,
|
|
826
|
+
bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
|
|
822
827
|
control: {...this.config.control},
|
|
823
828
|
daemonRuntime: this.runtime ? {...this.runtime} : undefined,
|
|
824
829
|
orphans: [...this.orphans],
|
|
@@ -851,6 +856,30 @@ function stringOrUndefined(value) {
|
|
|
851
856
|
return value
|
|
852
857
|
}
|
|
853
858
|
|
|
859
|
+
const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Removes process definitions and captured output from a value before it reaches statePath.
|
|
863
|
+
* The live status/events APIs retain those diagnostics in memory; persistent state is only a
|
|
864
|
+
* secret-safe recovery aid and must not become a second process log or configuration store.
|
|
865
|
+
* @param {JsonValue} value - JSON value to sanitize.
|
|
866
|
+
* @returns {JsonValue} A secret-safe copy.
|
|
867
|
+
*/
|
|
868
|
+
function secretSafeStateValue(value) {
|
|
869
|
+
if (Array.isArray(value)) return value.map((entry) => secretSafeStateValue(entry))
|
|
870
|
+
if (!value || typeof value !== "object") return value
|
|
871
|
+
|
|
872
|
+
/** @type {Record<string, JsonValue>} */
|
|
873
|
+
const safe = {}
|
|
874
|
+
|
|
875
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
876
|
+
if (SECRET_BEARING_STATE_KEYS.has(key)) continue
|
|
877
|
+
safe[key] = secretSafeStateValue(entry)
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return safe
|
|
881
|
+
}
|
|
882
|
+
|
|
854
883
|
/**
|
|
855
884
|
* @param {JsonValue} value - Value.
|
|
856
885
|
* @param {string} key - Key.
|
|
@@ -14,20 +14,34 @@ 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 = [
|
|
20
22
|
{args: ["--config", "CONFIG", "--release-path", "RELEASE", "--release-id", "v1"], message: /must be provided together/},
|
|
21
23
|
{args: ["--config", "relative/config.js", "--release-path", "RELEASE", "--release-id", "v1", "--revision", "abc123"], message: /--config must be an absolute path/},
|
|
22
24
|
{args: ["--config", "CONFIG", "--release-path", "relative/release", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be an absolute path/},
|
|
25
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE_UNNORMALIZED", "--release-id", "v1", "--revision", "abc123"], message: /--release-path must be normalized/},
|
|
26
|
+
{args: ["--config", "CONFIG", "--release-path", "RELEASE_MISSING", "--release-id", "v1", "--revision", "abc123"], message: /--release-path is not accessible/},
|
|
23
27
|
{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/}
|
|
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/}
|
|
25
33
|
]
|
|
26
34
|
|
|
27
35
|
for (const testCase of cases) {
|
|
28
36
|
await t.test(testCase.message.source, async () => {
|
|
29
37
|
const fixture = await createFixture()
|
|
30
|
-
const args = testCase.args.map((arg) =>
|
|
38
|
+
const args = testCase.args.map((arg) => {
|
|
39
|
+
if (arg === "CONFIG") return fixture.configPath
|
|
40
|
+
if (arg === "RELEASE") return fixture.root
|
|
41
|
+
if (arg === "RELEASE_UNNORMALIZED") return `${fixture.root}/child/..`
|
|
42
|
+
if (arg === "RELEASE_MISSING") return path.join(fixture.root, "missing")
|
|
43
|
+
return arg
|
|
44
|
+
})
|
|
31
45
|
|
|
32
46
|
try {
|
|
33
47
|
const result = await runDaemon(args)
|
|
@@ -35,6 +49,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
|
|
|
35
49
|
assert.notEqual(result.code, 0)
|
|
36
50
|
assert.match(result.stderr, testCase.message)
|
|
37
51
|
await assert.rejects(() => fs.stat(fixture.socketPath), {code: "ENOENT"})
|
|
52
|
+
await assert.rejects(() => fs.stat(fixture.startedPath), {code: "ENOENT"})
|
|
38
53
|
} finally {
|
|
39
54
|
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
40
55
|
}
|
|
@@ -44,7 +59,7 @@ test("daemon bootstrap requires complete, safe, absolute inputs before binding l
|
|
|
44
59
|
|
|
45
60
|
test("daemon bootstrap activates the exact release through the foreground daemon", async () => {
|
|
46
61
|
const fixture = await createFixture()
|
|
47
|
-
const child = spawnDaemon(fixture, {releaseId: "release-42", revision: "abc123"})
|
|
62
|
+
const child = spawnDaemon(fixture, {attestation: firstAttestation, releaseId: "release-42", revision: "abc123"})
|
|
48
63
|
|
|
49
64
|
try {
|
|
50
65
|
await waitForLog(child, "control socket listening")
|
|
@@ -53,6 +68,12 @@ test("daemon bootstrap activates the exact release through the foreground daemon
|
|
|
53
68
|
|
|
54
69
|
assert.equal(activeRelease.releasePath, fixture.root)
|
|
55
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
|
+
})
|
|
56
77
|
assert.ok(status.proxy && typeof status.proxy === "object" && !Array.isArray(status.proxy) && typeof status.proxy.port === "number")
|
|
57
78
|
assert.equal((await fetch(`http://127.0.0.1:${status.proxy.port}/release`).then((response) => response.text())).trim(), "release-42")
|
|
58
79
|
|
|
@@ -101,6 +122,7 @@ test("plain daemon startup remains listener-only with no active release", async
|
|
|
101
122
|
|
|
102
123
|
assert.equal(status.activeReleaseId, null)
|
|
103
124
|
assert.deepEqual(status.releases, [])
|
|
125
|
+
assert.equal(status.bootstrap, undefined)
|
|
104
126
|
|
|
105
127
|
child.kill("SIGTERM")
|
|
106
128
|
assert.equal((await once(child, "exit"))[0], 0)
|
|
@@ -110,6 +132,47 @@ test("plain daemon startup remains listener-only with no active release", async
|
|
|
110
132
|
}
|
|
111
133
|
})
|
|
112
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
|
+
|
|
113
176
|
test("SIGTERM during bootstrap activation follows the daemon shutdown path", async () => {
|
|
114
177
|
const fixture = await createFixture({healthPath: "/never-ready", healthTimeoutMs: 60000})
|
|
115
178
|
const started = waitForFile(fixture.startedPath)
|
|
@@ -344,11 +407,15 @@ async function waitForFile(filePath) {
|
|
|
344
407
|
|
|
345
408
|
/**
|
|
346
409
|
* @param {{configPath: string, root: string}} fixture - Fixture paths.
|
|
347
|
-
* @param {{releaseId: string, revision: string}} release - Bootstrap metadata.
|
|
410
|
+
* @param {{attestation?: string, releaseId: string, revision: string}} release - Bootstrap metadata.
|
|
348
411
|
* @returns {import("node:child_process").ChildProcessWithoutNullStreams} Spawned daemon.
|
|
349
412
|
*/
|
|
350
413
|
function spawnDaemon(fixture, release) {
|
|
351
|
-
|
|
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"]})
|
|
352
419
|
}
|
|
353
420
|
|
|
354
421
|
/**
|
|
@@ -356,7 +423,15 @@ function spawnDaemon(fixture, release) {
|
|
|
356
423
|
* @returns {Promise<{code: number | null, output: string, stderr: string}>} Completed process result.
|
|
357
424
|
*/
|
|
358
425
|
async function runDaemon(args) {
|
|
359
|
-
|
|
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"]})
|
|
360
435
|
let output = ""
|
|
361
436
|
let stderr = ""
|
|
362
437
|
|
|
@@ -544,6 +544,10 @@ test("does not auto-restart when the restart policy is disabled (maxRestarts: 0)
|
|
|
544
544
|
|
|
545
545
|
test("stops auto-restarting once maxRestarts within the window is reached", async () => {
|
|
546
546
|
const managed = buildCrasher({backoffFactor: 1, maxDelayMs: 0, maxRestarts: 2, windowMs: 60000})
|
|
547
|
+
/** @type {{data: Record<string, import("../src/json.js").JsonValue>, message: string}[]} */
|
|
548
|
+
const events = []
|
|
549
|
+
|
|
550
|
+
managed.logger = (message, data = {}) => { events.push({data, message}) }
|
|
547
551
|
|
|
548
552
|
try {
|
|
549
553
|
await managed.start()
|
|
@@ -554,6 +558,11 @@ test("stops auto-restarting once maxRestarts within the window is reached", asyn
|
|
|
554
558
|
|
|
555
559
|
assert.equal(managed.status().restarts, 2)
|
|
556
560
|
assert.equal(managed.status().state, "failed")
|
|
561
|
+
assert.deepEqual(events.find((event) => event.message === "restart limit reached")?.data, {
|
|
562
|
+
id: "crasher",
|
|
563
|
+
maxRestarts: 2,
|
|
564
|
+
windowMs: 60000
|
|
565
|
+
})
|
|
557
566
|
} finally {
|
|
558
567
|
await managed.stop()
|
|
559
568
|
}
|
package/test/rollbridge.test.js
CHANGED
|
@@ -8,11 +8,11 @@ import net from "node:net"
|
|
|
8
8
|
import os from "node:os"
|
|
9
9
|
import path from "node:path"
|
|
10
10
|
import test from "node:test"
|
|
11
|
-
import {fileURLToPath} from "node:url"
|
|
11
|
+
import {fileURLToPath, pathToFileURL} from "node:url"
|
|
12
12
|
import RollbridgeDaemon from "../src/daemon.js"
|
|
13
13
|
import {normalizeConfig} from "../src/config.js"
|
|
14
14
|
import {sendControlCommand} from "../src/control-client.js"
|
|
15
|
-
import {readState, writeState} from "../src/state-store.js"
|
|
15
|
+
import {liveProcesses, readState, writeState} from "../src/state-store.js"
|
|
16
16
|
import {runCli} from "../src/cli.js"
|
|
17
17
|
|
|
18
18
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
|
@@ -635,6 +635,37 @@ test("persists daemon state to statePath and removes it on a clean shutdown", as
|
|
|
635
635
|
assert.equal(stateAfterShutdown, undefined, "state file removed on clean shutdown")
|
|
636
636
|
})
|
|
637
637
|
|
|
638
|
+
test("persisted daemon state excludes process commands, environment values, and output", async () => {
|
|
639
|
+
const secret = "state-secret-value"
|
|
640
|
+
const fixture = await createFixture({persistState: true})
|
|
641
|
+
const web = fixture.config.processes.find((processConfig) => processConfig.id === "web")
|
|
642
|
+
|
|
643
|
+
assert.ok(web)
|
|
644
|
+
web.env.ROLLBRIDGE_TEST_SECRET = secret
|
|
645
|
+
web.command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(`console.log(process.env.ROLLBRIDGE_TEST_SECRET); import(${JSON.stringify(pathToFileURL(dummyAppPath).href)})`)}`
|
|
646
|
+
|
|
647
|
+
const daemon = await startDaemon(fixture.config)
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
await daemon.deploy({releaseId: "v1", releasePath: fixture.root, revision: "v1"})
|
|
651
|
+
await waitFor(() => Boolean(daemon.activeRelease?.getProcess("web")?.status().logs.some((entry) => entry.line === secret)))
|
|
652
|
+
|
|
653
|
+
daemon.persistState()
|
|
654
|
+
await waitFor(async () => (await fs.readFile(fixture.statePath, "utf8")).includes('"activeReleaseId": "v1"'))
|
|
655
|
+
|
|
656
|
+
const persisted = await fs.readFile(fixture.statePath, "utf8")
|
|
657
|
+
|
|
658
|
+
assert.doesNotMatch(persisted, /state-secret-value/)
|
|
659
|
+
assert.doesNotMatch(persisted, /ROLLBRIDGE_TEST_SECRET/)
|
|
660
|
+
assert.doesNotMatch(persisted, /"command"/)
|
|
661
|
+
assert.doesNotMatch(persisted, /"logs"/)
|
|
662
|
+
assert.deepEqual(liveProcesses(JSON.parse(persisted), () => true).map(({id, releaseId}) => ({id, releaseId})), [{id: "web", releaseId: "v1"}])
|
|
663
|
+
} finally {
|
|
664
|
+
await daemon.shutdown()
|
|
665
|
+
await fs.rm(fixture.root, {force: true, recursive: true})
|
|
666
|
+
}
|
|
667
|
+
})
|
|
668
|
+
|
|
638
669
|
test("a clean shutdown clears the state file even when a persist write is in flight", async () => {
|
|
639
670
|
const fixture = await createFixture({persistState: true})
|
|
640
671
|
const daemon = await startDaemon(fixture.config)
|
|
@@ -1130,6 +1161,7 @@ test("deploy can ensure the daemon before sending the release command", async ()
|
|
|
1130
1161
|
const proxy = /** @type {{port: number}} */ (status.proxy)
|
|
1131
1162
|
|
|
1132
1163
|
assert.equal(status.activeReleaseId, "ensured-v1")
|
|
1164
|
+
assert.equal(status.bootstrap, undefined)
|
|
1133
1165
|
assert.match(await fs.readFile(pidPath, "utf8"), /\d+/)
|
|
1134
1166
|
assert.equal(await fetchTextFromPort(proxy.port, "/release"), "ensured-v1")
|
|
1135
1167
|
} finally {
|