@flowfuse/driver-kubernetes 1.14.0

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/kubernetes.js ADDED
@@ -0,0 +1,1132 @@
1
+ const got = require('got')
2
+ const k8s = require('@kubernetes/client-node')
3
+ const _ = require('lodash')
4
+
5
+ /**
6
+ * Kubernates Container driver
7
+ *
8
+ * Handles the creation and deletation of containers to back Projects
9
+ *
10
+ * This driver creates Projects backed by Kubernates
11
+ *
12
+ * @module kubernates
13
+ * @memberof forge.containers.drivers
14
+ *
15
+ */
16
+
17
+ const podTemplate = {
18
+ apiVersion: 'v1',
19
+ kind: 'Pod',
20
+ metadata: {
21
+ // name: "k8s-client-test",
22
+ labels: {
23
+ // name: "k8s-client-test",
24
+ nodered: 'true'
25
+ // app: "k8s-client-test",
26
+ }
27
+ },
28
+ spec: {
29
+ securityContext: {
30
+ runAsUser: 1000,
31
+ runAsGroup: 1000,
32
+ fsGroup: 1000
33
+ },
34
+ containers: [
35
+ {
36
+ resources: {
37
+ request: {
38
+ // 10th of a core
39
+ cpu: '100m',
40
+ memory: '128Mi'
41
+ },
42
+ limits: {
43
+ cpu: '125m',
44
+ memory: '192Mi'
45
+ }
46
+ },
47
+ name: 'node-red',
48
+ // image: "docker-pi.local:5000/bronze-node-red",
49
+ imagePullPolicy: 'Always',
50
+ env: [
51
+ // {name: "APP_NAME", value: "test"},
52
+ { name: 'TZ', value: 'Europe/London' }
53
+ ],
54
+ ports: [
55
+ { name: 'web', containerPort: 1880, protocol: 'TCP' }
56
+ ],
57
+ securityContext: {
58
+ allowPrivilegeEscalation: false
59
+ }
60
+ }
61
+ ]
62
+ // nodeSelector: {
63
+ // role: 'projects'
64
+ // }
65
+
66
+ },
67
+ enableServiceLinks: false
68
+ }
69
+
70
+ const deploymentTemplate = {
71
+ apiVersion: 'apps/v1',
72
+ kind: 'Deployment',
73
+ metadata: {
74
+ // name: "k8s-client-test-deployment",
75
+ labels: {
76
+ // name: "k8s-client-test-deployment",
77
+ nodered: 'true'
78
+ // app: "k8s-client-test-deployment"
79
+ }
80
+ },
81
+ spec: {
82
+ replicas: 1,
83
+ selector: {
84
+ matchLabels: {
85
+ // app: "k8s-client-test-deployment"
86
+ }
87
+ },
88
+ template: {
89
+ metadata: {
90
+ labels: {
91
+ // name: "k8s-client-test-deployment",
92
+ nodered: 'true'
93
+ // app: "k8s-client-test-deployment"
94
+ }
95
+ },
96
+ spec: {
97
+ securityContext: {
98
+ runAsUser: 1000,
99
+ runAsGroup: 1000,
100
+ fsGroup: 1000
101
+ },
102
+ containers: [
103
+ {
104
+ resources: {
105
+ request: {
106
+ // 10th of a core
107
+ cpu: '100m',
108
+ memory: '128Mi'
109
+ },
110
+ limits: {
111
+ cpu: '125m',
112
+ memory: '192Mi'
113
+ }
114
+ },
115
+ name: 'node-red',
116
+ // image: "docker-pi.local:5000/bronze-node-red",
117
+ imagePullPolicy: 'Always',
118
+ env: [
119
+ // {name: "APP_NAME", value: "test"},
120
+ { name: 'TZ', value: 'Europe/London' }
121
+ ],
122
+ ports: [
123
+ { name: 'web', containerPort: 1880, protocol: 'TCP' },
124
+ { name: 'management', containerPort: 2880, protocol: 'TCP' }
125
+ ],
126
+ securityContext: {
127
+ allowPrivilegeEscalation: false
128
+ }
129
+ }
130
+ ]
131
+ },
132
+ enableServiceLinks: false
133
+ }
134
+ }
135
+ }
136
+
137
+ const serviceTemplate = {
138
+ apiVersion: 'v1',
139
+ kind: 'Service',
140
+ metadata: {
141
+ // name: "k8s-client-test-service"
142
+ },
143
+ spec: {
144
+ type: 'ClusterIP',
145
+ selector: {
146
+ // name: "k8s-client-test"
147
+ },
148
+ ports: [
149
+ { name: 'web', port: 1880, protocol: 'TCP' },
150
+ { name: 'management', port: 2880, protocol: 'TCP' }
151
+ ]
152
+ }
153
+ }
154
+
155
+ const ingressTemplate = {
156
+ apiVersion: 'networking.k8s.io/v1',
157
+ kind: 'Ingress',
158
+ metadata: {
159
+ // name: "k8s-client-test-ingress",
160
+ // namespace: 'flowforge',
161
+ annotations: process.env.INGRESS_ANNOTATIONS ? JSON.parse(process.env.INGRESS_ANNOTATIONS) : {}
162
+ },
163
+ spec: {
164
+ ingressClassName: process.env.INGRESS_CLASS_NAME ? process.env.INGRESS_CLASS_NAME : null,
165
+ rules: [
166
+ {
167
+ // host: "k8s-client-test" + "." + "ubuntu.local",
168
+ http: {
169
+ paths: [
170
+ {
171
+ pathType: 'Prefix',
172
+ path: '/',
173
+ backend: {
174
+ service: {
175
+ // name: 'k8s-client-test-service',
176
+ port: { number: 1880 }
177
+ }
178
+ }
179
+ }
180
+ ]
181
+ }
182
+ }
183
+ ]
184
+ }
185
+ }
186
+
187
+ const createDeployment = async (project, options) => {
188
+ const stack = project.ProjectStack.properties
189
+
190
+ const localDeployment = JSON.parse(JSON.stringify(deploymentTemplate))
191
+ const localPod = localDeployment.spec.template
192
+ localDeployment.metadata.name = project.safeName
193
+ localDeployment.metadata.labels.name = project.safeName
194
+ localDeployment.metadata.labels.app = project.id
195
+ localDeployment.spec.selector.matchLabels.app = project.id
196
+
197
+ // Examples:
198
+ // 1. With this affinity definitions we can skip toarations
199
+ // affinity:
200
+ // nodeAffinity:
201
+ // requiredDuringSchedulingIgnoredDuringExecution:
202
+ // nodeSelectorTerms:
203
+ // - matchExpressions:
204
+ // - key: node-owner
205
+ // operator: In
206
+ // values:
207
+ // - streaming-services-transcribe
208
+
209
+ // 2. With this affinity
210
+ // preferredDuringSchedulingIgnoredDuringExecution:
211
+ // - weight: 100
212
+ // preference:
213
+ // matchExpressions:
214
+ // - key: purpose
215
+ // operator: In
216
+ // values:
217
+ // - skills
218
+ // ---> we need these tolerations
219
+ // tolerations:
220
+ // - key: purpose
221
+ // operator: Equal
222
+ // value: skills
223
+ // effect: NoSchedule
224
+ if (process.env.DEPLOYMENT_TOLERATIONS !== undefined) {
225
+ // TOLERATIONS
226
+ try {
227
+ localPod.spec.tolerations = JSON.parse(process.env.DEPLOYMENT_TOLERATIONS)
228
+ this._app.log.info(`DEPLOYMENT TOLERATIONS loaded: ${localPod.spec.tolerations}`)
229
+ } catch (err) {
230
+ this._app.log.error(`TOLERATIONS load error: ${err}`)
231
+ }
232
+ }
233
+
234
+ localPod.metadata.labels.app = project.id
235
+ localPod.metadata.labels.name = project.safeName
236
+ localPod.spec.serviceAccount = process.env.EDITOR_SERVICE_ACCOUNT
237
+
238
+ if (stack.container) {
239
+ localPod.spec.containers[0].image = stack.container
240
+ } else {
241
+ localPod.spec.containers[0].image = `${this._options.registry}flowforge/node-red`
242
+ }
243
+
244
+ const baseURL = new URL(this._app.config.base_url)
245
+ let projectURL
246
+ if (!project.url.startsWith('http')) {
247
+ projectURL = `${baseURL.protocol}//${project.safeName}.${this._options.domain}`
248
+ } else {
249
+ const temp = new URL(project.url)
250
+ projectURL = `${temp.protocol}//${temp.hostname}${temp.port ? ':' + temp.port : ''}`
251
+ }
252
+
253
+ const teamID = this._app.db.models.Team.encodeHashid(project.TeamId)
254
+ const authTokens = await project.refreshAuthTokens()
255
+ localPod.spec.containers[0].env.push({ name: 'FORGE_CLIENT_ID', value: authTokens.clientID })
256
+ localPod.spec.containers[0].env.push({ name: 'FORGE_CLIENT_SECRET', value: authTokens.clientSecret })
257
+ localPod.spec.containers[0].env.push({ name: 'FORGE_URL', value: this._app.config.api_url })
258
+ localPod.spec.containers[0].env.push({ name: 'BASE_URL', value: projectURL })
259
+ localPod.spec.containers[0].env.push({ name: 'FORGE_TEAM_ID', value: teamID })
260
+ localPod.spec.containers[0].env.push({ name: 'FORGE_PROJECT_ID', value: project.id })
261
+ localPod.spec.containers[0].env.push({ name: 'FORGE_PROJECT_TOKEN', value: authTokens.token })
262
+ // Inbound connections for k8s disabled by default
263
+ localPod.spec.containers[0].env.push({ name: 'FORGE_NR_NO_TCP_IN', value: 'true' }) // MVP. Future iteration could present this to YML or UI
264
+ localPod.spec.containers[0].env.push({ name: 'FORGE_NR_NO_UDP_IN', value: 'true' }) // MVP. Future iteration could present this to YML or UI
265
+ if (authTokens.broker) {
266
+ localPod.spec.containers[0].env.push({ name: 'FORGE_BROKER_URL', value: authTokens.broker.url })
267
+ localPod.spec.containers[0].env.push({ name: 'FORGE_BROKER_USERNAME', value: authTokens.broker.username })
268
+ localPod.spec.containers[0].env.push({ name: 'FORGE_BROKER_PASSWORD', value: authTokens.broker.password })
269
+ }
270
+ if (this._app.license.active()) {
271
+ localPod.spec.containers[0].env.push({ name: 'FORGE_LICENSE_TYPE', value: 'ee' })
272
+ }
273
+ if (stack.memory) {
274
+ localPod.spec.containers[0].env.push({ name: 'FORGE_MEMORY_LIMIT', value: `${stack.memory}` })
275
+ }
276
+ if (stack.cpu) {
277
+ localPod.spec.containers[0].env.push({ name: 'FORGE_CPU_LIMIT', value: `${stack.cpu}` })
278
+ }
279
+
280
+ const credentialSecret = await project.getSetting('credentialSecret')
281
+ if (credentialSecret) {
282
+ localPod.spec.containers[0].env.push({ name: 'FORGE_NR_SECRET', value: credentialSecret })
283
+ }
284
+
285
+ if (this._app.config.driver.options.projectSelector) {
286
+ localPod.spec.nodeSelector = this._app.config.driver.options.projectSelector
287
+ }
288
+ if (this._app.config.driver.options.registrySecrets) {
289
+ localPod.spec.imagePullSecrets = []
290
+ this._app.config.driver.options.registrySecrets.forEach(sec => {
291
+ const entry = {
292
+ name: sec
293
+ }
294
+ localPod.spec.imagePullSecrets.push(entry)
295
+ })
296
+ }
297
+
298
+ if (this._app.config.driver.options.privateCA) {
299
+ localPod.spec.containers[0].volumeMounts = [
300
+ {
301
+ name: 'cacert',
302
+ mountPath: '/usr/local/ssl-certs',
303
+ readOnly: true
304
+ }
305
+ ]
306
+ localPod.spec.volumes = [
307
+ {
308
+ name: 'cacert',
309
+ configMap: {
310
+ name: this._app.config.driver.options.privateCA
311
+ }
312
+ }
313
+ ]
314
+ localPod.spec.containers[0].env.push({ name: 'NODE_EXTRA_CA_CERTS', value: '/usr/local/ssl-certs/chain.pem' })
315
+ }
316
+
317
+ if (stack.memory && stack.cpu) {
318
+ localPod.spec.containers[0].resources.request.memory = `${stack.memory}Mi`
319
+ localPod.spec.containers[0].resources.limits.memory = `${stack.memory}Mi`
320
+ localPod.spec.containers[0].resources.request.cpu = `${stack.cpu * 10}m`
321
+ localPod.spec.containers[0].resources.limits.cpu = `${stack.cpu * 10}m`
322
+ }
323
+
324
+ const ha = await project.getSetting('ha')
325
+ if (ha?.replicas > 1) {
326
+ localDeployment.spec.replicas = ha.replicas
327
+ }
328
+
329
+ project.url = projectURL
330
+ await project.save()
331
+
332
+ return localDeployment
333
+ }
334
+
335
+ const createService = async (project, options) => {
336
+ const prefix = project.safeName.match(/^[0-9]/) ? 'srv-' : ''
337
+
338
+ const localService = JSON.parse(JSON.stringify(serviceTemplate))
339
+ localService.metadata.name = `${prefix}${project.safeName}`
340
+ localService.spec.selector.name = project.safeName
341
+ return localService
342
+ }
343
+
344
+ const mustache = (string, data = {}) =>
345
+ Object.entries(data).reduce((res, [key, value]) => res.replace(new RegExp(`{{\\s*${key}\\s*}}`, 'g'), value), string)
346
+
347
+ const createIngress = async (project, options) => {
348
+ const prefix = project.safeName.match(/^[0-9]/) ? 'srv-' : ''
349
+ const url = new URL(project.url)
350
+
351
+ // exposedData available for annotation replacements
352
+ const exposedData = {
353
+ serviceName: `${prefix}${project.safeName}`,
354
+ instanceURL: url.href,
355
+ instanceHost: url.host,
356
+ instanceProtocol: url.protocol
357
+ }
358
+
359
+ this._app.log.info('K8S DRIVER: start parse ingress template')
360
+
361
+ const localIngress = JSON.parse(JSON.stringify(ingressTemplate))
362
+
363
+ // process annotations with potential replacements
364
+ Object.keys(localIngress.metadata.annotations).forEach((key) => {
365
+ localIngress.metadata.annotations[key] = mustache(localIngress.metadata.annotations[key], exposedData)
366
+ })
367
+
368
+ localIngress.metadata.name = project.safeName
369
+ localIngress.spec.rules[0].host = url.host
370
+ localIngress.spec.rules[0].http.paths[0].backend.service.name = `${prefix}${project.safeName}`
371
+
372
+ return localIngress
373
+ }
374
+
375
+ const createProject = async (project, options) => {
376
+ const namespace = this._app.config.driver.options.projectNamespace || 'flowforge'
377
+
378
+ const localDeployment = await createDeployment(project, options)
379
+ const localService = await createService(project, options)
380
+ const localIngress = await createIngress(project, options)
381
+
382
+ try {
383
+ await this._k8sAppApi.createNamespacedDeployment(namespace, localDeployment)
384
+ } catch (err) {
385
+ if (err.statusCode === 409) {
386
+ // If deployment exists, perform an upgrade
387
+ this._app.log.warn(`[k8s] Deployment for project ${project.id} already exists. Upgrading deployment`)
388
+ const result = await this._k8sAppApi.readNamespacedDeployment(project.safeName, namespace)
389
+
390
+ const existingDeployment = result.body
391
+ // Check if the metadata and spec are aligned. They won't be though (at minimal because we regenerate auth)
392
+ if (!_.isEqual(existingDeployment.metadata, localDeployment.metadata) || !_.isEqual(existingDeployment.spec, localDeployment.spec)) {
393
+ // If not aligned, replace the deployment
394
+ await this._k8sAppApi.replaceNamespacedDeployment(project.safeName, namespace, localDeployment)
395
+ }
396
+ } else {
397
+ // Log other errors and rethrow them for additional higher-level handling
398
+ this._app.log.error(`[k8s] Unexpected error creating deployment for project ${project.id}.`)
399
+ this._app.log.error(`[k8s] deployment ${JSON.stringify(localDeployment, undefined, 2)}`)
400
+ this._app.log.error(err)
401
+ // rethrow the error so the wrapper knows this hasn't worked
402
+ throw err
403
+ }
404
+ }
405
+
406
+ await new Promise((resolve, reject) => {
407
+ let counter = 0
408
+ const pollInterval = setInterval(async () => {
409
+ try {
410
+ await this._k8sAppApi.readNamespacedDeployment(project.safeName, this._namespace)
411
+ clearInterval(pollInterval)
412
+ resolve()
413
+ } catch (err) {
414
+ // hmm
415
+ counter++
416
+ if (counter > this._k8sRetries) {
417
+ clearInterval(pollInterval)
418
+ this._app.log.error(`[k8s] Project ${project.id} - timeout waiting for Deployment`)
419
+ reject(new Error('Timed out to creating Deployment'))
420
+ }
421
+ }
422
+ }, this._k8sDelay)
423
+ })
424
+
425
+ try {
426
+ await this._k8sApi.createNamespacedService(namespace, localService)
427
+ } catch (err) {
428
+ if (err.statusCode === 409) {
429
+ this._app.log.warn(`[k8s] Service for project ${project.id} already exists, proceeding...`)
430
+ } else {
431
+ if (project.state !== 'suspended') {
432
+ this._app.log.error(`[k8s] Project ${project.id} - error creating service: ${err.toString()}`)
433
+ throw err
434
+ }
435
+ }
436
+ }
437
+
438
+ const prefix = project.safeName.match(/^[0-9]/) ? 'srv-' : ''
439
+ await new Promise((resolve, reject) => {
440
+ let counter = 0
441
+ const pollInterval = setInterval(async () => {
442
+ try {
443
+ await this._k8sApi.readNamespacedService(prefix + project.safeName, this._namespace)
444
+ clearInterval(pollInterval)
445
+ resolve()
446
+ } catch (err) {
447
+ counter++
448
+ if (counter > this._k8sRetries) {
449
+ clearInterval(pollInterval)
450
+ this._app.log.error(`[k8s] Project ${project.id} - timeout waiting for Service`)
451
+ reject(new Error('Timed out to creating Service'))
452
+ }
453
+ }
454
+ }, this._k8sDelay)
455
+ })
456
+
457
+ try {
458
+ await this._k8sNetApi.createNamespacedIngress(namespace, localIngress)
459
+ } catch (err) {
460
+ if (err.statusCode === 409) {
461
+ this._app.log.warn(`[k8s] Ingress for project ${project.id} already exists, proceeding...`)
462
+ } else {
463
+ if (project.state !== 'suspended') {
464
+ this._app.log.error(`[k8s] Project ${project.id} - error creating ingress: ${err.toString()}`)
465
+ throw err
466
+ }
467
+ }
468
+ }
469
+
470
+ await new Promise((resolve, reject) => {
471
+ let counter = 0
472
+ const pollInterval = setInterval(async () => {
473
+ try {
474
+ await this._k8sNetApi.readNamespacedIngress(project.safeName, this._namespace)
475
+ clearInterval(pollInterval)
476
+ resolve()
477
+ } catch (err) {
478
+ counter++
479
+ if (counter > this._k8sRetries) {
480
+ clearInterval(pollInterval)
481
+ this._app.log.error(`[k8s] Project ${project.id} - timeout waiting for Ingress`)
482
+ reject(new Error('Timed out to creating Ingress'))
483
+ }
484
+ }
485
+ }, this._k8sDelay)
486
+ })
487
+
488
+ await project.updateSetting('k8sType', 'deployment')
489
+
490
+ this._app.log.debug(`[k8s] Container ${project.id} started`)
491
+ project.state = 'running'
492
+ await project.save()
493
+
494
+ this._projects[project.id].state = 'starting'
495
+ }
496
+
497
+ // eslint-disable-next-line no-unused-vars
498
+ const createPod = async (project, options) => {
499
+ // const namespace = this._app.config.driver.options.projectNamespace || 'flowforge'
500
+ const stack = project.ProjectStack.properties
501
+
502
+ const localPod = JSON.parse(JSON.stringify(podTemplate))
503
+ localPod.metadata.name = project.safeName
504
+ localPod.metadata.labels.name = project.safeName
505
+ localPod.metadata.labels.app = project.id
506
+ if (stack.container) {
507
+ localPod.spec.containers[0].image = stack.container
508
+ } else {
509
+ localPod.spec.containers[0].image = `${this._options.registry}flowforge/node-red`
510
+ }
511
+
512
+ const baseURL = new URL(this._app.config.base_url)
513
+ const projectURL = `${baseURL.protocol}//${project.safeName}.${this._options.domain}`
514
+ const teamID = this._app.db.models.Team.encodeHashid(project.TeamId)
515
+ const authTokens = await project.refreshAuthTokens()
516
+ localPod.spec.containers[0].env.push({ name: 'FORGE_CLIENT_ID', value: authTokens.clientID })
517
+ localPod.spec.containers[0].env.push({ name: 'FORGE_CLIENT_SECRET', value: authTokens.clientSecret })
518
+ localPod.spec.containers[0].env.push({ name: 'FORGE_URL', value: this._app.config.api_url })
519
+ localPod.spec.containers[0].env.push({ name: 'BASE_URL', value: projectURL })
520
+ localPod.spec.containers[0].env.push({ name: 'FORGE_TEAM_ID', value: teamID })
521
+ localPod.spec.containers[0].env.push({ name: 'FORGE_PROJECT_ID', value: project.id })
522
+ localPod.spec.containers[0].env.push({ name: 'FORGE_PROJECT_TOKEN', value: authTokens.token })
523
+ // Inbound connections for k8s disabled by default
524
+ localPod.spec.containers[0].env.push({ name: 'FORGE_NR_NO_TCP_IN', value: 'true' }) // MVP. Future iteration could present this to YML or UI
525
+ localPod.spec.containers[0].env.push({ name: 'FORGE_NR_NO_UDP_IN', value: 'true' }) // MVP. Future iteration could present this to YML or UI
526
+ if (authTokens.broker) {
527
+ localPod.spec.containers[0].env.push({ name: 'FORGE_BROKER_URL', value: authTokens.broker.url })
528
+ localPod.spec.containers[0].env.push({ name: 'FORGE_BROKER_USERNAME', value: authTokens.broker.username })
529
+ localPod.spec.containers[0].env.push({ name: 'FORGE_BROKER_PASSWORD', value: authTokens.broker.password })
530
+ }
531
+ if (this._app.license.active()) {
532
+ localPod.spec.containers[0].env.push({ name: 'FORGE_LICENSE_TYPE', value: 'ee' })
533
+ }
534
+
535
+ const credentialSecret = await project.getSetting('credentialSecret')
536
+ if (credentialSecret) {
537
+ localPod.spec.containers[0].env.push({ name: 'FORGE_NR_SECRET', value: credentialSecret })
538
+ }
539
+
540
+ if (this._app.config.driver.options.projectSelector) {
541
+ localPod.spec.nodeSelector = this._app.config.driver.options.projectSelector
542
+ }
543
+ if (this._app.config.driver.options.registrySecrets) {
544
+ localPod.spec.imagePullSecrets = []
545
+ this._app.config.driver.options.registrySecrets.forEach(sec => {
546
+ const entry = {
547
+ name: sec
548
+ }
549
+ localPod.spec.imagePullSecrets.push(entry)
550
+ })
551
+ }
552
+
553
+ if (stack.memory && stack.cpu) {
554
+ localPod.spec.containers[0].resources.request.memory = `${stack.memory}Mi`
555
+ localPod.spec.containers[0].resources.limits.memory = `${stack.memory}Mi`
556
+ localPod.spec.containers[0].resources.request.cpu = `${stack.cpu * 10}m`
557
+ localPod.spec.containers[0].resources.limits.cpu = `${stack.cpu * 10}m`
558
+ }
559
+
560
+ project.url = projectURL
561
+ await project.save()
562
+
563
+ return localPod
564
+ }
565
+
566
+ const getEndpoints = async (project) => {
567
+ const prefix = project.safeName.match(/^[0-9]/) ? 'srv-' : ''
568
+ if (await project.getSetting('ha')) {
569
+ const endpoints = await this._k8sApi.readNamespacedEndpoints(`${prefix}${project.safeName}`, this._namespace)
570
+ const addresses = endpoints.body.subsets[0].addresses.map(a => { return a.ip })
571
+ const hosts = []
572
+ for (const address in addresses) {
573
+ hosts.push(addresses[address])
574
+ }
575
+ return hosts
576
+ } else {
577
+ return [`${prefix}${project.safeName}.${this._namespace}`]
578
+ }
579
+ }
580
+
581
+ module.exports = {
582
+ /**
583
+ * Initialises this driver
584
+ * @param {string} app - the Vue application
585
+ * @param {object} options - A set of configuration options for the driver
586
+ * @return {forge.containers.ProjectArguments}
587
+ */
588
+ init: async (app, options) => {
589
+ this._app = app
590
+ this._projects = {}
591
+ this._options = options
592
+
593
+ this._namespace = this._app.config.driver.options.projectNamespace || 'flowforge'
594
+ this._k8sDelay = this._app.config.driver.options.k8sDelay || 1000
595
+ this._k8sRetries = this._app.config.driver.options.k8sRetries || 10
596
+
597
+ const kc = new k8s.KubeConfig()
598
+
599
+ options.registry = app.config.driver.options?.registry || '' // use docker hub registry
600
+
601
+ if (options.registry !== '' && !options.registry.endsWith('/')) {
602
+ options.registry += '/'
603
+ }
604
+
605
+ kc.loadFromDefault()
606
+
607
+ this._k8sApi = kc.makeApiClient(k8s.CoreV1Api)
608
+ this._k8sAppApi = kc.makeApiClient(k8s.AppsV1Api)
609
+ this._k8sNetApi = kc.makeApiClient(k8s.NetworkingV1Api)
610
+
611
+ // Get a list of all projects - with the absolute minimum of fields returned
612
+ const projects = await app.db.models.Project.findAll({
613
+ attributes: [
614
+ 'id',
615
+ 'name',
616
+ 'state',
617
+ 'ProjectStackId',
618
+ 'TeamId'
619
+ ]
620
+ })
621
+ projects.forEach(async (project) => {
622
+ if (this._projects[project.id] === undefined) {
623
+ this._projects[project.id] = {
624
+ state: 'unknown'
625
+ }
626
+ }
627
+ })
628
+
629
+ this._initialCheckTimeout = setTimeout(() => {
630
+ this._app.log.debug('[k8s] Restarting projects')
631
+ const namespace = this._namespace
632
+ projects.forEach(async (project) => {
633
+ try {
634
+ if (project.state === 'suspended') {
635
+ // Do not restart suspended projects
636
+ return
637
+ }
638
+
639
+ // need to upgrade bare pods to deployments
640
+
641
+ // try {
642
+ // this._app.log.info(`[k8s] Testing ${project.id} in ${namespace} is bare pod`)
643
+ // await this._k8sApi.readNamespacedPodStatus(project.safeName, namespace)
644
+ // // should only get here is a bare pod exists
645
+ // this._app.log.info(`[k8s] upgrading ${project.id} to deployment`)
646
+ // const fullProject = await this._app.db.models.Project.byId(project.id)
647
+ // const localDeployment = await createDeployment(fullProject, options)
648
+ // this._k8sAppApi.createNamespacedDeployment(namespace, localDeployment)
649
+ // .then(() => {
650
+ // return this._k8sApi.deleteNamespacedPod(project.safeName, namespace)
651
+ // })
652
+ // .catch(err => {
653
+ // this._app.log.error(`[k8s] failed to upgrade ${project.id} to deployment`)
654
+ // })
655
+ // // it's just been created, not need to check if it still exists in the next block
656
+ // return
657
+ // } catch (err) {
658
+ // // bare pod not found can move on
659
+ // this._app.log.info(`[k8s] ${project.id} in ${namespace} is not bare pod`)
660
+ // }
661
+
662
+ // look for missing projects
663
+ const currentType = await project.getSetting('k8sType')
664
+ if (currentType === 'deployment') {
665
+ try {
666
+ this._app.log.info(`[k8s] Testing ${project.id} in ${namespace} deployment exists`)
667
+ await this._k8sAppApi.readNamespacedDeployment(project.safeName, namespace)
668
+ this._app.log.info(`[k8s] deployment ${project.id} in ${namespace} found`)
669
+ } catch (err) {
670
+ this._app.log.error(`[k8s] Error while reading namespaced deployment for project '${project.safeName}' ${project.id}. Error msg=${err.message}, stack=${err.stack}`)
671
+ this._app.log.info(`[k8s] Project ${project.id} - recreating deployment`)
672
+ const fullProject = await this._app.db.models.Project.byId(project.id)
673
+ await createProject(fullProject, options)
674
+ }
675
+ } else {
676
+ try {
677
+ // pod already running
678
+ this._app.log.info(`[k8s] Testing ${project.id} in ${namespace} pod exists`)
679
+ await this._k8sApi.readNamespacedPodStatus(project.safeName, namespace)
680
+ this._app.log.info(`[k8s] pod ${project.id} in ${namespace} found`)
681
+ } catch (err) {
682
+ this._app.log.debug(`[k8s] Project ${project.id} - recreating deployment`)
683
+ const fullProject = await this._app.db.models.Project.byId(project.id)
684
+ await createProject(fullProject, options)
685
+ }
686
+ }
687
+ } catch (err) {
688
+ this._app.log.error(`[k8s] Project ${project.id} - error resuming project: ${err.stack}`)
689
+ }
690
+ })
691
+ }, 1000)
692
+
693
+ // need to work out what we can expose for K8s
694
+ return {
695
+ stack: {
696
+ properties: {
697
+ cpu: {
698
+ label: 'CPU Cores (%)',
699
+ validate: '^([1-9][0-9]?|100)$',
700
+ invalidMessage: 'Invalid value - must be a number between 1 and 100',
701
+ description: 'How much of a single CPU core each Project should receive'
702
+ },
703
+ memory: {
704
+ label: 'Memory (MB)',
705
+ validate: '^[1-9]\\d*$',
706
+ invalidMessage: 'Invalid value - must be a number',
707
+ description: 'How much memory the container for each Project will be granted, recommended value 256'
708
+ },
709
+ container: {
710
+ label: 'Container Location',
711
+ // taken from https://stackoverflow.com/a/62964157
712
+ validate: '^(([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])\\.)*([a-z0-9]|[a-z0-9][a-z0-9\\-]*[a-z0-9])(:[0-9]+\\/)?(?:[0-9a-z-]+[/@])(?:([0-9a-z-]+))[/@]?(?:([0-9a-z-]+))?(?::[a-z0-9\\.-]+)?$',
713
+ invalidMessage: 'Invalid value - must be a Docker image',
714
+ description: 'Container image location, can include a tag'
715
+ }
716
+ }
717
+ }
718
+ }
719
+ },
720
+ /**
721
+ * Start a Project
722
+ * @param {Project} project - the project model instance
723
+ * @return {forge.containers.Project}
724
+ */
725
+ start: async (project) => {
726
+ this._projects[project.id] = {
727
+ state: 'starting'
728
+ }
729
+
730
+ // Rather than await this promise, we return it. That allows the wrapper
731
+ // to respond to the create request much quicker and the create can happen
732
+ // asynchronously.
733
+ // If the create fails, the Project still exists but will be put in suspended
734
+ // state (and taken out of billing if enabled).
735
+
736
+ // Remember, this call is used for both creating a new project as well as
737
+ // restarting an existing project
738
+ // return createPod(project)
739
+ return createProject(project, this._options)
740
+ },
741
+
742
+ /**
743
+ * Stop a Project
744
+ * @param {Project} project - the project model instance
745
+ */
746
+ stop: async (project) => {
747
+ // Stop the project
748
+ this._projects[project.id].state = 'stopping'
749
+
750
+ try {
751
+ await this._k8sNetApi.deleteNamespacedIngress(project.safeName, this._namespace)
752
+ } catch (err) {
753
+ this._app.log.error(`[k8s] Project ${project.id} - error deleting ingress: ${err.toString()}`)
754
+ }
755
+
756
+ // Note that, regardless, the main objective is to delete deployment (runnable)
757
+ // Even if some k8s resources like ingress or service are still not deleted (maybe because of
758
+ // k8s service latency), the most important thing is to get to deployment.
759
+ try {
760
+ await new Promise((resolve, reject) => {
761
+ let counter = 0
762
+ const pollInterval = setInterval(async () => {
763
+ try {
764
+ await this._k8sNetApi.readNamespacedIngress(project.safeName, this._namespace)
765
+ } catch (err) {
766
+ clearInterval(pollInterval)
767
+ resolve()
768
+ }
769
+ counter++
770
+ if (counter > this._k8sRetries) {
771
+ clearInterval(pollInterval)
772
+ this._app.log.error(`[k8s] Project ${project.id} - timed out deleting ingress`)
773
+ reject(new Error('Timed out to deleting Ingress'))
774
+ }
775
+ }, this._k8sDelay)
776
+ })
777
+ } catch (err) {
778
+ this._app.log.error(`[k8s] Project ${project.id} - Ingress was not deleted: ${err.toString()}`)
779
+ }
780
+
781
+ const prefix = project.safeName.match(/^[0-9]/) ? 'srv-' : ''
782
+ try {
783
+ await this._k8sApi.deleteNamespacedService(prefix + project.safeName, this._namespace)
784
+ } catch (err) {
785
+ this._app.log.error(`[k8s] Project ${project.id} - error deleting service: ${err.toString()}`)
786
+ }
787
+
788
+ try {
789
+ await new Promise((resolve, reject) => {
790
+ let counter = 0
791
+ const pollInterval = setInterval(async () => {
792
+ try {
793
+ await this._k8sApi.readNamespacedService(prefix + project.safeName, this._namespace)
794
+ } catch (err) {
795
+ clearInterval(pollInterval)
796
+ resolve()
797
+ }
798
+ counter++
799
+ if (counter > this._k8sRetries) {
800
+ clearInterval(pollInterval)
801
+ this._app.log.error(`[k8s] Project ${project.id} - timed deleting service`)
802
+ reject(new Error('Timed out to deleting Service'))
803
+ }
804
+ }, this._k8sDelay)
805
+ })
806
+ } catch (err) {
807
+ this._app.log.error(`[k8s] Project ${project.id} - Service was not deleted: ${err.toString()}`)
808
+ }
809
+
810
+ const currentType = await project.getSetting('k8sType')
811
+ let pod = true
812
+ if (currentType === 'deployment') {
813
+ await this._k8sAppApi.deleteNamespacedDeployment(project.safeName, this._namespace)
814
+ pod = false
815
+ } else {
816
+ await this._k8sApi.deleteNamespacedPod(project.safeName, this._namespace)
817
+ }
818
+
819
+ this._projects[project.id].state = 'suspended'
820
+ return new Promise((resolve, reject) => {
821
+ let counter = 0
822
+ const pollInterval = setInterval(async () => {
823
+ try {
824
+ if (pod) {
825
+ await this._k8sApi.readNamespacedPodStatus(project.safeName, this._namespace)
826
+ } else {
827
+ await this._k8sAppApi.readNamespacedDeployment(project.safeName, this._namespace)
828
+ }
829
+ counter++
830
+ if (counter > this._k8sRetries) {
831
+ clearInterval(pollInterval)
832
+ this._app.log.error(`[k8s] Project ${project.id} - timed deleting ${pod ? 'Pod' : 'Deployment'}`)
833
+ reject(new Error('Timed out to deleting Deployment'))
834
+ }
835
+ } catch (err) {
836
+ clearInterval(pollInterval)
837
+ resolve()
838
+ }
839
+ }, this._k8sDelay)
840
+ })
841
+ },
842
+
843
+ /**
844
+ * Removes a Project
845
+ * @param {Project} project - the project model instance
846
+ * @return {Object}
847
+ */
848
+ remove: async (project) => {
849
+ try {
850
+ await this._k8sNetApi.deleteNamespacedIngress(project.safeName, this._namespace)
851
+ } catch (err) {
852
+ this._app.log.error(`[k8s] Project ${project.id} - error deleting ingress: ${err.toString()}`)
853
+ }
854
+ try {
855
+ if (project.safeName.match(/^[0-9]/)) {
856
+ await this._k8sApi.deleteNamespacedService('srv-' + project.safeName, this._namespace)
857
+ } else {
858
+ await this._k8sApi.deleteNamespacedService(project.safeName, this._namespace)
859
+ }
860
+ } catch (err) {
861
+ this._app.log.error(`[k8s] Project ${project.id} - error deleting service: ${err.toString()}`)
862
+ }
863
+ const currentType = await project.getSetting('k8sType')
864
+ try {
865
+ // A suspended project won't have a pod to delete - but try anyway
866
+ // just in case state has got out of sync
867
+ if (currentType === 'deployment') {
868
+ await this._k8sAppApi.deleteNamespacedDeployment(project.safeName, this._namespace)
869
+ } else {
870
+ await this._k8sApi.deleteNamespacedPod(project.safeName, this._namespace)
871
+ }
872
+ } catch (err) {
873
+ if (project.state !== 'suspended') {
874
+ if (currentType === 'deployment') {
875
+ this._app.log.error(`[k8s] Project ${project.id} - error deleting deployment: ${err.toString()}`)
876
+ } else {
877
+ this._app.log.error(`[k8s] Project ${project.id} - error deleting pod: ${err.toString()}`)
878
+ }
879
+ }
880
+ }
881
+ delete this._projects[project.id]
882
+ },
883
+ /**
884
+ * Retrieves details of a project's container
885
+ * @param {Project} project - the project model instance
886
+ * @return {Object}
887
+ */
888
+ details: async (project) => {
889
+ if (this._projects[project.id] === undefined) {
890
+ return { state: 'unknown' }
891
+ }
892
+ if (this._projects[project.id].state === 'suspended') {
893
+ // We should only poll the launcher if we think it is running.
894
+ // Otherwise, return our cached state
895
+ return {
896
+ state: this._projects[project.id].state
897
+ }
898
+ }
899
+ const prefix = project.safeName.match(/^[0-9]/) ? 'srv-' : ''
900
+ // this._app.log.debug('checking actual pod, not cache')
901
+
902
+ /** @type { { response: IncomingMessage, body: k8s.V1Deployment } } */
903
+ let details
904
+ const currentType = await project.getSetting('k8sType')
905
+ try {
906
+ if (currentType === 'deployment') {
907
+ details = await this._k8sAppApi.readNamespacedDeployment(project.safeName, this._namespace)
908
+ if (details.body.status?.conditions[0].status === 'False') {
909
+ // return "starting" status until pod it running
910
+ this._projects[project.id].state = 'starting'
911
+ return {
912
+ id: project.id,
913
+ state: 'starting',
914
+ meta: {}
915
+ }
916
+ } else if (details.body.status?.conditions[0].status === 'True' &&
917
+ (details.body.status?.conditions[0].type === 'Available' ||
918
+ (details.body.status?.conditions[0].type === 'Progressing' && details.body.status?.conditions[0].reason === 'NewReplicaSetAvailable')
919
+ )) {
920
+ // not calling all endpoints for HA as they should be the same
921
+ const infoURL = `http://${prefix}${project.safeName}.${this._namespace}:2880/flowforge/info`
922
+ try {
923
+ const info = JSON.parse((await got.get(infoURL)).body)
924
+ this._projects[project.id].state = info.state
925
+ return info
926
+ } catch (err) {
927
+ this._app.log.debug(`error getting state from project ${project.id}: ${err}`)
928
+ return {
929
+ id: project.id,
930
+ state: 'starting',
931
+ meta: {}
932
+ }
933
+ }
934
+ } else {
935
+ return {
936
+ id: project.id,
937
+ state: 'starting',
938
+ error: `Unexpected pod status '${details.body.status?.conditions[0]?.status}', type '${details.body.status?.conditions[0]?.type}'`,
939
+ meta: {}
940
+ }
941
+ }
942
+ } else {
943
+ details = await this._k8sApi.readNamespacedPodStatus(project.safeName, this._namespace)
944
+ if (details.body.status?.phase === 'Pending') {
945
+ // return "starting" status until pod it running
946
+ this._projects[project.id].state = 'starting'
947
+ return {
948
+ id: project.id,
949
+ state: 'starting',
950
+ meta: {}
951
+ }
952
+ } else if (details.body.status?.phase === 'Running') {
953
+ // not calling all endpoints for HA as they should be the same
954
+ const infoURL = `http://${prefix}${project.safeName}.${this._namespace}:2880/flowforge/info`
955
+ try {
956
+ const info = JSON.parse((await got.get(infoURL)).body)
957
+ this._projects[project.id].state = info.state
958
+ return info
959
+ } catch (err) {
960
+ this._app.log.debug(`error getting state from project ${project.id}: ${err}`)
961
+ return {
962
+ id: project.id,
963
+ state: 'starting',
964
+ meta: {}
965
+ }
966
+ }
967
+ } else {
968
+ return {
969
+ id: project.id,
970
+ state: 'starting',
971
+ error: `Unexpected pod status '${details.body.status?.phase}'`,
972
+ meta: {}
973
+ }
974
+ }
975
+ }
976
+ } catch (err) {
977
+ this._app.log.debug(`error getting pod status for project ${project.id}: ${err}`)
978
+ return {
979
+ id: project?.id,
980
+ error: err,
981
+ state: 'starting',
982
+ meta: details?.body?.status
983
+ }
984
+ }
985
+ },
986
+
987
+ /**
988
+ * Returns the settings for the project
989
+ * @param {Project} project - the project model instance
990
+ */
991
+ settings: async (project) => {
992
+ const settings = {}
993
+ settings.projectID = project.id
994
+ settings.port = 1880
995
+ settings.rootDir = '/'
996
+ settings.userDir = 'data'
997
+
998
+ return settings
999
+ },
1000
+
1001
+ /**
1002
+ * Starts the flows
1003
+ * @param {Project} project - the project model instance
1004
+ * @return {forge.Status}
1005
+ */
1006
+ startFlows: async (project) => {
1007
+ if (this._projects[project.id] === undefined) {
1008
+ return { state: 'unknown' }
1009
+ }
1010
+ const endpoints = await getEndpoints(project)
1011
+ const commands = []
1012
+ for (const address in endpoints) {
1013
+ commands.push(got.post(`http://${endpoints[address]}:2880/flowforge/command`, {
1014
+ json: {
1015
+ cmd: 'start'
1016
+ }
1017
+ }))
1018
+ }
1019
+ await Promise.all(commands)
1020
+ return { status: 'okay' }
1021
+ },
1022
+
1023
+ /**
1024
+ * Stops the flows
1025
+ * @param {Project} project - the project model instance
1026
+ * @return {forge.Status}
1027
+ */
1028
+ stopFlows: async (project) => {
1029
+ if (this._projects[project.id] === undefined) {
1030
+ return { state: 'unknown' }
1031
+ }
1032
+ const endpoints = await getEndpoints(project)
1033
+ const commands = []
1034
+ for (const address in endpoints) {
1035
+ commands.push(got.post(`http://${endpoints[address]}:2880/flowforge/command`, {
1036
+ json: {
1037
+ cmd: 'stop'
1038
+ }
1039
+ }))
1040
+ }
1041
+ await Promise.all(commands)
1042
+ return Promise.resolve({ status: 'okay' })
1043
+ },
1044
+
1045
+ /**
1046
+ * Get a Project's logs
1047
+ * @param {Project} project - the project model instance
1048
+ * @return {array} logs
1049
+ */
1050
+ logs: async (project) => {
1051
+ if (this._projects[project.id] === undefined) {
1052
+ return { state: 'unknown' }
1053
+ }
1054
+ if (await project.getSetting('ha')) {
1055
+ const addresses = await getEndpoints(project)
1056
+ const logRequests = []
1057
+ for (const address in addresses) {
1058
+ logRequests.push(got.get(`http://${addresses[address]}:2880/flowforge/logs`).json())
1059
+ }
1060
+ const results = await Promise.all(logRequests)
1061
+ const combinedResults = results.flat(1)
1062
+ combinedResults.sort((a, b) => { return a.ts - b.ts })
1063
+ return combinedResults
1064
+ } else {
1065
+ const prefix = project.safeName.match(/^[0-9]/) ? 'srv-' : ''
1066
+ const result = await got.get(`http://${prefix}${project.safeName}.${this._namespace}:2880/flowforge/logs`).json()
1067
+ return result
1068
+ }
1069
+ },
1070
+
1071
+ /**
1072
+ * Restarts the flows
1073
+ * @param {Project} project - the project model instance
1074
+ * @return {forge.Status}
1075
+ */
1076
+ restartFlows: async (project) => {
1077
+ if (this._projects[project.id] === undefined) {
1078
+ return { state: 'unknown' }
1079
+ }
1080
+ const endpoints = await getEndpoints(project)
1081
+ const commands = []
1082
+ for (const address in endpoints) {
1083
+ commands.push(got.post(`http://${endpoints[address]}:2880/flowforge/command`, {
1084
+ json: {
1085
+ cmd: 'restart'
1086
+ }
1087
+ }))
1088
+ }
1089
+ await Promise.all(commands)
1090
+ return { state: 'okay' }
1091
+ },
1092
+ /**
1093
+ * Logout Node-RED instance
1094
+ * @param {Project} project - the project model instance
1095
+ * @param {string} token - the node-red token to revoke
1096
+ * @return {forge.Status}
1097
+ */
1098
+ revokeUserToken: async (project, token) => { // logout:nodered(step-3)
1099
+ this._app.log.debug(`[k8s] Project ${project.id} - logging out node-red instance`)
1100
+ const endpoints = await getEndpoints(project)
1101
+ const commands = []
1102
+ for (const address in endpoints) {
1103
+ commands.push(got.post(`http://${endpoints[address]}:2880/flowforge/command`, {
1104
+ json: {
1105
+ cmd: 'logout',
1106
+ token
1107
+ }
1108
+ }))
1109
+ }
1110
+ await Promise.all(commands)
1111
+ },
1112
+ /**
1113
+ * Shutdown Driver
1114
+ */
1115
+ shutdown: async () => {
1116
+ clearTimeout(this._initialCheckTimeout)
1117
+ },
1118
+ /**
1119
+ * getDefaultStackProperties
1120
+ */
1121
+ getDefaultStackProperties: () => {
1122
+ // need to work out what the right container tag is
1123
+ const properties = {
1124
+ cpu: 10,
1125
+ memory: 256,
1126
+ container: 'flowforge/node-red',
1127
+ ...this._app.config.driver.options?.default_stack
1128
+ }
1129
+
1130
+ return properties
1131
+ }
1132
+ }