rollbridge 0.1.28 → 0.1.30
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/AGENTS.md +14 -0
- package/README.md +69 -14
- package/TODO.md +5 -2
- package/changelog.d/20260828-atomic-owner-replacement.md +22 -0
- package/changelog.d/20260828-durable-owner-recovery.md +7 -0
- package/changelog.d/20260828-same-owner-jobs-generations.md +6 -0
- package/docs/cli.md +43 -21
- package/docs/config.md +71 -18
- package/docs/logging.md +8 -3
- package/docs/tensorbuzz-runbook.md +7 -6
- package/docs/troubleshooting.md +28 -12
- package/docs/velocious.md +11 -4
- package/docs/workers.md +8 -2
- package/examples/tensorbuzz.com.js +12 -4
- package/package.json +1 -1
- package/src/cli.js +209 -36
- package/src/config.js +8 -2
- package/src/control-client.js +118 -1
- package/src/daemon.js +939 -53
- package/src/guardian-client.js +434 -0
- package/src/managed-process.js +45 -15
- package/src/process-guardian.js +601 -0
- package/src/release-group.js +190 -15
- package/src/state-store.js +1 -1
- package/test/config-validation.test.js +22 -0
- package/test/fixtures/pre-split3-daemon-runner.js +30 -0
- package/test/fixtures/pre-split3-daemon.js +1336 -0
- package/test/fixtures/pre-split3-guardian-client.js +293 -0
- package/test/fixtures/pre-split3-process-guardian.js +292 -0
- package/test/fixtures/service-app.js +32 -2
- package/test/guardian-client.test.js +304 -0
- package/test/owner-recovery.test.js +950 -0
- package/test/owner-replacement.test.js +772 -0
- package/test/release-runtime-retention.test.js +1 -1
- package/test/rollbridge.test.js +178 -5
- package/test/shutdown-completion.test.js +1 -1
- package/test/state-store.test.js +12 -0
|
@@ -0,0 +1,1336 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs/promises"
|
|
4
|
+
import http from "node:http"
|
|
5
|
+
import net from "node:net"
|
|
6
|
+
import crypto from "node:crypto"
|
|
7
|
+
import {isDeepStrictEqual} from "node:util"
|
|
8
|
+
import httpProxy from "http-proxy"
|
|
9
|
+
import {loadConfig} from "../../src/config.js"
|
|
10
|
+
import EventLog from "../../src/event-log.js"
|
|
11
|
+
import GuardianClient from "./pre-split3-guardian-client.js"
|
|
12
|
+
import ReleaseGroup from "../../src/release-group.js"
|
|
13
|
+
import {clearState, isProcessAlive, liveProcesses, readState, writeState} from "../../src/state-store.js"
|
|
14
|
+
import {resolveGroupId, resolveUserId} from "../../src/system-ids.js"
|
|
15
|
+
|
|
16
|
+
const EVENT_HISTORY_LIMIT = 1000
|
|
17
|
+
const STATE_PERSIST_INTERVAL_MS = 5000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {import("./json.js").JsonValue} JsonValue
|
|
21
|
+
* @typedef {{releaseId?: string, releasePath: string, revision?: string}} DeployArgs
|
|
22
|
+
* @typedef {{attestation?: string, releaseId: string, releasePath: string, revision: string}} BootstrapIdentity
|
|
23
|
+
* @typedef {{id: string, process: import("./managed-process.js").ManagedProcessStatus}} ProcessStatus
|
|
24
|
+
* @typedef {{activeReleaseId: string | null, application: string, bootstrap: BootstrapIdentity | undefined, control: import("./config.js").ControlConfig, daemonRuntime: import("./daemon-runtime.js").DaemonRuntimeIdentity | undefined, ownerRecovery: {configDigest: string} | undefined, orphans: {id: string, pid: number, releaseId: string | null}[], proxy: {host: string, port: number | undefined, upstreamHost: string}, releaseReferences: {releaseId: string, releasePath: string}[], releases: import("./release-group.js").ReleaseStatus[], services: ProcessStatus[], singletons: ProcessStatus[]}} DaemonStatus
|
|
25
|
+
* @typedef {{configDigest: string, format: number, guardian: {pid?: number, socketPath: string, token: string}, reconnectGraceMs: number}} OwnerRecoveryMetadata
|
|
26
|
+
* @typedef {DaemonStatus & {recovery: OwnerRecoveryMetadata}} OwnerRecoverySnapshot
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export default class RollbridgeDaemon {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} args - Options.
|
|
32
|
+
* @param {BootstrapIdentity} [args.bootstrap] - Immutable known-release foreground bootstrap identity.
|
|
33
|
+
* @param {import("./config.js").RollbridgeConfig} args.config - Rollbridge config.
|
|
34
|
+
* @param {string} [args.configPath] - Config file path to reload before deploys.
|
|
35
|
+
* @param {(message: string, data?: Record<string, JsonValue>) => void} [args.logger] - Logger.
|
|
36
|
+
* @param {import("./daemon-runtime.js").DaemonRuntimeIdentity} [args.runtime] - Immutable daemon runtime identity.
|
|
37
|
+
*/
|
|
38
|
+
constructor({bootstrap, config, configPath, logger, runtime}) {
|
|
39
|
+
this.bootstrap = bootstrap ? {...bootstrap} : undefined
|
|
40
|
+
this.config = config
|
|
41
|
+
this.configPath = configPath
|
|
42
|
+
this.runtime = runtime
|
|
43
|
+
this.eventLog = new EventLog(EVENT_HISTORY_LIMIT)
|
|
44
|
+
|
|
45
|
+
const baseLogger = logger || ((message, data = {}) => console.log(JSON.stringify({at: new Date().toISOString(), data, message})))
|
|
46
|
+
|
|
47
|
+
// Every operational milestone is logged through this.logger, so recording here
|
|
48
|
+
// gives a structured event history for free (deploys, switches, stops, crashes,
|
|
49
|
+
// restarts, and failed commands).
|
|
50
|
+
this.logger = /** @type {(message: string, data?: Record<string, JsonValue>) => void} */ ((message, data = {}) => {
|
|
51
|
+
this.eventLog.record(message, data)
|
|
52
|
+
baseLogger(message, data)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
this.releases = /** @type {Map<string, ReleaseGroup>} */ (new Map())
|
|
56
|
+
this.services = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
|
|
57
|
+
this.servicePorts = /** @type {Record<string, number>} */ ({})
|
|
58
|
+
this.singletons = /** @type {Map<string, import("./managed-process.js").default>} */ (new Map())
|
|
59
|
+
this.activeRelease = /** @type {ReleaseGroup | undefined} */ (undefined)
|
|
60
|
+
this.proxy = httpProxy.createProxyServer({ws: true, xfwd: true})
|
|
61
|
+
this.proxyServer = /** @type {http.Server | undefined} */ (undefined)
|
|
62
|
+
this.controlServer = /** @type {net.Server | undefined} */ (undefined)
|
|
63
|
+
this.controlSocketOwned = false
|
|
64
|
+
this.controlSockets = /** @type {Set<net.Socket>} */ (new Set())
|
|
65
|
+
this.proxyPort = /** @type {number | undefined} */ (undefined)
|
|
66
|
+
this.stopping = false
|
|
67
|
+
this.statePath = config.statePath
|
|
68
|
+
this.persistTimer = /** @type {ReturnType<typeof setInterval> | undefined} */ (undefined)
|
|
69
|
+
this.persistenceEnabled = false
|
|
70
|
+
this.pendingWrite = /** @type {Promise<void> | undefined} */ (undefined)
|
|
71
|
+
this.stateCleanupEnabled = false
|
|
72
|
+
this.shutdownPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
73
|
+
this.retirementPromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
74
|
+
this.controlClosePromise = /** @type {Promise<void> | undefined} */ (undefined)
|
|
75
|
+
this.startingReleases = /** @type {Set<ReleaseGroup>} */ (new Set())
|
|
76
|
+
this.guardian = /** @type {GuardianClient | undefined} */ (undefined)
|
|
77
|
+
this.guardianIdentity = /** @type {{pid?: number, socketPath: string, token: string} | undefined} */ (undefined)
|
|
78
|
+
// Still-alive managed processes left by a previous daemon (from statePath), captured at
|
|
79
|
+
// startup and surfaced in status(). The daemon cannot re-manage them, only report them.
|
|
80
|
+
this.orphans = /** @type {{id: string, pid: number, releaseId: string | null}[]} */ ([])
|
|
81
|
+
|
|
82
|
+
this.proxy.on("error", (error, req, res) => this.onProxyError(error, req, res))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Starts daemon listeners.
|
|
87
|
+
* @param {{exposeControl?: boolean, reportOrphans?: boolean}} [options] - Listener startup options.
|
|
88
|
+
* @returns {Promise<void>} Resolves when the requested listeners are ready.
|
|
89
|
+
*/
|
|
90
|
+
async start({exposeControl = true, reportOrphans = true} = {}) {
|
|
91
|
+
if (this.config.ownerRecovery) await this.initializeOwnerRecovery()
|
|
92
|
+
else if (reportOrphans) await this.reportOrphans()
|
|
93
|
+
await this.startProxy()
|
|
94
|
+
if (exposeControl) await this.exposeControl()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Connects to the durable process guardian and reconstructs a matching persisted owner snapshot. */
|
|
98
|
+
async initializeOwnerRecovery() {
|
|
99
|
+
if (!this.statePath) throw new Error("ownerRecovery requires statePath")
|
|
100
|
+
const state = await readState(this.statePath)
|
|
101
|
+
const snapshot = state && typeof state === "object" && !Array.isArray(state) ? /** @type {OwnerRecoverySnapshot} */ (state) : undefined
|
|
102
|
+
const recovery = snapshot?.recovery
|
|
103
|
+
const configDigest = this.ownerRecoveryConfigDigest()
|
|
104
|
+
|
|
105
|
+
if (snapshot && !recovery) throw new Error(`Owner recovery state ${this.statePath} is missing durable guardian identity; refusing to overwrite it.`)
|
|
106
|
+
if (recovery && recovery.configDigest !== configDigest) throw new Error("Owner recovery config identity does not match the persisted owner; refusing cross-authority adoption.")
|
|
107
|
+
if (snapshot && ((this.runtime?.digest ?? null) !== (snapshot.daemonRuntime?.digest ?? null))) {
|
|
108
|
+
throw new Error("Owner recovery runtime identity does not match the persisted owner; use the exact same Rollbridge runtime.")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const guardianIdentity = recovery?.guardian || {
|
|
112
|
+
socketPath: `${this.statePath}.guardian.sock`,
|
|
113
|
+
token: crypto.randomBytes(32).toString("hex")
|
|
114
|
+
}
|
|
115
|
+
this.guardianIdentity = guardianIdentity
|
|
116
|
+
this.guardian = new GuardianClient(guardianIdentity)
|
|
117
|
+
if (recovery) await this.guardian.connect()
|
|
118
|
+
else {
|
|
119
|
+
await this.guardian.launch()
|
|
120
|
+
guardianIdentity.pid = this.guardian.pid
|
|
121
|
+
}
|
|
122
|
+
await this.guardian.claimOwner(this.config.ownerRecovery?.reconnectGraceMs ?? 30000)
|
|
123
|
+
|
|
124
|
+
if (snapshot) {
|
|
125
|
+
await this.restoreOwnerState(snapshot)
|
|
126
|
+
await this.guardian.reconcileInventory()
|
|
127
|
+
for (const release of this.releases.values()) {
|
|
128
|
+
if (release.state === "draining") void this.drainAndPrune(release, this.config)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
this.persistenceEnabled = true
|
|
133
|
+
await this.persistState({throwOnError: true})
|
|
134
|
+
}
|
|
135
|
+
this.stateCleanupEnabled = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @returns {string} Stable identity for same-authority recovery. */
|
|
139
|
+
ownerRecoveryConfigDigest() {
|
|
140
|
+
return crypto.createHash("sha256").update(JSON.stringify(this.config)).digest("hex")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @param {OwnerRecoverySnapshot} snapshot - Validated persisted owner state. */
|
|
144
|
+
async restoreOwnerState(snapshot) {
|
|
145
|
+
if (!Array.isArray(snapshot.releases) || (snapshot.activeReleaseId !== null && typeof snapshot.activeReleaseId !== "string")) {
|
|
146
|
+
throw new Error("Owner recovery state is partial or corrupt; active release metadata is required.")
|
|
147
|
+
}
|
|
148
|
+
if (snapshot.activeReleaseId === null && snapshot.releases.length === 0) return
|
|
149
|
+
this.bootstrap = snapshot.bootstrap ? {...snapshot.bootstrap} : undefined
|
|
150
|
+
|
|
151
|
+
for (const releaseStatus of snapshot.releases) {
|
|
152
|
+
if (releaseStatus.state !== "active" && releaseStatus.state !== "draining") continue
|
|
153
|
+
const release = new ReleaseGroup({
|
|
154
|
+
config: this.config,
|
|
155
|
+
logger: this.logger,
|
|
156
|
+
processFactory: (key, definition) => this.guardianProcess(key, definition),
|
|
157
|
+
releaseId: releaseStatus.releaseId,
|
|
158
|
+
releasePath: releaseStatus.releasePath,
|
|
159
|
+
revision: releaseStatus.revision,
|
|
160
|
+
servicePorts: this.servicePorts,
|
|
161
|
+
shouldStart: () => !this.stopping
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await release.restore(releaseStatus)
|
|
165
|
+
this.releases.set(release.releaseId, release)
|
|
166
|
+
if (release.releaseId === snapshot.activeReleaseId) this.activeRelease = release
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (snapshot.activeReleaseId !== null && !this.activeRelease) throw new Error(`Owner recovery state does not contain active release ${snapshot.activeReleaseId}.`)
|
|
170
|
+
const definitionRelease = this.activeRelease || [...this.releases.values()].at(-1)
|
|
171
|
+
if (!definitionRelease) throw new Error("Owner recovery state has no release definition for owned processes.")
|
|
172
|
+
if (!this.activeRelease && snapshot.singletons.length > 0) throw new Error("Owner recovery state has release-owned singletons without an active release identity.")
|
|
173
|
+
for (const serviceStatus of snapshot.services) {
|
|
174
|
+
const processConfig = this.config.processes.find((candidate) => candidate.id === serviceStatus.id && candidate.policy === "service" && candidate.deployStrategy !== "handoff")
|
|
175
|
+
|
|
176
|
+
if (!processConfig) throw new Error(`Owner recovery state contains unknown service ${serviceStatus.id}.`)
|
|
177
|
+
const service = definitionRelease.buildProcess(processConfig, {guardianKey: `service:${serviceStatus.id}`, shouldRestart: () => !this.stopping})
|
|
178
|
+
|
|
179
|
+
await this.recoverGuardianProcess(service)
|
|
180
|
+
this.services.set(serviceStatus.id, service)
|
|
181
|
+
if (definitionRelease.ports[serviceStatus.id] !== undefined) this.servicePorts[serviceStatus.id] = definitionRelease.ports[serviceStatus.id]
|
|
182
|
+
}
|
|
183
|
+
for (const singletonStatus of snapshot.singletons) {
|
|
184
|
+
const processConfig = this.config.processes.find((candidate) => candidate.id === singletonStatus.id && candidate.policy === "singleton")
|
|
185
|
+
|
|
186
|
+
if (!processConfig) throw new Error(`Owner recovery state contains unknown singleton ${singletonStatus.id}.`)
|
|
187
|
+
const singleton = definitionRelease.buildProcess(processConfig, {guardianKey: `singleton:${definitionRelease.releaseId}:${singletonStatus.id}`})
|
|
188
|
+
|
|
189
|
+
await this.recoverGuardianProcess(singleton)
|
|
190
|
+
this.singletons.set(singletonStatus.id, singleton)
|
|
191
|
+
}
|
|
192
|
+
this.logger("owner state recovered", {activeReleaseId: this.activeRelease?.releaseId ?? null, releases: this.releases.size})
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {string} key - Guardian key.
|
|
197
|
+
* @param {Parameters<GuardianClient["process"]>[1]} definition - Process definition.
|
|
198
|
+
* @returns {import("./managed-process.js").default} Guardian-backed managed process.
|
|
199
|
+
*/
|
|
200
|
+
guardianProcess(key, definition) {
|
|
201
|
+
if (!this.guardian) throw new Error("Process guardian is not initialized")
|
|
202
|
+
return this.guardian.process(key, definition)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @param {import("./managed-process.js").default} processInstance - Guardian-backed process. */
|
|
206
|
+
async recoverGuardianProcess(processInstance) {
|
|
207
|
+
if (!("recover" in processInstance) || typeof processInstance.recover !== "function") throw new Error(`Managed process ${processInstance.id} is not guardian-backed`)
|
|
208
|
+
await processInstance.recover()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Releases only resources created by a fenced startup loser. */
|
|
212
|
+
async abandonOwnerRecoveryAttempt() {
|
|
213
|
+
this.guardian?.disconnect()
|
|
214
|
+
await this.closeServer(this.proxyServer)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @returns {Promise<void>} Exposes control commands and begins periodic state persistence. */
|
|
218
|
+
async exposeControl() {
|
|
219
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
220
|
+
|
|
221
|
+
await this.startControlServer()
|
|
222
|
+
|
|
223
|
+
if (this.stopping) {
|
|
224
|
+
await this.closeServer(this.controlServer)
|
|
225
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
226
|
+
throw new Error("Rollbridge is shutting down")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.startStatePersistence()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @returns {Promise<void>} Starts the stable local proxy. */
|
|
233
|
+
async startProxy() {
|
|
234
|
+
const server = http.createServer((request, response) => this.proxyHttp(request, response))
|
|
235
|
+
|
|
236
|
+
server.on("upgrade", (request, socket, head) => this.proxyWebSocket(request, socket, head))
|
|
237
|
+
this.proxyServer = server
|
|
238
|
+
|
|
239
|
+
await new Promise((resolve, reject) => {
|
|
240
|
+
server.once("error", reject)
|
|
241
|
+
server.listen(this.config.proxy.port, this.config.proxy.host, () => {
|
|
242
|
+
const address = server.address()
|
|
243
|
+
this.proxyPort = address && typeof address === "object" ? address.port : this.config.proxy.port
|
|
244
|
+
this.logger("proxy listening", {host: this.config.proxy.host, port: this.proxyPort})
|
|
245
|
+
resolve(undefined)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @returns {Promise<void>} Starts the control socket. */
|
|
251
|
+
async startControlServer() {
|
|
252
|
+
const server = net.createServer((socket) => this.handleControlSocket(socket))
|
|
253
|
+
|
|
254
|
+
this.controlServer = server
|
|
255
|
+
await this.prepareControlSocketPath()
|
|
256
|
+
|
|
257
|
+
await new Promise((resolve, reject) => {
|
|
258
|
+
server.once("error", reject)
|
|
259
|
+
server.listen(this.config.control.path, () => {
|
|
260
|
+
this.controlSocketOwned = true
|
|
261
|
+
this.logger("control socket listening", {path: this.config.control.path})
|
|
262
|
+
resolve(undefined)
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
if (this.config.control.mode !== undefined) {
|
|
267
|
+
await fs.chmod(this.config.control.path, this.config.control.mode)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await this.applyControlSocketOwnership()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Applies control.owner/control.group to the bound socket via chown, resolving names to ids.
|
|
275
|
+
* @returns {Promise<void>} Resolves once ownership is applied (no-op when neither is set).
|
|
276
|
+
*/
|
|
277
|
+
async applyControlSocketOwnership() {
|
|
278
|
+
const {group, owner, path: socketPath} = this.config.control
|
|
279
|
+
|
|
280
|
+
if (owner === undefined && group === undefined) return
|
|
281
|
+
|
|
282
|
+
// -1 leaves the uid/gid unchanged (POSIX chown semantics).
|
|
283
|
+
const uid = owner === undefined ? -1 : resolveUserId(owner)
|
|
284
|
+
const gid = group === undefined ? -1 : resolveGroupId(group)
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
await fs.chown(socketPath, uid, gid)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
290
|
+
|
|
291
|
+
throw new Error(`Could not set control socket owner/group on ${socketPath}: ${reason}. Run the daemon as a user allowed to chown it (for example root, or a member of the target group).`, {cause: error})
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @returns {Promise<void>} Removes a stale Unix socket before binding, or fails clearly when a daemon is alive. */
|
|
296
|
+
async prepareControlSocketPath() {
|
|
297
|
+
const existing = await inspectControlSocket(this.config.control.path)
|
|
298
|
+
|
|
299
|
+
if (existing.alive) {
|
|
300
|
+
throw new Error(controlSocketBusyMessage(this.config.control.path, existing))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return
|
|
307
|
+
throw error
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {http.IncomingMessage} request - Client request.
|
|
313
|
+
* @param {http.ServerResponse} response - Client response.
|
|
314
|
+
* @returns {void}
|
|
315
|
+
*/
|
|
316
|
+
proxyHttp(request, response) {
|
|
317
|
+
const release = this.activeRelease
|
|
318
|
+
|
|
319
|
+
if (!release) {
|
|
320
|
+
response.writeHead(503, {"Content-Type": "text/plain; charset=utf-8"})
|
|
321
|
+
response.end("No active release\n")
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const {target} = release.proxyTarget()
|
|
326
|
+
const releaseConnection = release.retainConnection("http")
|
|
327
|
+
let released = false
|
|
328
|
+
const done = () => {
|
|
329
|
+
if (released) return
|
|
330
|
+
|
|
331
|
+
released = true
|
|
332
|
+
releaseConnection()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
response.once("finish", done)
|
|
336
|
+
response.once("close", done)
|
|
337
|
+
this.proxy.web(request, response, {target})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {http.IncomingMessage} request - Client request.
|
|
342
|
+
* @param {import("node:stream").Duplex} socket - Client socket.
|
|
343
|
+
* @param {Buffer} head - Upgrade head.
|
|
344
|
+
* @returns {void}
|
|
345
|
+
*/
|
|
346
|
+
proxyWebSocket(request, socket, head) {
|
|
347
|
+
const release = this.activeRelease
|
|
348
|
+
|
|
349
|
+
if (!release) {
|
|
350
|
+
socket.end("HTTP/1.1 503 Service Unavailable\r\n\r\n")
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const {target} = release.proxyTarget()
|
|
355
|
+
const releaseConnection = release.retainConnection("websocket")
|
|
356
|
+
socket.once("close", releaseConnection)
|
|
357
|
+
this.proxy.ws(request, socket, head, {target})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {Error} error - Proxy error.
|
|
362
|
+
* @param {http.IncomingMessage} _request - Client request.
|
|
363
|
+
* @param {http.ServerResponse | import("node:net").Socket} response - Response or socket.
|
|
364
|
+
* @returns {void}
|
|
365
|
+
*/
|
|
366
|
+
onProxyError(error, _request, response) {
|
|
367
|
+
this.logger("proxy error", {error: error.message})
|
|
368
|
+
|
|
369
|
+
if ("writeHead" in response && !response.headersSent) {
|
|
370
|
+
response.writeHead(502, {"Content-Type": "text/plain; charset=utf-8"})
|
|
371
|
+
response.end("Bad gateway\n")
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if ("destroy" in response) {
|
|
376
|
+
response.destroy()
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* @param {import("node:net").Socket} socket - Control socket.
|
|
382
|
+
* @returns {void}
|
|
383
|
+
*/
|
|
384
|
+
handleControlSocket(socket) {
|
|
385
|
+
this.controlSockets.add(socket)
|
|
386
|
+
socket.setEncoding("utf8")
|
|
387
|
+
let buffer = ""
|
|
388
|
+
|
|
389
|
+
socket.once("close", () => this.controlSockets.delete(socket))
|
|
390
|
+
socket.on("error", (error) => {
|
|
391
|
+
const code = error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : null
|
|
392
|
+
|
|
393
|
+
this.logger("control connection error", {code, error: error.message})
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
socket.on("data", (chunk) => {
|
|
397
|
+
buffer += chunk
|
|
398
|
+
let newlineIndex = buffer.indexOf("\n")
|
|
399
|
+
|
|
400
|
+
while (newlineIndex >= 0) {
|
|
401
|
+
const line = buffer.slice(0, newlineIndex)
|
|
402
|
+
buffer = buffer.slice(newlineIndex + 1)
|
|
403
|
+
this.handleControlLine(line, socket)
|
|
404
|
+
newlineIndex = buffer.indexOf("\n")
|
|
405
|
+
}
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* @param {string} line - JSON command line.
|
|
411
|
+
* @param {import("node:net").Socket} socket - Control socket.
|
|
412
|
+
* @returns {void}
|
|
413
|
+
*/
|
|
414
|
+
handleControlLine(line, socket) {
|
|
415
|
+
const closesConnection = isShutdownControlLine(line)
|
|
416
|
+
const respond = (/** @type {Record<string, JsonValue>} */ response) => {
|
|
417
|
+
const payload = `${JSON.stringify(response)}\n`
|
|
418
|
+
|
|
419
|
+
if (closesConnection) {
|
|
420
|
+
socket.end(payload, () => socket.destroy())
|
|
421
|
+
} else if (!socket.destroyed) {
|
|
422
|
+
socket.write(payload)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
this.executeControlLine(line, socket)
|
|
427
|
+
.then((response) => respond({status: "success", ...response}))
|
|
428
|
+
.catch((error) => {
|
|
429
|
+
this.logger("command failed", {error: error instanceof Error ? error.message : String(error)})
|
|
430
|
+
respond({
|
|
431
|
+
error: error instanceof Error ? error.message : String(error),
|
|
432
|
+
status: "error"
|
|
433
|
+
})
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {string} line - JSON command line.
|
|
439
|
+
* @param {net.Socket} [controlSocket] - Requesting control connection, used only for shutdown completion.
|
|
440
|
+
* @returns {Promise<Record<string, JsonValue>>} Command response.
|
|
441
|
+
*/
|
|
442
|
+
async executeControlLine(line, controlSocket) {
|
|
443
|
+
const command = JSON.parse(line)
|
|
444
|
+
|
|
445
|
+
if (!command || typeof command !== "object") {
|
|
446
|
+
throw new Error("Control command must be an object")
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const data = /** @type {Record<string, JsonValue>} */ (command)
|
|
450
|
+
const commandName = data.command
|
|
451
|
+
|
|
452
|
+
if (commandName === "deploy") {
|
|
453
|
+
return await this.deploy({
|
|
454
|
+
releaseId: stringOrUndefined(data.releaseId),
|
|
455
|
+
releasePath: requiredString(data.releasePath, "releasePath"),
|
|
456
|
+
revision: stringOrUndefined(data.revision)
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (commandName === "status") {
|
|
461
|
+
return this.status()
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (commandName === "events") {
|
|
465
|
+
return {events: this.eventLog.recent(typeof data.limit === "number" ? data.limit : undefined)}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (commandName === "stop") {
|
|
469
|
+
await this.stopRelease(stringOrUndefined(data.releaseId))
|
|
470
|
+
return this.status()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (commandName === "restart") {
|
|
474
|
+
return await this.restartProcesses({
|
|
475
|
+
policy: stringOrUndefined(data.policy),
|
|
476
|
+
processId: stringOrUndefined(data.processId)
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (commandName === "rollback") {
|
|
481
|
+
return await this.rollback({releaseId: stringOrUndefined(data.releaseId)})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (commandName === "shutdown") {
|
|
485
|
+
// Stop accepting new control connections before cleanup, but keep this requesting
|
|
486
|
+
// connection open as the completion channel. Waiting for all control connections here
|
|
487
|
+
// would deadlock: server.close() includes the socket awaiting this response.
|
|
488
|
+
await this.shutdown({completionSocket: controlSocket, waitForControlConnections: false})
|
|
489
|
+
|
|
490
|
+
return {message: "shutdown"}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (commandName === "retire-owner") {
|
|
494
|
+
const attestation = requiredString(data.attestation, "attestation")
|
|
495
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(attestation)) throw new Error("Owner retirement attestation must use the canonical sha256:<64 lowercase hex> format")
|
|
496
|
+
await this.retireOwner({attestation, completionSocket: controlSocket})
|
|
497
|
+
return {message: "owner retired"}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
throw new Error(`Unknown command: ${String(commandName)}`)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Starts a new release, switches traffic, and drains the previous release.
|
|
505
|
+
* @param {DeployArgs} args - Deploy args.
|
|
506
|
+
* @returns {Promise<Record<string, JsonValue>>} Deploy result.
|
|
507
|
+
*/
|
|
508
|
+
async deploy({releaseId, releasePath, revision}) {
|
|
509
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
510
|
+
|
|
511
|
+
const nextConfig = this.configPath ? await loadConfig(this.configPath) : this.config
|
|
512
|
+
|
|
513
|
+
this.assertReloadCompatible(nextConfig)
|
|
514
|
+
|
|
515
|
+
const newReleaseId = releaseId || revision || new Date().toISOString().replace(/[^0-9]/g, "")
|
|
516
|
+
const release = new ReleaseGroup({
|
|
517
|
+
config: nextConfig,
|
|
518
|
+
logger: this.logger,
|
|
519
|
+
...(this.guardian ? {processFactory: (key, definition) => this.guardianProcess(key, definition)} : {}),
|
|
520
|
+
releaseId: newReleaseId,
|
|
521
|
+
releasePath,
|
|
522
|
+
revision,
|
|
523
|
+
servicePorts: this.servicePorts,
|
|
524
|
+
shouldStart: () => !this.stopping
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
this.logger("deploy starting", {releaseId: newReleaseId, releasePath, revision})
|
|
528
|
+
const startedServices = /** @type {string[]} */ ([])
|
|
529
|
+
|
|
530
|
+
this.startingReleases.add(release)
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
await this.ensureServices(release, startedServices)
|
|
534
|
+
await release.start()
|
|
535
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
536
|
+
} catch (error) {
|
|
537
|
+
this.logger("deploy failed", {error: error instanceof Error ? error.message : String(error), releaseId: newReleaseId})
|
|
538
|
+
await release.stop()
|
|
539
|
+
await this.stopStartedServices(startedServices)
|
|
540
|
+
throw error
|
|
541
|
+
} finally {
|
|
542
|
+
this.startingReleases.delete(release)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const previousRelease = this.activeRelease
|
|
546
|
+
|
|
547
|
+
this.config = nextConfig
|
|
548
|
+
this.releases.set(release.releaseId, release)
|
|
549
|
+
release.activate()
|
|
550
|
+
this.activeRelease = release
|
|
551
|
+
this.logger("traffic switched", {previousReleaseId: previousRelease ? previousRelease.releaseId : null, releaseId: release.releaseId})
|
|
552
|
+
|
|
553
|
+
this.refreshServiceDefinitions(release)
|
|
554
|
+
let retirementFailure
|
|
555
|
+
|
|
556
|
+
if (previousRelease) {
|
|
557
|
+
try {
|
|
558
|
+
await previousRelease.beginRetirement(nextConfig)
|
|
559
|
+
void this.drainAndPrune(previousRelease, nextConfig)
|
|
560
|
+
} catch (error) {
|
|
561
|
+
retirementFailure = previousRelease.retirementError ?? (error instanceof Error ? error.message : String(error))
|
|
562
|
+
this.logger("release retirement quiescence failed", {error: retirementFailure, releaseId: previousRelease.releaseId})
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await this.replaceSingletons(release)
|
|
567
|
+
|
|
568
|
+
await this.persistState()
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
activeReleaseId: release.releaseId,
|
|
572
|
+
previousReleaseId: previousRelease ? previousRelease.releaseId : null,
|
|
573
|
+
...(retirementFailure && previousRelease ? {retirement: {error: retirementFailure, releaseId: previousRelease.releaseId, status: "quiescence_failed"}} : {})
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Rejects config changes that require rebinding daemon-owned resources or changing process topology.
|
|
579
|
+
* @param {import("./config.js").RollbridgeConfig} nextConfig - Freshly loaded config.
|
|
580
|
+
* @returns {void}
|
|
581
|
+
*/
|
|
582
|
+
assertReloadCompatible(nextConfig) {
|
|
583
|
+
/** @type {string[]} */
|
|
584
|
+
const restartRequired = []
|
|
585
|
+
|
|
586
|
+
if (nextConfig.application !== this.config.application) restartRequired.push("application")
|
|
587
|
+
if (!isDeepStrictEqual(nextConfig.control, this.config.control)) restartRequired.push("control")
|
|
588
|
+
if (nextConfig.statePath !== this.config.statePath) restartRequired.push("statePath")
|
|
589
|
+
if (!isDeepStrictEqual(nextConfig.ownerRecovery, this.config.ownerRecovery)) restartRequired.push("ownerRecovery")
|
|
590
|
+
|
|
591
|
+
if (nextConfig.proxy.host !== this.config.proxy.host) restartRequired.push("proxy.host")
|
|
592
|
+
if (nextConfig.proxy.port !== this.config.proxy.port) restartRequired.push("proxy.port")
|
|
593
|
+
if (nextConfig.proxy.upstreamHost !== this.config.proxy.upstreamHost) restartRequired.push("proxy.upstreamHost")
|
|
594
|
+
|
|
595
|
+
if (nextConfig.processes.length !== this.config.processes.length) {
|
|
596
|
+
restartRequired.push("processes")
|
|
597
|
+
} else {
|
|
598
|
+
for (const processConfig of this.config.processes) {
|
|
599
|
+
const nextProcessConfig = nextConfig.processes.find((candidate) => candidate.id === processConfig.id)
|
|
600
|
+
|
|
601
|
+
if (!nextProcessConfig ||
|
|
602
|
+
nextProcessConfig.policy !== processConfig.policy ||
|
|
603
|
+
nextProcessConfig.deployStrategy !== processConfig.deployStrategy ||
|
|
604
|
+
nextProcessConfig.replicas !== processConfig.replicas ||
|
|
605
|
+
!isDeepStrictEqual(nextProcessConfig.port, processConfig.port)) {
|
|
606
|
+
restartRequired.push("processes")
|
|
607
|
+
break
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (restartRequired.length > 0) {
|
|
613
|
+
throw new Error(`Config changes to ${restartRequired.join(", ")} cannot be applied live; restart the Rollbridge daemon before deploying.`)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Rolls back to a previously-active release by re-running the deploy flow on its
|
|
619
|
+
* retained metadata: it re-starts the target release, health-checks it, switches
|
|
620
|
+
* traffic, replaces singletons, and drains the current release — just like a deploy,
|
|
621
|
+
* so a failed rollback leaves the current release active.
|
|
622
|
+
* @param {{releaseId?: string}} [args] - Target release id; defaults to the most recently retired release.
|
|
623
|
+
* @returns {Promise<Record<string, JsonValue>>} The rollback result.
|
|
624
|
+
*/
|
|
625
|
+
async rollback({releaseId} = {}) {
|
|
626
|
+
const target = releaseId ? this.releases.get(releaseId) : this.previousRelease()
|
|
627
|
+
|
|
628
|
+
if (!target) {
|
|
629
|
+
throw new Error(releaseId ? `No retained release "${releaseId}" to roll back to.` : "No previous release to roll back to.")
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (target === this.activeRelease) {
|
|
633
|
+
throw new Error(`Release "${target.releaseId}" is already active.`)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// The target may still be draining a prior deploy (live processes). Stop it before the
|
|
637
|
+
// deploy below re-uses its id in this.releases, otherwise the still-running instance
|
|
638
|
+
// would be dropped from status/pruning/shutdown and could be orphaned.
|
|
639
|
+
if (target.state !== "stopped" && target.state !== "failed") {
|
|
640
|
+
await target.stop()
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
this.logger("rollback starting", {releaseId: target.releaseId, releasePath: target.releasePath})
|
|
644
|
+
|
|
645
|
+
return await this.deploy({releaseId: target.releaseId, releasePath: target.releasePath, revision: target.revision})
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* @returns {ReleaseGroup | undefined} The most recently active release other than the current one, if any.
|
|
650
|
+
*/
|
|
651
|
+
previousRelease() {
|
|
652
|
+
/** @type {ReleaseGroup | undefined} */
|
|
653
|
+
let previous
|
|
654
|
+
|
|
655
|
+
for (const release of this.releases.values()) {
|
|
656
|
+
if (release === this.activeRelease || !release.activatedAt) continue
|
|
657
|
+
if (!previous || Date.parse(release.activatedAt) >= Date.parse(/** @type {string} */ (previous.activatedAt))) previous = release
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return previous
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Starts missing daemon-wide services before release-owned processes need them.
|
|
665
|
+
* @param {ReleaseGroup} release - Release providing templates and ports.
|
|
666
|
+
* @param {string[]} startedServices - Service ids started by this deploy.
|
|
667
|
+
* @returns {Promise<void>} Resolves when missing services are running.
|
|
668
|
+
*/
|
|
669
|
+
async ensureServices(release, startedServices) {
|
|
670
|
+
await release.allocatePorts()
|
|
671
|
+
|
|
672
|
+
for (const processConfig of release.config.processes) {
|
|
673
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
674
|
+
if (processConfig.policy !== "service" || processConfig.deployStrategy === "handoff") continue
|
|
675
|
+
if (this.services.has(processConfig.id)) continue
|
|
676
|
+
|
|
677
|
+
const service = release.buildProcess(processConfig, {guardianKey: `service:${processConfig.id}`, shouldRestart: () => !this.stopping})
|
|
678
|
+
|
|
679
|
+
this.services.set(processConfig.id, service)
|
|
680
|
+
|
|
681
|
+
if (release.ports[processConfig.id] !== undefined) {
|
|
682
|
+
this.servicePorts[processConfig.id] = release.ports[processConfig.id]
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
try {
|
|
686
|
+
await service.start("deploy")
|
|
687
|
+
startedServices.push(processConfig.id)
|
|
688
|
+
} catch (error) {
|
|
689
|
+
this.services.delete(processConfig.id)
|
|
690
|
+
delete this.servicePorts[processConfig.id]
|
|
691
|
+
throw error
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Stops services that were started for a failed deploy.
|
|
700
|
+
* @param {string[]} startedServices - Service ids started by the failed deploy.
|
|
701
|
+
* @returns {Promise<void>} Resolves when cleanup finishes.
|
|
702
|
+
*/
|
|
703
|
+
async stopStartedServices(startedServices) {
|
|
704
|
+
for (const serviceId of startedServices) {
|
|
705
|
+
const service = this.services.get(serviceId)
|
|
706
|
+
|
|
707
|
+
if (!service) continue
|
|
708
|
+
|
|
709
|
+
await service.stop()
|
|
710
|
+
this.services.delete(serviceId)
|
|
711
|
+
delete this.servicePorts[serviceId]
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Updates daemon-wide service restart templates after a successful deploy.
|
|
717
|
+
* @param {ReleaseGroup} release - Active release.
|
|
718
|
+
* @returns {void}
|
|
719
|
+
*/
|
|
720
|
+
refreshServiceDefinitions(release) {
|
|
721
|
+
for (const processConfig of this.config.processes) {
|
|
722
|
+
if (processConfig.policy !== "service") continue
|
|
723
|
+
|
|
724
|
+
const service = this.services.get(processConfig.id)
|
|
725
|
+
|
|
726
|
+
if (!service) continue
|
|
727
|
+
|
|
728
|
+
const nextDefinition = release.buildProcess(processConfig, {shouldRestart: () => !this.stopping})
|
|
729
|
+
|
|
730
|
+
service.updateDefinition(nextDefinition)
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Restarts singleton processes for the new release without overlapping old singleton processes.
|
|
736
|
+
* @param {ReleaseGroup} release - Active release.
|
|
737
|
+
* @returns {Promise<void>} Resolves when singletons have been replaced.
|
|
738
|
+
*/
|
|
739
|
+
async replaceSingletons(release) {
|
|
740
|
+
for (const processConfig of this.config.processes) {
|
|
741
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
742
|
+
if (processConfig.policy !== "singleton") continue
|
|
743
|
+
|
|
744
|
+
const previous = this.singletons.get(processConfig.id)
|
|
745
|
+
|
|
746
|
+
if (previous) {
|
|
747
|
+
await previous.stop()
|
|
748
|
+
if (this.stopping) throw new Error("Rollbridge is shutting down")
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const singleton = release.buildProcess(processConfig, {guardianKey: `singleton:${release.releaseId}:${processConfig.id}`})
|
|
752
|
+
|
|
753
|
+
this.singletons.set(processConfig.id, singleton)
|
|
754
|
+
await singleton.start("deploy")
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Restarts non-proxied processes selected by id or policy, or all of them: running
|
|
760
|
+
* processes are bounced (stop then start) and crashed or stopped ones are revived,
|
|
761
|
+
* matching the conventional meaning of "restart".
|
|
762
|
+
*
|
|
763
|
+
* The proxied process is never restarted in place (that would drop traffic); use a
|
|
764
|
+
* deploy for a zero-downtime replacement.
|
|
765
|
+
* @param {{policy?: string, processId?: string}} selector - Restart selector; restarts all non-proxied processes when both are omitted.
|
|
766
|
+
* @returns {Promise<Record<string, JsonValue>>} The ids that were restarted.
|
|
767
|
+
*/
|
|
768
|
+
async restartProcesses({policy, processId} = {}) {
|
|
769
|
+
if (policy === "proxied" || (processId !== undefined && this.isProxiedId(processId))) {
|
|
770
|
+
throw new Error('The proxied process cannot be restarted in place; use "rollbridge deploy" for a zero-downtime replacement.')
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const targets = this.collectRestartTargets({policy, processId})
|
|
774
|
+
|
|
775
|
+
if (processId !== undefined && targets.length === 0) {
|
|
776
|
+
throw new Error(`No managed process with id "${processId}" to restart.`)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
for (const target of targets) {
|
|
780
|
+
this.logger("process restart requested", {processId: target.id})
|
|
781
|
+
await target.process.stop()
|
|
782
|
+
await target.process.start("manual")
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return {restarted: targets.map((target) => target.id)}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* @param {{policy?: string, processId?: string}} selector - Restart selector.
|
|
790
|
+
* @returns {{id: string, process: import("./managed-process.js").default}[]} Running non-proxied processes matching the selector.
|
|
791
|
+
*/
|
|
792
|
+
collectRestartTargets({policy, processId}) {
|
|
793
|
+
const targets = /** @type {{id: string, process: import("./managed-process.js").default}[]} */ ([])
|
|
794
|
+
|
|
795
|
+
for (const processConfig of this.config.processes) {
|
|
796
|
+
if (processConfig.policy === "proxied") continue
|
|
797
|
+
if (policy !== undefined && processConfig.policy !== policy) continue
|
|
798
|
+
|
|
799
|
+
for (const instance of this.runningInstances(processConfig)) {
|
|
800
|
+
// A processId selector matches the base config id (all replicas) or one replica's id.
|
|
801
|
+
if (processId !== undefined && processId !== processConfig.id && processId !== instance.id) continue
|
|
802
|
+
|
|
803
|
+
targets.push(instance)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return targets
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* @param {import("./config.js").ProcessConfig} processConfig - Process definition.
|
|
812
|
+
* @returns {{id: string, process: import("./managed-process.js").default}[]} Running instances (replicas) for this config.
|
|
813
|
+
*/
|
|
814
|
+
runningInstances(processConfig) {
|
|
815
|
+
if (processConfig.policy === "service") {
|
|
816
|
+
const service = this.services.get(processConfig.id)
|
|
817
|
+
|
|
818
|
+
return service ? [{id: processConfig.id, process: service}] : []
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (processConfig.policy === "singleton") {
|
|
822
|
+
const singleton = this.singletons.get(processConfig.id)
|
|
823
|
+
|
|
824
|
+
return singleton ? [{id: processConfig.id, process: singleton}] : []
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return this.activeRelease ? this.activeRelease.getProcesses(processConfig.id) : []
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* @param {string} id - Process id.
|
|
832
|
+
* @returns {boolean} True when the id belongs to the proxied process.
|
|
833
|
+
*/
|
|
834
|
+
isProxiedId(id) {
|
|
835
|
+
return this.config.processes.some((processConfig) => processConfig.policy === "proxied" && processConfig.id === id)
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* @param {string | undefined} releaseId - Release id, or active release when omitted.
|
|
840
|
+
* @returns {Promise<void>} Resolves when stopped.
|
|
841
|
+
*/
|
|
842
|
+
async stopRelease(releaseId) {
|
|
843
|
+
const release = releaseId ? this.releases.get(releaseId) : this.activeRelease
|
|
844
|
+
|
|
845
|
+
if (!release) throw new Error(`Release not found: ${releaseId || "active"}`)
|
|
846
|
+
if (release === this.activeRelease) this.activeRelease = undefined
|
|
847
|
+
|
|
848
|
+
await release.stop()
|
|
849
|
+
this.logger("release stopped", {releaseId: release.releaseId})
|
|
850
|
+
this.pruneStoppedReleases()
|
|
851
|
+
this.persistState()
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Drains and stops a retired release in the background, then prunes stopped releases.
|
|
856
|
+
* @param {ReleaseGroup} release - Release to drain and stop.
|
|
857
|
+
* @param {import("./config.js").RollbridgeConfig} [config] - Refreshed config governing retirement.
|
|
858
|
+
* @returns {Promise<void>} Resolves once drained, stopped, and pruned.
|
|
859
|
+
*/
|
|
860
|
+
async drainAndPrune(release, config = this.config) {
|
|
861
|
+
try {
|
|
862
|
+
await release.drainAndStop(config.proxy.drainTimeoutMs, config)
|
|
863
|
+
this.logger("release drained", {releaseId: release.releaseId})
|
|
864
|
+
} catch (error) {
|
|
865
|
+
this.logger("release drain failed", {error: error instanceof Error ? error.message : String(error), releaseId: release.releaseId})
|
|
866
|
+
} finally {
|
|
867
|
+
this.pruneStoppedReleases()
|
|
868
|
+
this.persistState()
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** @returns {void} Removes stopped releases beyond the retention policy. */
|
|
873
|
+
pruneStoppedReleases() {
|
|
874
|
+
const statuses = [...this.releases.values()].map((release) => release.status())
|
|
875
|
+
|
|
876
|
+
for (const releaseId of releasesToPrune(statuses, this.config.releaseRetention, Date.now())) {
|
|
877
|
+
this.releases.delete(releaseId)
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** @returns {void} Starts periodic state persistence when statePath is configured. */
|
|
882
|
+
startStatePersistence() {
|
|
883
|
+
if (!this.statePath) return
|
|
884
|
+
|
|
885
|
+
this.stateCleanupEnabled = true
|
|
886
|
+
this.persistenceEnabled = true
|
|
887
|
+
this.persistState()
|
|
888
|
+
this.persistTimer = setInterval(() => this.persistState(), STATE_PERSIST_INTERVAL_MS)
|
|
889
|
+
this.persistTimer.unref?.()
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Persists a state snapshot (status plus recent events) to statePath, atomically and
|
|
894
|
+
* fire-and-forget unless the caller awaits the returned write. A failed write is logged.
|
|
895
|
+
* @param {{throwOnError?: boolean}} [options] - Whether a write failure rejects the returned promise.
|
|
896
|
+
* @returns {Promise<void> | undefined} The queued write, or undefined when persistence is disabled.
|
|
897
|
+
*/
|
|
898
|
+
persistState({throwOnError = false} = {}) {
|
|
899
|
+
if (!this.statePath || !this.persistenceEnabled || this.stopping) return
|
|
900
|
+
|
|
901
|
+
const statePath = this.statePath
|
|
902
|
+
const status = /** @type {Record<string, JsonValue>} */ (secretSafeStateValue(this.status()))
|
|
903
|
+
const events = secretSafeStateValue(this.eventLog.recent())
|
|
904
|
+
const snapshot = {
|
|
905
|
+
...status,
|
|
906
|
+
events,
|
|
907
|
+
persistedAt: new Date().toISOString(),
|
|
908
|
+
...(this.guardianIdentity ? {recovery: {
|
|
909
|
+
configDigest: this.ownerRecoveryConfigDigest(),
|
|
910
|
+
format: 1,
|
|
911
|
+
guardian: this.guardianIdentity,
|
|
912
|
+
reconnectGraceMs: this.config.ownerRecovery?.reconnectGraceMs
|
|
913
|
+
}} : {})
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Serialize writes (and track the tail) so shutdown can wait for an in-flight write before
|
|
917
|
+
// clearing the file — otherwise a write started before shutdown could recreate it afterward.
|
|
918
|
+
this.pendingWrite = Promise.resolve(this.pendingWrite)
|
|
919
|
+
.catch(() => {})
|
|
920
|
+
.then(() => writeState(statePath, snapshot))
|
|
921
|
+
.catch((error) => {
|
|
922
|
+
this.logger("state persist failed", {error: error instanceof Error ? error.message : String(error)})
|
|
923
|
+
if (throwOnError) throw error
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
return this.pendingWrite
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* On startup, reads any state left by a previous daemon and reports managed processes whose
|
|
931
|
+
* pids are still alive — likely orphans from a daemon that did not shut down cleanly. This is
|
|
932
|
+
* advisory (Rollbridge cannot re-adopt detached children); the operator stops the leftovers.
|
|
933
|
+
* A recycled pid could be a false positive, so reports are a prompt to investigate.
|
|
934
|
+
* @returns {Promise<void>} Resolves once orphans are reported.
|
|
935
|
+
*/
|
|
936
|
+
async reportOrphans() {
|
|
937
|
+
if (!this.statePath) return
|
|
938
|
+
|
|
939
|
+
this.stateCleanupEnabled = true
|
|
940
|
+
const orphans = liveProcesses(await readState(this.statePath))
|
|
941
|
+
|
|
942
|
+
// Keep them for status() so `rollbridge status` reflects still-running children after a
|
|
943
|
+
// restart, not just the startup log below.
|
|
944
|
+
this.orphans = orphans
|
|
945
|
+
|
|
946
|
+
for (const orphan of orphans) {
|
|
947
|
+
this.logger("orphaned managed process detected", {pid: orphan.pid, processId: orphan.id, releaseId: orphan.releaseId})
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (orphans.length > 0) {
|
|
951
|
+
this.logger("orphaned processes from a previous daemon", {count: orphans.length, hint: "a previous daemon did not shut down cleanly; verify these pids and stop any leftovers"})
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* Stops proxy, control socket, and child processes.
|
|
957
|
+
* @param {{completionSocket?: net.Socket, waitForControlConnections?: boolean}} [options] - Shutdown connection behavior.
|
|
958
|
+
* @returns {Promise<void>} Resolves when owned resources are stopped (and, by default, control connections close).
|
|
959
|
+
*/
|
|
960
|
+
async shutdown({completionSocket, waitForControlConnections = true} = {}) {
|
|
961
|
+
if (!this.shutdownPromise) this.shutdownPromise = this.performShutdown(completionSocket)
|
|
962
|
+
|
|
963
|
+
await this.shutdownPromise
|
|
964
|
+
if (waitForControlConnections && this.controlClosePromise) await this.controlClosePromise
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Relinquishes stable listeners promptly while retaining draining children under
|
|
969
|
+
* this daemon until their normal stop contract completes.
|
|
970
|
+
* @param {{attestation: string, completionSocket?: net.Socket}} options - Attested handoff request.
|
|
971
|
+
* @returns {Promise<void>} Resolves once a replacement can exclusively bind listeners.
|
|
972
|
+
*/
|
|
973
|
+
async retireOwner({attestation, completionSocket}) {
|
|
974
|
+
if (this.retirementPromise) return await this.retirementPromise
|
|
975
|
+
this.retirementPromise = this.performOwnerRetirement(attestation, completionSocket)
|
|
976
|
+
return await this.retirementPromise
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* @param {string} attestation - Replacement boot attestation.
|
|
981
|
+
* @param {net.Socket | undefined} completionSocket - Requesting handoff connection.
|
|
982
|
+
* @returns {Promise<void>} Resolves after quiesce and listener release.
|
|
983
|
+
*/
|
|
984
|
+
async performOwnerRetirement(attestation, completionSocket) {
|
|
985
|
+
this.stopping = true
|
|
986
|
+
if (this.persistTimer) {
|
|
987
|
+
clearInterval(this.persistTimer)
|
|
988
|
+
this.persistTimer = undefined
|
|
989
|
+
}
|
|
990
|
+
this.persistenceEnabled = false
|
|
991
|
+
if (this.pendingWrite) await this.pendingWrite
|
|
992
|
+
this.stateCleanupEnabled = false
|
|
993
|
+
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
994
|
+
for (const socket of this.controlSockets) if (socket !== completionSocket) socket.destroy()
|
|
995
|
+
await Promise.all([
|
|
996
|
+
...[...this.services.values()].map((processInstance) => processInstance.quiesce()),
|
|
997
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.quiesce()),
|
|
998
|
+
...[...this.startingReleases].map((release) => release.quiesce()),
|
|
999
|
+
...[...this.releases.values()].map((release) => release.quiesce())
|
|
1000
|
+
])
|
|
1001
|
+
await this.removeControlSocket()
|
|
1002
|
+
void this.closeServer(this.proxyServer)
|
|
1003
|
+
void Promise.allSettled([
|
|
1004
|
+
...[...this.services.values()].map((processInstance) => processInstance.stop()),
|
|
1005
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
1006
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
1007
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
1008
|
+
])
|
|
1009
|
+
this.logger("external owner retired", {attestation, status: "draining"})
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* @param {net.Socket | undefined} completionSocket - Requester retained for the final response.
|
|
1014
|
+
* @returns {Promise<void>} Retires listeners and cleans up every daemon-owned resource.
|
|
1015
|
+
*/
|
|
1016
|
+
async performShutdown(completionSocket) {
|
|
1017
|
+
this.stopping = true
|
|
1018
|
+
const cleanupErrors = /** @type {Error[]} */ ([])
|
|
1019
|
+
|
|
1020
|
+
// server.close() stops new connections synchronously. Unlink immediately afterward so a
|
|
1021
|
+
// replacement can bind as soon as cleanup completes; existing connections remain usable for
|
|
1022
|
+
// the shutdown completion/error response.
|
|
1023
|
+
this.controlClosePromise = this.closeServer(this.controlServer)
|
|
1024
|
+
|
|
1025
|
+
for (const socket of this.controlSockets) {
|
|
1026
|
+
if (socket !== completionSocket) socket.destroy()
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
await captureShutdownError(cleanupErrors, "control socket unlink", () => this.removeControlSocket())
|
|
1030
|
+
|
|
1031
|
+
if (this.persistTimer) {
|
|
1032
|
+
clearInterval(this.persistTimer)
|
|
1033
|
+
this.persistTimer = undefined
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
await captureShutdownError(cleanupErrors, "proxy close", async () => this.proxy.close())
|
|
1037
|
+
const dependentStopResults = await Promise.allSettled([
|
|
1038
|
+
...[...this.singletons.values()].map((processInstance) => processInstance.stop()),
|
|
1039
|
+
...[...this.startingReleases].map((release) => release.stop()),
|
|
1040
|
+
...[...this.releases.values()].map((release) => release.stop())
|
|
1041
|
+
])
|
|
1042
|
+
const serviceStopResults = await Promise.allSettled([...this.services.values()].map((processInstance) => processInstance.stop()))
|
|
1043
|
+
const stopResults = [...dependentStopResults, ...serviceStopResults]
|
|
1044
|
+
const guardian = this.guardian
|
|
1045
|
+
if (guardian) await captureShutdownError(cleanupErrors, "process guardian shutdown", () => guardian.shutdown())
|
|
1046
|
+
await captureShutdownError(cleanupErrors, "proxy server close", () => this.closeServer(this.proxyServer))
|
|
1047
|
+
|
|
1048
|
+
// Wait for any in-flight write first so it can't recreate or overwrite the final state (no
|
|
1049
|
+
// new writes start: stopping is set and the persist timer is cleared above). Prior-daemon
|
|
1050
|
+
// orphans are not owned by this daemon, so retain their records until they are confirmed gone.
|
|
1051
|
+
await captureShutdownError(cleanupErrors, "persistent state cleanup", async () => {
|
|
1052
|
+
if (!this.statePath || !this.stateCleanupEnabled) return
|
|
1053
|
+
if (this.pendingWrite) await this.pendingWrite
|
|
1054
|
+
const orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
1055
|
+
|
|
1056
|
+
if (orphans.length > 0) {
|
|
1057
|
+
await writeState(this.statePath, {activeReleaseId: null, orphans, releases: [], services: [], singletons: []})
|
|
1058
|
+
} else {
|
|
1059
|
+
await clearState(this.statePath)
|
|
1060
|
+
}
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
const stopErrors = stopResults.filter((result) => result.status === "rejected").map((result) => result.reason)
|
|
1064
|
+
|
|
1065
|
+
if (stopErrors.length > 0) {
|
|
1066
|
+
cleanupErrors.push(new AggregateError(stopErrors, `Shutdown failed to stop ${stopErrors.length} owned resource${stopErrors.length === 1 ? "" : "s"}.`))
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, cleanupErrors.map((error) => error.message).join("; "))
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** @returns {Promise<void>} Removes the configured control socket path. */
|
|
1073
|
+
async removeControlSocket() {
|
|
1074
|
+
if (!this.controlSocketOwned) return
|
|
1075
|
+
|
|
1076
|
+
await fs.rm(this.config.control.path, {force: true})
|
|
1077
|
+
this.controlSocketOwned = false
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* @param {net.Server | http.Server | undefined} server - Server.
|
|
1082
|
+
* @returns {Promise<void>} Resolves when closed.
|
|
1083
|
+
*/
|
|
1084
|
+
async closeServer(server) {
|
|
1085
|
+
if (!server || !server.listening) return
|
|
1086
|
+
|
|
1087
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** @returns {number | undefined} Current proxy port. */
|
|
1091
|
+
getProxyPort() {
|
|
1092
|
+
return this.proxyPort
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/** @returns {DaemonStatus} Status payload. */
|
|
1096
|
+
status() {
|
|
1097
|
+
// Re-check liveness and prune the dead permanently, so the list self-clears as the operator
|
|
1098
|
+
// stops the leftovers (e.g. via `rollbridge recover`). Pruning (not just filtering) matters:
|
|
1099
|
+
// a cleared orphan must not reappear if the OS later recycles its pid for an unrelated process.
|
|
1100
|
+
this.orphans = this.orphans.filter((orphan) => isProcessAlive(orphan.pid))
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
activeReleaseId: this.activeRelease ? this.activeRelease.releaseId : null,
|
|
1104
|
+
application: this.config.application,
|
|
1105
|
+
bootstrap: this.bootstrap ? {...this.bootstrap} : undefined,
|
|
1106
|
+
control: {...this.config.control},
|
|
1107
|
+
daemonRuntime: this.runtime ? {...this.runtime} : undefined,
|
|
1108
|
+
ownerRecovery: this.guardian ? {configDigest: this.ownerRecoveryConfigDigest()} : undefined,
|
|
1109
|
+
orphans: [...this.orphans],
|
|
1110
|
+
proxy: {
|
|
1111
|
+
host: this.config.proxy.host,
|
|
1112
|
+
port: this.proxyPort ?? this.config.proxy.port,
|
|
1113
|
+
upstreamHost: this.config.proxy.upstreamHost
|
|
1114
|
+
},
|
|
1115
|
+
releaseReferences: [...this.releases.values()]
|
|
1116
|
+
.filter((release) => release.state === "active" || release.state === "draining")
|
|
1117
|
+
.map((release) => ({releaseId: release.releaseId, releasePath: release.releasePath})),
|
|
1118
|
+
releases: [...this.releases.values()].map((release) => release.status()),
|
|
1119
|
+
services: [...this.services.entries()].map(([id, processInstance]) => ({
|
|
1120
|
+
id,
|
|
1121
|
+
process: processInstance.status()
|
|
1122
|
+
})),
|
|
1123
|
+
singletons: [...this.singletons.entries()].map(([id, processInstance]) => ({
|
|
1124
|
+
id,
|
|
1125
|
+
process: processInstance.status()
|
|
1126
|
+
}))
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* @param {JsonValue} value - Value.
|
|
1133
|
+
* @returns {string | undefined} String value.
|
|
1134
|
+
*/
|
|
1135
|
+
function stringOrUndefined(value) {
|
|
1136
|
+
if (value === undefined || value === null) return undefined
|
|
1137
|
+
if (typeof value !== "string") throw new Error("Expected string value")
|
|
1138
|
+
|
|
1139
|
+
return value
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* @param {string} line - Raw control line.
|
|
1144
|
+
* @returns {boolean} Whether the line requests shutdown and needs a terminal response connection.
|
|
1145
|
+
*/
|
|
1146
|
+
function isShutdownControlLine(line) {
|
|
1147
|
+
try {
|
|
1148
|
+
const command = JSON.parse(line)
|
|
1149
|
+
|
|
1150
|
+
return Boolean(command && typeof command === "object" && ["retire-owner", "shutdown"].includes(command.command))
|
|
1151
|
+
} catch {
|
|
1152
|
+
return false
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Runs one shutdown cleanup step and records a labeled failure without skipping later cleanup.
|
|
1158
|
+
* @param {Error[]} errors - Accumulated cleanup errors.
|
|
1159
|
+
* @param {string} label - Non-secret cleanup step name.
|
|
1160
|
+
* @param {() => Promise<void>} operation - Cleanup operation.
|
|
1161
|
+
* @returns {Promise<void>} Resolves after the operation succeeds or its failure is recorded.
|
|
1162
|
+
*/
|
|
1163
|
+
async function captureShutdownError(errors, label, operation) {
|
|
1164
|
+
try {
|
|
1165
|
+
await operation()
|
|
1166
|
+
} catch (error) {
|
|
1167
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
1168
|
+
|
|
1169
|
+
errors.push(new Error(`${label} failed: ${reason}`, {cause: error}))
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const SECRET_BEARING_STATE_KEYS = new Set(["children", "command", "cwd", "env", "environment", "logs", "output"])
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Removes process definitions and captured output from a value before it reaches statePath.
|
|
1177
|
+
* The live status/events APIs retain those diagnostics in memory; persistent state is only a
|
|
1178
|
+
* secret-safe recovery aid and must not become a second process log or configuration store.
|
|
1179
|
+
* @param {JsonValue} value - JSON value to sanitize.
|
|
1180
|
+
* @returns {JsonValue} A secret-safe copy.
|
|
1181
|
+
*/
|
|
1182
|
+
function secretSafeStateValue(value) {
|
|
1183
|
+
if (Array.isArray(value)) return value.map((entry) => secretSafeStateValue(entry))
|
|
1184
|
+
if (!value || typeof value !== "object") return value
|
|
1185
|
+
|
|
1186
|
+
/** @type {Record<string, JsonValue>} */
|
|
1187
|
+
const safe = {}
|
|
1188
|
+
|
|
1189
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1190
|
+
if (SECRET_BEARING_STATE_KEYS.has(key)) continue
|
|
1191
|
+
safe[key] = secretSafeStateValue(entry)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return safe
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/**
|
|
1198
|
+
* @param {JsonValue} value - Value.
|
|
1199
|
+
* @param {string} key - Key.
|
|
1200
|
+
* @returns {string} String value.
|
|
1201
|
+
*/
|
|
1202
|
+
function requiredString(value, key) {
|
|
1203
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1204
|
+
throw new Error(`${key} is required`)
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
return value
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* @typedef {{releaseId: string, state: string, stoppedAt: string | undefined}} PrunableRelease
|
|
1212
|
+
*/
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Selects stopped releases to prune by the retention policy, keeping the most recent.
|
|
1216
|
+
* @param {PrunableRelease[]} releases - Status of all tracked releases, in deploy order (oldest first).
|
|
1217
|
+
* @param {import("./config.js").ReleaseRetentionConfig} policy - Retention policy.
|
|
1218
|
+
* @param {number} now - Current epoch milliseconds.
|
|
1219
|
+
* @returns {string[]} Release ids to remove.
|
|
1220
|
+
*/
|
|
1221
|
+
export function releasesToPrune(releases, policy, now) {
|
|
1222
|
+
const stopped = releases
|
|
1223
|
+
.filter((release) => release.state === "stopped")
|
|
1224
|
+
.map((release, index) => ({deployOrder: index, releaseId: release.releaseId, stoppedAtMs: release.stoppedAt ? Date.parse(release.stoppedAt) : 0}))
|
|
1225
|
+
// Most recent first; ties (same stoppedAt millisecond) prefer the later-deployed release.
|
|
1226
|
+
.sort((first, second) => second.stoppedAtMs - first.stoppedAtMs || second.deployOrder - first.deployOrder)
|
|
1227
|
+
|
|
1228
|
+
/** @type {string[]} */
|
|
1229
|
+
const remove = []
|
|
1230
|
+
|
|
1231
|
+
stopped.forEach((release, index) => {
|
|
1232
|
+
const beyondKeep = index >= policy.keep
|
|
1233
|
+
const tooOld = policy.maxAgeMs > 0 && release.stoppedAtMs > 0 && now - release.stoppedAtMs > policy.maxAgeMs
|
|
1234
|
+
|
|
1235
|
+
if (beyondKeep || tooOld) remove.push(release.releaseId)
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1238
|
+
return remove
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* @typedef {{alive: boolean, application?: string, activeReleaseId?: string | null, proxy?: {host: string, port: number}}} ControlSocketInspection
|
|
1243
|
+
*/
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Builds an operator-facing message explaining why the control socket cannot be bound.
|
|
1247
|
+
* @param {string} socketPath - Control socket path.
|
|
1248
|
+
* @param {ControlSocketInspection} inspection - Result of probing the socket.
|
|
1249
|
+
* @returns {string} Diagnostic message.
|
|
1250
|
+
*/
|
|
1251
|
+
function controlSocketBusyMessage(socketPath, inspection) {
|
|
1252
|
+
if (inspection.application === undefined) {
|
|
1253
|
+
return `The control socket ${socketPath} is already in use by another process. Stop that process or set a different control.path.`
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const releaseDetail = inspection.activeReleaseId ? `active release: ${inspection.activeReleaseId}` : "no active release"
|
|
1257
|
+
|
|
1258
|
+
return `A Rollbridge daemon for application "${inspection.application}" is already running on ${socketPath} (${releaseDetail}). ` +
|
|
1259
|
+
`Run "rollbridge status" to inspect it or "rollbridge shutdown" to stop it, or set a different control.path.`
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Probes an existing control socket to see whether a daemon is alive, and identifies it when it is Rollbridge.
|
|
1264
|
+
* @param {string} socketPath - Control socket path.
|
|
1265
|
+
* @param {number} [timeoutMs] - How long to wait for a status response before treating the socket as busy.
|
|
1266
|
+
* @returns {Promise<ControlSocketInspection>} Whether the socket is live and, when it is Rollbridge, its identity.
|
|
1267
|
+
*/
|
|
1268
|
+
export async function inspectControlSocket(socketPath, timeoutMs = 1000) {
|
|
1269
|
+
return await new Promise((resolve, reject) => {
|
|
1270
|
+
const socket = net.createConnection(socketPath)
|
|
1271
|
+
let buffer = ""
|
|
1272
|
+
let settled = false
|
|
1273
|
+
let timer = /** @type {ReturnType<typeof setTimeout> | undefined} */ (undefined)
|
|
1274
|
+
|
|
1275
|
+
const finish = (/** @type {ControlSocketInspection} */ result) => {
|
|
1276
|
+
if (settled) return
|
|
1277
|
+
|
|
1278
|
+
settled = true
|
|
1279
|
+
if (timer) clearTimeout(timer)
|
|
1280
|
+
socket.destroy()
|
|
1281
|
+
resolve(result)
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
timer = setTimeout(() => finish({alive: true}), timeoutMs)
|
|
1285
|
+
socket.setEncoding("utf8")
|
|
1286
|
+
socket.once("connect", () => socket.write(`${JSON.stringify({command: "status"})}\n`))
|
|
1287
|
+
socket.on("data", (chunk) => {
|
|
1288
|
+
buffer += chunk
|
|
1289
|
+
const newlineIndex = buffer.indexOf("\n")
|
|
1290
|
+
|
|
1291
|
+
if (newlineIndex < 0) return
|
|
1292
|
+
|
|
1293
|
+
const status = parseControlStatus(buffer.slice(0, newlineIndex))
|
|
1294
|
+
|
|
1295
|
+
finish(status ? {activeReleaseId: status.activeReleaseId, alive: true, application: status.application, proxy: status.proxy} : {alive: true})
|
|
1296
|
+
})
|
|
1297
|
+
socket.once("error", (error) => {
|
|
1298
|
+
if (settled) return
|
|
1299
|
+
|
|
1300
|
+
if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "ECONNREFUSED")) {
|
|
1301
|
+
settled = true
|
|
1302
|
+
if (timer) clearTimeout(timer)
|
|
1303
|
+
resolve({alive: false})
|
|
1304
|
+
return
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
settled = true
|
|
1308
|
+
if (timer) clearTimeout(timer)
|
|
1309
|
+
reject(error)
|
|
1310
|
+
})
|
|
1311
|
+
})
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* Parses a control status response line into a Rollbridge identity, if it is one.
|
|
1316
|
+
* @param {string} line - JSON response line.
|
|
1317
|
+
* @returns {{application: string, activeReleaseId: string | null, proxy: {host: string, port: number} | undefined} | undefined} Identity, or undefined when unrecognized.
|
|
1318
|
+
*/
|
|
1319
|
+
function parseControlStatus(line) {
|
|
1320
|
+
/** @type {JsonValue} */
|
|
1321
|
+
let parsed
|
|
1322
|
+
|
|
1323
|
+
try {
|
|
1324
|
+
parsed = JSON.parse(line)
|
|
1325
|
+
} catch {
|
|
1326
|
+
return undefined
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined
|
|
1330
|
+
if (typeof parsed.application !== "string") return undefined
|
|
1331
|
+
|
|
1332
|
+
const proxy = "proxy" in parsed && parsed.proxy && typeof parsed.proxy === "object" && !Array.isArray(parsed.proxy) && typeof parsed.proxy.host === "string" && typeof parsed.proxy.port === "number" ? {host: parsed.proxy.host, port: parsed.proxy.port} : undefined
|
|
1333
|
+
|
|
1334
|
+
return {activeReleaseId: typeof parsed.activeReleaseId === "string" ? parsed.activeReleaseId : null, application: parsed.application, proxy}
|
|
1335
|
+
}
|
|
1336
|
+
|