@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/.eslintrc +19 -0
- package/.github/workflows/project-automation.yml +10 -0
- package/.github/workflows/publish.yml +42 -0
- package/.github/workflows/release-publish.yml +18 -0
- package/CHANGELOG.md +142 -0
- package/LICENSE +178 -0
- package/README.md +43 -0
- package/kubernetes.js +1132 -0
- package/package.json +32 -0
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
|
+
}
|