@flowfuse/nr-launcher 1.13.3
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/CHANGELOG.md +182 -0
- package/LICENSE +178 -0
- package/README.md +28 -0
- package/index.js +148 -0
- package/lib/admin.js +95 -0
- package/lib/auditLogger/index.js +41 -0
- package/lib/auth/adminAuth.js +77 -0
- package/lib/auth/httpAuthMiddleware.js +71 -0
- package/lib/auth/httpAuthPlugin.js +10 -0
- package/lib/auth/strategy.js +34 -0
- package/lib/context/FFContextStorage.js +422 -0
- package/lib/context/index.js +9 -0
- package/lib/context/memoryCache.js +156 -0
- package/lib/launcher.js +695 -0
- package/lib/logBuffer.js +57 -0
- package/lib/resources/resourcePlugin.js +20 -0
- package/lib/resources/sample.js +57 -0
- package/lib/resources/sampleBuffer.js +85 -0
- package/lib/runtimeSettings.js +320 -0
- package/lib/storage/index.js +92 -0
- package/lib/storage/libraryPlugin.js +90 -0
- package/lib/theme/LICENSE +178 -0
- package/lib/theme/README.md +24 -0
- package/lib/theme/common/forge-common.css +108 -0
- package/lib/theme/common/forge-common.js +75 -0
- package/lib/theme/forge-dark/forge-dark-custom.css +2 -0
- package/lib/theme/forge-dark/forge-dark-custom.js +1 -0
- package/lib/theme/forge-dark/forge-dark-monaco.json +213 -0
- package/lib/theme/forge-dark/forge-dark-theme.css +12 -0
- package/lib/theme/forge-dark/forge-dark.js +61 -0
- package/lib/theme/forge-light/forge-light-custom.css +2 -0
- package/lib/theme/forge-light/forge-light-custom.js +1 -0
- package/lib/theme/forge-light/forge-light-monaco.json +227 -0
- package/lib/theme/forge-light/forge-light-theme.css +12 -0
- package/lib/theme/forge-light/forge-light.js +62 -0
- package/package.json +72 -0
- package/resources/favicon-16x16.png +0 -0
- package/resources/favicon-32x32.png +0 -0
- package/resources/favicon.ico +0 -0
- package/resources/ff-nr.png +0 -0
package/lib/launcher.js
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const got = require('got')
|
|
4
|
+
const childProcess = require('child_process')
|
|
5
|
+
const LogBuffer = require('./logBuffer')
|
|
6
|
+
const { getSettingsFile } = require('./runtimeSettings')
|
|
7
|
+
const SampleBuffer = require('./resources/sampleBuffer')
|
|
8
|
+
const resourceSample = require('./resources/sample')
|
|
9
|
+
|
|
10
|
+
/** The point at which we check to see if we are in a boot loop */
|
|
11
|
+
const MAX_RESTART_COUNT = 5
|
|
12
|
+
|
|
13
|
+
/** The minimum run time we we expect when determining if we are in a boot loop
|
|
14
|
+
* - Run duration is measured from the time node-red is spawned to the time it crashes
|
|
15
|
+
*/
|
|
16
|
+
const MIN_RUNTIME = 30000
|
|
17
|
+
|
|
18
|
+
/** If the distribution of run durations are less than this, we are likely in a boot loop
|
|
19
|
+
* - Run duration is measured from the time node-red is spawned to the time it crashes.
|
|
20
|
+
*/
|
|
21
|
+
const MIN_RUNTIME_DEVIATION = 2000 // 2 seconds either side of the mean
|
|
22
|
+
|
|
23
|
+
/** How long wait for Node-RED to cleanly stop before killing */
|
|
24
|
+
const NODE_RED_STOP_TIMEOUT = 10000
|
|
25
|
+
|
|
26
|
+
/** Interval between status polls of Node-RED used to detect a hung runtime */
|
|
27
|
+
/** Interval between status polls of Node-RED used to detect a hung runtime */
|
|
28
|
+
const HEALTH_POLL_INTERVAL = 7499 // A prime number (to minimise syncing with other processes)
|
|
29
|
+
|
|
30
|
+
/** Timeout to apply to health polling requests */
|
|
31
|
+
const HEALTH_POLL_TIMEOUT = HEALTH_POLL_INTERVAL - 500 // Allow 500ms to avoid overlapping requests
|
|
32
|
+
|
|
33
|
+
/** The number of consecutive timeouts during startup phase before considering a NR hang */
|
|
34
|
+
const HEALTH_POLL_MAX_STARTUP_ERROR_COUNT = 10
|
|
35
|
+
|
|
36
|
+
/** The number of consecutive timeouts before considering a NR hang */
|
|
37
|
+
const HEALTH_POLL_MAX_ERROR_COUNT = 3
|
|
38
|
+
|
|
39
|
+
/** The number of seconds between resource polling */
|
|
40
|
+
const RESOURCE_POLL_INTERVAL = 10
|
|
41
|
+
const RESOURCE_ALERT_SAMPLES = 30
|
|
42
|
+
const RESOURCE_ALERT_THRESHOLD = 0.75
|
|
43
|
+
const CPU_LIMIT = process.env.FORGE_CPU_LIMIT
|
|
44
|
+
const MEMORY_LIMIT = process.env.FORGE_MEMORY_LIMIT
|
|
45
|
+
|
|
46
|
+
const States = {
|
|
47
|
+
STOPPED: 'stopped',
|
|
48
|
+
LOADING: 'loading',
|
|
49
|
+
INSTALLING: 'installing',
|
|
50
|
+
STARTING: 'starting',
|
|
51
|
+
RUNNING: 'running',
|
|
52
|
+
SAFE: 'safe',
|
|
53
|
+
CRASHED: 'crashed',
|
|
54
|
+
STOPPING: 'stopping'
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* options:
|
|
58
|
+
* - logBufferMax
|
|
59
|
+
* - forgeURL
|
|
60
|
+
* - project
|
|
61
|
+
* - token
|
|
62
|
+
* - execPath
|
|
63
|
+
*/
|
|
64
|
+
class Launcher {
|
|
65
|
+
constructor (options) {
|
|
66
|
+
this.options = options
|
|
67
|
+
this.state = States.STOPPED
|
|
68
|
+
// Assume we want to start NR unless told otherwise via loadSettings
|
|
69
|
+
this.targetState = States.RUNNING
|
|
70
|
+
this.env = {
|
|
71
|
+
PATH: process.env.PATH
|
|
72
|
+
}
|
|
73
|
+
this.settings = null
|
|
74
|
+
|
|
75
|
+
// Array of times and run durations for monitoring boot loops
|
|
76
|
+
this.startTimes = []
|
|
77
|
+
this.runDurations = []
|
|
78
|
+
|
|
79
|
+
this.logBuffer = new LogBuffer(this.options.logBufferMax || 1000)
|
|
80
|
+
this.logBuffer.add({ level: 'system', msg: `Launcher version: ${this.options?.versions?.launcher || 'unknown'}` })
|
|
81
|
+
|
|
82
|
+
// A callback function that will be set if the launcher is waiting
|
|
83
|
+
// for Node-RED to exit
|
|
84
|
+
this.exitCallback = null
|
|
85
|
+
|
|
86
|
+
this.healthPoll = null
|
|
87
|
+
this.resourcePoll = null
|
|
88
|
+
|
|
89
|
+
// defaults to the last 3.5 hours of samples at 5 second intervals
|
|
90
|
+
this.sampleBuffer = new SampleBuffer(this.options.sampleBufferMax || 2520)
|
|
91
|
+
this.cpuAuditLogged = 0
|
|
92
|
+
this.memoryAuditLogged = 0
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async loadSettings () {
|
|
96
|
+
this.state = States.LOADING
|
|
97
|
+
this.logBuffer.add({ level: 'system', msg: 'Loading project settings' })
|
|
98
|
+
const settingsURL = `${this.options.forgeURL}/api/v1/projects/${this.options.project}/settings`
|
|
99
|
+
let newSettings
|
|
100
|
+
try {
|
|
101
|
+
newSettings = await got(settingsURL, {
|
|
102
|
+
headers: {
|
|
103
|
+
authorization: `Bearer ${this.options.token}`
|
|
104
|
+
}
|
|
105
|
+
}).json()
|
|
106
|
+
} catch (err) {
|
|
107
|
+
this.settings = null
|
|
108
|
+
this.state = States.STOPPED
|
|
109
|
+
// if launcher failed to restart it will have old token
|
|
110
|
+
if (err?.response?.statusCode === 401) {
|
|
111
|
+
// throwing error will cause call to `start` to fail and an audit event will be logged
|
|
112
|
+
throw new Error('Unauthorized')
|
|
113
|
+
}
|
|
114
|
+
// what to do when forge is down?
|
|
115
|
+
console.log(`Unable to get settings from ${settingsURL} with error ${err.toString()}`)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this.settings = newSettings
|
|
120
|
+
this.settings.projectToken = this.options.token
|
|
121
|
+
this.settings.clientID = process.env.FORGE_CLIENT_ID
|
|
122
|
+
this.settings.teamID = process.env.FORGE_TEAM_ID
|
|
123
|
+
this.settings.clientSecret = process.env.FORGE_CLIENT_SECRET
|
|
124
|
+
this.settings.credentialSecret = process.env.FORGE_NR_SECRET
|
|
125
|
+
this.settings.allowInboundTcp = this.options?.allowInboundTcp
|
|
126
|
+
this.settings.allowInboundUdp = this.options?.allowInboundUdp
|
|
127
|
+
this.settings.licenseType = process.env.FORGE_LICENSE_TYPE
|
|
128
|
+
this.settings.broker = this.options.broker
|
|
129
|
+
this.settings.launcherVersion = this.options?.versions?.launcher || ''
|
|
130
|
+
|
|
131
|
+
// setup nodeDir to include the path to additional nodes and plugins
|
|
132
|
+
const nodesDir = []
|
|
133
|
+
if (Array.isArray(this.settings.nodesDir) && this.settings.nodesDir.length) {
|
|
134
|
+
nodesDir.push(...this.settings.nodesDir)
|
|
135
|
+
} else if (this.settings.nodesDir && typeof this.settings.nodesDir === 'string') {
|
|
136
|
+
nodesDir.push(this.settings.nodesDir)
|
|
137
|
+
}
|
|
138
|
+
nodesDir.push(path.join(require.main.path, 'node_modules', '@flowfuse', 'nr-project-nodes').replace(/\\/g, '/'))
|
|
139
|
+
nodesDir.push(path.join(require.main.path, '..', '..', '@flowfuse', 'nr-project-nodes').replace(/\\/g, '/'))
|
|
140
|
+
nodesDir.push(path.join(require.main.path, 'node_modules', '@flowfuse', 'nr-file-nodes').replace(/\\/g, '/'))
|
|
141
|
+
nodesDir.push(path.join(require.main.path, '..', '..', '@flowfuse', 'nr-file-nodes').replace(/\\/g, '/'))
|
|
142
|
+
nodesDir.push(require.main.path)
|
|
143
|
+
|
|
144
|
+
this.settings.nodesDir = nodesDir
|
|
145
|
+
|
|
146
|
+
const settingsFileContent = getSettingsFile(this.settings)
|
|
147
|
+
const settingsPath = path.join(this.settings.rootDir, this.settings.userDir, 'settings.js')
|
|
148
|
+
this.targetState = this.settings.state || States.RUNNING
|
|
149
|
+
try {
|
|
150
|
+
fs.writeFileSync(settingsPath, settingsFileContent)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.state = States.STOPPED
|
|
153
|
+
this.logBuffer.add({ level: 'system', msg: 'Unable to write package file' })
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
const npmrcPath = path.join(this.settings.rootDir, this.settings.userDir, '.npmrc')
|
|
157
|
+
if (this.settings.settings.palette.npmrc) {
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(npmrcPath, this.settings.settings.palette.npmrc)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.state = States.STOPPED
|
|
162
|
+
this.logBuffer.add({ level: 'system', msg: 'Unable to write .npmrc file' })
|
|
163
|
+
throw error
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
if (fs.existsSync(npmrcPath)) {
|
|
167
|
+
try {
|
|
168
|
+
fs.unlinkSync(npmrcPath)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this.state = States.STOPPED
|
|
171
|
+
this.logBuffer.add({ level: 'system', msg: 'Unable to remove old .npmrc file' })
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await this.updatePackage()
|
|
179
|
+
} catch (error) {
|
|
180
|
+
this.state = States.STOPPED
|
|
181
|
+
this.logBuffer.add({ level: 'system', msg: 'Unable to install or update packages' })
|
|
182
|
+
throw error
|
|
183
|
+
}
|
|
184
|
+
this.logBuffer.add({ level: 'system', msg: `Target state is '${this.targetState}'` })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async updatePackage () {
|
|
188
|
+
const pkgFilePath = path.join(this.settings.rootDir, this.settings.userDir, 'package.json')
|
|
189
|
+
const packageContent = fs.readFileSync(pkgFilePath, { encoding: 'utf8' })
|
|
190
|
+
const pkg = JSON.parse(packageContent)
|
|
191
|
+
const existingDependencies = pkg.dependencies || {}
|
|
192
|
+
const wantedDependencies = this.settings.settings.palette?.modules || {}
|
|
193
|
+
|
|
194
|
+
const existingModules = Object.keys(existingDependencies)
|
|
195
|
+
const wantedModules = Object.keys(wantedDependencies)
|
|
196
|
+
|
|
197
|
+
let changed = false
|
|
198
|
+
if (existingModules.length !== wantedModules.length) {
|
|
199
|
+
changed = true
|
|
200
|
+
} else {
|
|
201
|
+
existingModules.sort()
|
|
202
|
+
wantedModules.sort()
|
|
203
|
+
for (let i = 0; i < existingModules.length; i++) {
|
|
204
|
+
if (existingModules[i] !== wantedModules[i]) {
|
|
205
|
+
changed = true
|
|
206
|
+
break
|
|
207
|
+
}
|
|
208
|
+
if (existingDependencies[existingModules[i]] !== wantedDependencies[wantedModules[i]]) {
|
|
209
|
+
changed = true
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (changed) {
|
|
216
|
+
this.state = States.INSTALLING
|
|
217
|
+
this.logBuffer.add({ level: 'system', msg: 'Updating project dependencies' })
|
|
218
|
+
pkg.dependencies = wantedDependencies
|
|
219
|
+
fs.writeFileSync(pkgFilePath, JSON.stringify(pkg, null, 2))
|
|
220
|
+
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const child = childProcess.spawn(
|
|
223
|
+
npmCommand,
|
|
224
|
+
['install', '--omit=dev', '--no-audit', '--no-update-notifier', '--no-fund'],
|
|
225
|
+
{ windowsHide: true, cwd: path.join(this.settings.rootDir, this.settings.userDir) })
|
|
226
|
+
child.stdout.on('data', (data) => {
|
|
227
|
+
this.logBuffer.add({ level: 'system', msg: '[npm] ' + data })
|
|
228
|
+
})
|
|
229
|
+
child.stderr.on('data', (data) => {
|
|
230
|
+
this.logBuffer.add({ level: 'system', msg: '[npm] ' + data })
|
|
231
|
+
})
|
|
232
|
+
child.on('error', (err) => {
|
|
233
|
+
this.logBuffer.add({ level: 'system', msg: '[npm] ' + err.toString() })
|
|
234
|
+
})
|
|
235
|
+
child.on('close', (code) => {
|
|
236
|
+
if (code === 0) {
|
|
237
|
+
resolve()
|
|
238
|
+
} else {
|
|
239
|
+
reject(new Error('Failed to install project dependencies'))
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
}).catch(err => {
|
|
243
|
+
// Revert the package file to the previous content. That ensures
|
|
244
|
+
// it will try to install again the next time it attempts to run
|
|
245
|
+
fs.writeFileSync(pkgFilePath, packageContent)
|
|
246
|
+
throw err
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async logAuditEvent (event, body) {
|
|
252
|
+
const data = {
|
|
253
|
+
timestamp: Date.now(),
|
|
254
|
+
event
|
|
255
|
+
}
|
|
256
|
+
if (body && typeof body === 'object') {
|
|
257
|
+
if (body.error) {
|
|
258
|
+
data.error = {
|
|
259
|
+
code: body.error.code || 'unexpected_error',
|
|
260
|
+
error: body.error.error || body.error.message || 'Unexpected error'
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
Object.assign(data, body)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return got.post(this.options.forgeURL + '/logging/' + this.options.project + '/audit', {
|
|
267
|
+
json: data,
|
|
268
|
+
headers: {
|
|
269
|
+
authorization: 'Bearer ' + this.options.token
|
|
270
|
+
}
|
|
271
|
+
}).catch(_err => {
|
|
272
|
+
console.error('Failed to log audit event', _err, event)
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
getState () {
|
|
277
|
+
return this.state
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
getLastStartTime () {
|
|
281
|
+
return this.startTimes.length !== 0 ? this.startTimes[this.startTimes.length - 1] : -1
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
getLog () {
|
|
285
|
+
return this.logBuffer
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
getResources () {
|
|
289
|
+
return this.sampleBuffer
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
isHealthy () {
|
|
293
|
+
return this.proc && this.proc.exitCode === null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async start (targetState) {
|
|
297
|
+
if (this.deferredStop) {
|
|
298
|
+
await this.deferredStop
|
|
299
|
+
}
|
|
300
|
+
if (targetState) {
|
|
301
|
+
this.targetState = targetState
|
|
302
|
+
}
|
|
303
|
+
if (!this.settings) {
|
|
304
|
+
throw new Error('Failed to load settings')
|
|
305
|
+
}
|
|
306
|
+
if (this.state === States.RUNNING || this.state === States.STARTING) {
|
|
307
|
+
// Already running or starting - no need to start again
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
if (this.targetState === States.STOPPED) {
|
|
311
|
+
// Target state is stopped - don't start
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
this.logBuffer.add({ level: 'system', msg: 'Starting Node-RED' })
|
|
315
|
+
const filterEnv = (env) =>
|
|
316
|
+
Object.entries(env).reduce((acc, [key, value]) =>
|
|
317
|
+
key.startsWith('FORGE') ? acc : { ...acc, [key]: value }, {})
|
|
318
|
+
|
|
319
|
+
// According to https://github.com/flowforge/flowforge-nr-launcher/pull/145,
|
|
320
|
+
// setting FORGE_EXPOSE_HOST_ENV on the container unlocks the host env propagation.
|
|
321
|
+
const appEnv = Object.assign({},
|
|
322
|
+
process.env.FORGE_EXPOSE_HOST_ENV ? filterEnv(process.env) : {},
|
|
323
|
+
this.env,
|
|
324
|
+
this.settings.env)
|
|
325
|
+
appEnv.TZ = this.settings.settings.timeZone
|
|
326
|
+
|
|
327
|
+
if (this.targetState === States.SAFE) {
|
|
328
|
+
appEnv.NODE_RED_ENABLE_SAFE_MODE = true
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (process.env.NODE_EXTRA_CA_CERTS) {
|
|
332
|
+
appEnv.NODE_EXTRA_CA_CERTS = process.env.NODE_EXTRA_CA_CERTS
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (process.env.HOME) {
|
|
336
|
+
appEnv.HOME = process.env.HOME
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (appEnv.NODE_RED_ENABLE_PROJECTS) {
|
|
340
|
+
delete appEnv.NODE_RED_ENABLE_PROJECTS
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const nodePaths = [
|
|
344
|
+
path.join(require.main.path, 'node_modules'),
|
|
345
|
+
path.join(require.main.path, '..', '..')
|
|
346
|
+
]
|
|
347
|
+
|
|
348
|
+
// Check to see if we're in the dev-env - in which case, we need to add the right
|
|
349
|
+
// path to NODE_PATH so that the settings file can load `@flowfuse/nr-launcher/*`
|
|
350
|
+
// We have to point at the driver-localfs node_modules as that will have the right paths
|
|
351
|
+
// in place to load this module.
|
|
352
|
+
const devEnvPath = path.join(require.main.path, '..', 'driver-localfs', 'node_modules')
|
|
353
|
+
if (fs.existsSync(devEnvPath)) {
|
|
354
|
+
nodePaths.push(devEnvPath)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
appEnv.NODE_PATH = nodePaths.join(path.delimiter)
|
|
358
|
+
appEnv.LAUNCHER_VERSION = this.settings.launcherVersion
|
|
359
|
+
|
|
360
|
+
const processOptions = {
|
|
361
|
+
windowsHide: true,
|
|
362
|
+
env: appEnv,
|
|
363
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
364
|
+
cwd: path.join(this.settings.rootDir, this.settings.userDir)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const processArguments = [
|
|
368
|
+
'-u',
|
|
369
|
+
path.join(this.settings.rootDir, this.settings.userDir),
|
|
370
|
+
'-p',
|
|
371
|
+
this.settings.port
|
|
372
|
+
]
|
|
373
|
+
|
|
374
|
+
if (this.settings.stack?.memory && /^[1-9]\d*$/.test(this.settings.stack.memory)) {
|
|
375
|
+
const memLimit = Math.round(this.settings.stack.memory * 0.75)
|
|
376
|
+
processArguments.push(`--max-old-space-size=${memLimit}`)
|
|
377
|
+
}
|
|
378
|
+
this.options.execPathJs = path.join(this.options.nodeRedPath, 'node_modules', 'node-red', 'red.js')
|
|
379
|
+
processArguments.unshift(this.options.execPathJs)
|
|
380
|
+
this.options.execPath = process.execPath
|
|
381
|
+
this.proc = childProcess.spawn(
|
|
382
|
+
this.options.execPath,
|
|
383
|
+
processArguments,
|
|
384
|
+
processOptions
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
this.state = States.STARTING
|
|
388
|
+
|
|
389
|
+
this.proc.on('spawn', () => {
|
|
390
|
+
// only works at NodeJS 16+
|
|
391
|
+
// this.proc.pid
|
|
392
|
+
this.startTimes.push(Date.now())
|
|
393
|
+
if (this.startTimes.length > MAX_RESTART_COUNT) {
|
|
394
|
+
this.startTimes.shift()
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Performs an HTTP HEAD to the baseURL of Node-RED to check its liveness.
|
|
400
|
+
*/
|
|
401
|
+
const statusPoll = async () => {
|
|
402
|
+
if (this.targetState === States.STOPPED) {
|
|
403
|
+
return States.STOPPED
|
|
404
|
+
}
|
|
405
|
+
const parsedUrl = new URL(this.settings.baseURL)
|
|
406
|
+
parsedUrl.protocol = 'http:'
|
|
407
|
+
parsedUrl.host = '127.0.0.1'
|
|
408
|
+
parsedUrl.port = this.settings.port
|
|
409
|
+
const pollUrl = parsedUrl.toString()
|
|
410
|
+
|
|
411
|
+
let statusCode = 0
|
|
412
|
+
try {
|
|
413
|
+
const opts = {
|
|
414
|
+
headers: {
|
|
415
|
+
pragma: 'no-cache',
|
|
416
|
+
'Cache-Control': 'max-age=0, must-revalidate, no-cache'
|
|
417
|
+
},
|
|
418
|
+
timeout: { request: HEALTH_POLL_TIMEOUT },
|
|
419
|
+
retry: { limit: 0 }
|
|
420
|
+
}
|
|
421
|
+
// Use a HEAD request to minimise data transfer
|
|
422
|
+
const res = await got.head(pollUrl, opts)
|
|
423
|
+
statusCode = res.statusCode || 500
|
|
424
|
+
} catch (error) {
|
|
425
|
+
this.logBuffer.add({ level: 'system', msg: `Node-RED health check failed: ${error.toString()} (${pollUrl})` })
|
|
426
|
+
console.log('Failed to poll NR on ', pollUrl, error)
|
|
427
|
+
statusCode = error.response?.statusCode || 500
|
|
428
|
+
}
|
|
429
|
+
if (statusCode >= 200 && statusCode < 500) {
|
|
430
|
+
return States.RUNNING
|
|
431
|
+
}
|
|
432
|
+
throw new Error()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Called after Node-RED exits unexpectedly. It calculates the runtime
|
|
437
|
+
* duration and decides if we've detected a crash loop.
|
|
438
|
+
* If a loop is detected, restarts in safe mode.
|
|
439
|
+
* If a loop is detected and we're already in safe mode, stops Node-RED
|
|
440
|
+
* Otherwise, restarts normally
|
|
441
|
+
*/
|
|
442
|
+
const restartAfterUnexpectedExit = async () => {
|
|
443
|
+
const lastStartTime = this.startTimes[this.startTimes.length - 1]
|
|
444
|
+
const duration = Math.abs(Date.now() - lastStartTime)
|
|
445
|
+
this.runDurations.push(duration)
|
|
446
|
+
if (this.runDurations.length > MAX_RESTART_COUNT) {
|
|
447
|
+
this.runDurations.shift()
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
this.logBuffer.add({ level: 'system', msg: `Node-RED unexpectedly stopped after: ${Math.round(duration / 1000)}s` })
|
|
451
|
+
|
|
452
|
+
// if start count == MAX_RESTART_COUNT, then check for boot loop
|
|
453
|
+
if (this.startTimes.length === MAX_RESTART_COUNT) {
|
|
454
|
+
// calculate the average runtime
|
|
455
|
+
const avg = this.runDurations.reduce((a, b) => a + b, 0) / this.runDurations.length
|
|
456
|
+
|
|
457
|
+
// calculate the mean deviation of runtime durations
|
|
458
|
+
const meanDeviation = this.runDurations.reduce((a, b) => a + Math.abs(b - avg), 0) / this.runDurations.length
|
|
459
|
+
|
|
460
|
+
// if the deviation is small (i.e. crashing at a consistent rate)
|
|
461
|
+
// or the average runtime is low, then it is likely we in a boot loop
|
|
462
|
+
if (meanDeviation < MIN_RUNTIME_DEVIATION || avg < MIN_RUNTIME) {
|
|
463
|
+
// go to safe mode (or stop if already in safe mode)
|
|
464
|
+
this.startTimes = []
|
|
465
|
+
this.runDurations = []
|
|
466
|
+
if (this.targetState === States.SAFE) {
|
|
467
|
+
this.logBuffer.add({ level: 'system', msg: 'Node-RED restart loop detected whilst in safe mode. Stopping.' })
|
|
468
|
+
this.targetState = States.STOPPED
|
|
469
|
+
} else {
|
|
470
|
+
this.logBuffer.add({ level: 'system', msg: 'Node-RED restart loop detected. Restarting in safe mode.' })
|
|
471
|
+
this.targetState = States.SAFE
|
|
472
|
+
this.start()
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
this.start()
|
|
476
|
+
}
|
|
477
|
+
} else {
|
|
478
|
+
this.start()
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (this.healthPoll) {
|
|
483
|
+
clearInterval(this.healthPoll)
|
|
484
|
+
}
|
|
485
|
+
let errorCount = 0
|
|
486
|
+
this.healthPoll = setInterval(() => {
|
|
487
|
+
if (this.state === States.STARTING || this.state === States.RUNNING) {
|
|
488
|
+
statusPoll().then(() => {
|
|
489
|
+
// A healthy result
|
|
490
|
+
errorCount = 0
|
|
491
|
+
if (this.state === States.STARTING) {
|
|
492
|
+
this.state = States.RUNNING
|
|
493
|
+
}
|
|
494
|
+
}).catch(async _ => {
|
|
495
|
+
// Error polling Node-RED settings page
|
|
496
|
+
errorCount++
|
|
497
|
+
if ((this.state === States.STARTING && errorCount === HEALTH_POLL_MAX_STARTUP_ERROR_COUNT) ||
|
|
498
|
+
(this.state === States.RUNNING && errorCount === HEALTH_POLL_MAX_ERROR_COUNT)) {
|
|
499
|
+
this.logBuffer.add({ level: 'system', msg: 'Node-RED hang detected.' })
|
|
500
|
+
const targetState = this.state
|
|
501
|
+
|
|
502
|
+
// Calling stop will clear the health poll interval
|
|
503
|
+
await this.stop()
|
|
504
|
+
this.targetState = targetState
|
|
505
|
+
// Restart via restartAfterUnexpectedExit as it will check
|
|
506
|
+
// for a restart loop
|
|
507
|
+
restartAfterUnexpectedExit()
|
|
508
|
+
}
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
}, HEALTH_POLL_INTERVAL)
|
|
512
|
+
|
|
513
|
+
if (this.resourcePoll) {
|
|
514
|
+
clearInterval(this.resourcePoll)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.resourcePoll = setInterval(async () => {
|
|
518
|
+
const parsedUrl = new URL(this.settings.baseURL)
|
|
519
|
+
parsedUrl.protocol = 'http:'
|
|
520
|
+
parsedUrl.host = '127.0.0.1'
|
|
521
|
+
parsedUrl.port = this.settings.port
|
|
522
|
+
parsedUrl.pathname = '/ff/metrics'
|
|
523
|
+
const pollUrl = parsedUrl.toString()
|
|
524
|
+
|
|
525
|
+
const sample = await resourceSample(pollUrl, RESOURCE_POLL_INTERVAL)
|
|
526
|
+
this.sampleBuffer.add(sample)
|
|
527
|
+
|
|
528
|
+
// avg over the last minute for alerts
|
|
529
|
+
if (CPU_LIMIT || MEMORY_LIMIT) {
|
|
530
|
+
const avg = this.sampleBuffer.avgLastX(RESOURCE_ALERT_SAMPLES)
|
|
531
|
+
if (avg.count === RESOURCE_ALERT_SAMPLES) {
|
|
532
|
+
console.log(`${JSON.stringify(avg, null, 2)} ${avg.count}`)
|
|
533
|
+
if (CPU_LIMIT) {
|
|
534
|
+
if (avg.cpu > (CPU_LIMIT * 0.75)) {
|
|
535
|
+
if (this.cpuAuditLogged === 0) {
|
|
536
|
+
await this.logAuditEvent('resource.cpu', {
|
|
537
|
+
interval: (RESOURCE_POLL_INTERVAL * RESOURCE_ALERT_SAMPLES),
|
|
538
|
+
threshold: (RESOURCE_ALERT_THRESHOLD * 100)
|
|
539
|
+
})
|
|
540
|
+
this.cpuAuditLogged = RESOURCE_ALERT_SAMPLES
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
if (this.cpuAuditLogged > 0) {
|
|
544
|
+
this.cpuAuditLogged -= 1
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (MEMORY_LIMIT) {
|
|
549
|
+
if (avg.ps > (MEMORY_LIMIT * 0.75)) {
|
|
550
|
+
if (this.memoryAuditLogged === 0) {
|
|
551
|
+
await this.logAuditEvent('resource.memory', {
|
|
552
|
+
interval: (RESOURCE_POLL_INTERVAL * RESOURCE_ALERT_SAMPLES),
|
|
553
|
+
threshold: (RESOURCE_ALERT_THRESHOLD * 100)
|
|
554
|
+
})
|
|
555
|
+
this.memoryAuditLogged = RESOURCE_ALERT_SAMPLES
|
|
556
|
+
}
|
|
557
|
+
} else {
|
|
558
|
+
if (this.memoryAuditLogged > 0) {
|
|
559
|
+
this.memoryAuditLogged -= 1
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}, RESOURCE_POLL_INTERVAL * 1000)
|
|
566
|
+
|
|
567
|
+
this.proc.on('close', (code, signal) => {
|
|
568
|
+
// console.log("node-red closed with", {code,signal})
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
this.proc.on('exit', async (code, signal) => {
|
|
572
|
+
this.logBuffer.add({ level: 'system', msg: `Node-RED exited rc=${code} signal=${signal}` })
|
|
573
|
+
// When childProcess.kill() is executed on windows, the exit code is null and the signal is 'SIGTERM'.
|
|
574
|
+
// So long as the process was instructed to STOP and its state is STOPPED, consider this a clean exit
|
|
575
|
+
if (process.platform === 'win32' && code === null && signal === 'SIGTERM' && this.targetState === States.STOPPED && this.state === States.STOPPED) {
|
|
576
|
+
code = 0
|
|
577
|
+
}
|
|
578
|
+
if (code === 0) {
|
|
579
|
+
this.state = States.STOPPED
|
|
580
|
+
await this.logAuditEvent('stopped')
|
|
581
|
+
} else {
|
|
582
|
+
this.state = States.CRASHED
|
|
583
|
+
await this.logAuditEvent('crashed')
|
|
584
|
+
|
|
585
|
+
// Only restart if our target state is not stopped
|
|
586
|
+
if (this.targetState !== States.STOPPED) {
|
|
587
|
+
restartAfterUnexpectedExit()
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (this.exitCallback) {
|
|
591
|
+
this.exitCallback()
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
this.proc.on('error', (err) => {
|
|
596
|
+
this.logBuffer.add({ level: 'system', msg: `Error with Node-RED process: ${err.toString()}` })
|
|
597
|
+
console.log('Process error: ' + err.toString())
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
let stdoutBuffer = ''
|
|
601
|
+
this.proc.stdout.on('data', (data) => {
|
|
602
|
+
// Do not assume `data` is a complete log record.
|
|
603
|
+
// Parse until newline
|
|
604
|
+
stdoutBuffer = stdoutBuffer + data
|
|
605
|
+
let linebreak = stdoutBuffer.indexOf('\n')
|
|
606
|
+
while (linebreak > -1) {
|
|
607
|
+
const line = stdoutBuffer.substring(0, linebreak)
|
|
608
|
+
if (line.length > 0) {
|
|
609
|
+
if (line[0] === '{' && line[line.length - 1] === '}') {
|
|
610
|
+
// In case something console.log's directly, we can't assume the line is JSON
|
|
611
|
+
// from our logger
|
|
612
|
+
try {
|
|
613
|
+
this.logBuffer.add(JSON.parse(line))
|
|
614
|
+
} catch (err) {
|
|
615
|
+
this.logBuffer.add({ msg: line })
|
|
616
|
+
}
|
|
617
|
+
} else {
|
|
618
|
+
this.logBuffer.add({ msg: line })
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
stdoutBuffer = stdoutBuffer.substring(linebreak + 1)
|
|
622
|
+
linebreak = stdoutBuffer.indexOf('\n')
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async stop () {
|
|
628
|
+
this.logBuffer.add({ level: 'system', msg: 'Stopping Node-RED' })
|
|
629
|
+
// Stop the health poll - do not want to mistake a stopping/stopped Node-RED
|
|
630
|
+
// for an unhealthly one
|
|
631
|
+
clearInterval(this.healthPoll)
|
|
632
|
+
clearInterval(this.resourcePoll)
|
|
633
|
+
this.targetState = States.STOPPED
|
|
634
|
+
if (this.deferredStop) {
|
|
635
|
+
// A stop request is already inflight - return the existing deferred object
|
|
636
|
+
return this.deferredStop
|
|
637
|
+
}
|
|
638
|
+
if (this.proc) {
|
|
639
|
+
// Setup a promise that will resolve once the process has really exited
|
|
640
|
+
this.deferredStop = new Promise((resolve, reject) => {
|
|
641
|
+
// Setup a timeout so we can more forcefully kill Node-RED
|
|
642
|
+
// if it has hung
|
|
643
|
+
this.exitTimeout = setTimeout(() => {
|
|
644
|
+
this.logBuffer.add({ level: 'system', msg: 'Node-RED stop timed-out. Sending SIGKILL' })
|
|
645
|
+
if (this.proc) {
|
|
646
|
+
this.proc.kill('SIGKILL')
|
|
647
|
+
}
|
|
648
|
+
}, NODE_RED_STOP_TIMEOUT)
|
|
649
|
+
// Setup a callback for when the process has actually exited
|
|
650
|
+
this.exitCallback = () => {
|
|
651
|
+
clearTimeout(this.exitTimeout)
|
|
652
|
+
this.exitCallback = null
|
|
653
|
+
this.deferredStop = null
|
|
654
|
+
this.exitTimeout = null
|
|
655
|
+
this.proc.unref()
|
|
656
|
+
this.proc = undefined
|
|
657
|
+
resolve()
|
|
658
|
+
}
|
|
659
|
+
// Send a kill signal. On Linux this will be a SIGTERM and
|
|
660
|
+
// allow Node-RED to shutdown cleanly. Windows looks like it does
|
|
661
|
+
// it more forcefully by default.
|
|
662
|
+
this.proc.kill()
|
|
663
|
+
this.state = States.STOPPED
|
|
664
|
+
})
|
|
665
|
+
return this.deferredStop
|
|
666
|
+
} else {
|
|
667
|
+
this.state = States.STOPPED
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async revokeUserToken (token) { // logout:nodered(step-5)
|
|
672
|
+
this.logBuffer.add({ level: 'system', msg: 'Node-RED logout requested' })
|
|
673
|
+
if (this.state !== States.RUNNING) {
|
|
674
|
+
// not running
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
const adminAPI = `${this.settings.baseURL}/auth/revoke`
|
|
679
|
+
const json = { token, noRedirect: true }
|
|
680
|
+
const headers = {
|
|
681
|
+
authorization: 'Bearer ' + token,
|
|
682
|
+
'cache-control': 'no-cache',
|
|
683
|
+
'content-type': 'application/json',
|
|
684
|
+
'node-red-api-version': 'v2',
|
|
685
|
+
pragma: 'no-cache',
|
|
686
|
+
Referer: this.settings.baseURL
|
|
687
|
+
}
|
|
688
|
+
await got.post(adminAPI, { json, headers })
|
|
689
|
+
} catch (error) {
|
|
690
|
+
this.logBuffer.add({ level: 'system', msg: `Error logging out Node-RED: ${error.toString()}` })
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
module.exports = { Launcher, States }
|