@flowfuse/driver-kubernetes 2.5.0 → 2.5.1-8ccd098-202407021403.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.
@@ -10,7 +10,11 @@ on:
10
10
 
11
11
  jobs:
12
12
  build:
13
- uses: 'flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml@v0.1.0'
13
+ uses: 'flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml@v0.14.0'
14
+ with:
15
+ node: '[
16
+ {"version": "18", "tests": false, "lint": true},
17
+ ]'
14
18
 
15
19
  publish:
16
20
  needs: build
package/README.md CHANGED
@@ -26,6 +26,10 @@ driver:
26
26
  cnameTarget: custom-loadbalancer.example.com
27
27
  certManagerIssuer: lets-encrypt
28
28
  ingressClass: custom-nginx
29
+ storage:
30
+ enabled: true
31
+ storageClass: nfs-storage
32
+ size: 5Gi
29
33
  ```
30
34
 
31
35
  - `registry` is the Docker Registry to load Stack Containers from
@@ -44,6 +48,10 @@ AWS EKS specific annotation for ALB Ingress. or `openshift` to allow running on
44
48
  - `customHostname.cnameTarget` The hostname users should configure their DNS entries to point at. Required. (default not set)
45
49
  - `customHostname.certManagerIssuer` Name of the Cluster issuer to use to create HTTPS certs for the custom hostname (default not set)
46
50
  - `customHostname.ingressClass` Name of the IngressClass to use to expose the custom hostname (default not set)
51
+ - `storage.enabled` Mounts a persistent volume on `/data/storage` (default false)
52
+ - `storage.storageClass` Name of StorageClass to use to allocate the volume (default not set)
53
+ - `storage.storageClassEFSTag` Used instead of `storage.storageClass` when needing to shard across multiple EFS file systems (default not set)
54
+ - `storage.size` Size of the volume to request (default not set)
47
55
 
48
56
  Expects to pick up K8s credentials from the environment
49
57
 
package/kubernetes.js CHANGED
@@ -1,6 +1,15 @@
1
1
  const got = require('got')
2
2
  const k8s = require('@kubernetes/client-node')
3
3
  const _ = require('lodash')
4
+ const awsEFS = require('./lib/aws-efs.js')
5
+
6
+ const {
7
+ deploymentTemplate,
8
+ serviceTemplate,
9
+ ingressTemplate,
10
+ customIngressTemplate,
11
+ persistentVolumeClaimTemplate
12
+ } = require('./templates.js')
4
13
 
5
14
  /**
6
15
  * Kubernates Container driver
@@ -14,153 +23,6 @@ const _ = require('lodash')
14
23
  *
15
24
  */
16
25
 
17
- const deploymentTemplate = {
18
- apiVersion: 'apps/v1',
19
- kind: 'Deployment',
20
- metadata: {
21
- // name: "k8s-client-test-deployment",
22
- labels: {
23
- // name: "k8s-client-test-deployment",
24
- nodered: 'true'
25
- // app: "k8s-client-test-deployment"
26
- }
27
- },
28
- spec: {
29
- replicas: 1,
30
- selector: {
31
- matchLabels: {
32
- // app: "k8s-client-test-deployment"
33
- }
34
- },
35
- template: {
36
- metadata: {
37
- labels: {
38
- // name: "k8s-client-test-deployment",
39
- nodered: 'true'
40
- // app: "k8s-client-test-deployment"
41
- }
42
- },
43
- spec: {
44
- securityContext: {
45
- runAsUser: 1000,
46
- runAsGroup: 1000,
47
- fsGroup: 1000
48
- },
49
- containers: [
50
- {
51
- resources: {
52
- requests: {
53
- // 10th of a core
54
- cpu: '100m',
55
- memory: '128Mi'
56
- },
57
- limits: {
58
- cpu: '125m',
59
- memory: '192Mi'
60
- }
61
- },
62
- name: 'node-red',
63
- // image: "docker-pi.local:5000/bronze-node-red",
64
- imagePullPolicy: 'Always',
65
- env: [
66
- // {name: "APP_NAME", value: "test"},
67
- { name: 'TZ', value: 'Europe/London' }
68
- ],
69
- ports: [
70
- { name: 'web', containerPort: 1880, protocol: 'TCP' },
71
- { name: 'management', containerPort: 2880, protocol: 'TCP' }
72
- ],
73
- securityContext: {
74
- allowPrivilegeEscalation: false
75
- }
76
- }
77
- ]
78
- },
79
- enableServiceLinks: false
80
- }
81
- }
82
- }
83
-
84
- const serviceTemplate = {
85
- apiVersion: 'v1',
86
- kind: 'Service',
87
- metadata: {
88
- // name: "k8s-client-test-service"
89
- },
90
- spec: {
91
- type: 'ClusterIP',
92
- selector: {
93
- // name: "k8s-client-test"
94
- },
95
- ports: [
96
- { name: 'web', port: 1880, protocol: 'TCP' },
97
- { name: 'management', port: 2880, protocol: 'TCP' }
98
- ]
99
- }
100
- }
101
-
102
- const ingressTemplate = {
103
- apiVersion: 'networking.k8s.io/v1',
104
- kind: 'Ingress',
105
- metadata: {
106
- // name: "k8s-client-test-ingress",
107
- // namespace: 'flowforge',
108
- annotations: process.env.INGRESS_ANNOTATIONS ? JSON.parse(process.env.INGRESS_ANNOTATIONS) : {}
109
- },
110
- spec: {
111
- ingressClassName: process.env.INGRESS_CLASS_NAME ? process.env.INGRESS_CLASS_NAME : null,
112
- rules: [
113
- {
114
- // host: "k8s-client-test" + "." + "ubuntu.local",
115
- http: {
116
- paths: [
117
- {
118
- pathType: 'Prefix',
119
- path: '/',
120
- backend: {
121
- service: {
122
- // name: 'k8s-client-test-service',
123
- port: { number: 1880 }
124
- }
125
- }
126
- }
127
- ]
128
- }
129
- }
130
- ]
131
- }
132
- }
133
-
134
- const customIngressTemplate = {
135
- apiVersion: 'networking.k8s.io/v1',
136
- kind: 'Ingress',
137
- metadata: {
138
- annotations: {}
139
- },
140
- spec: {
141
- rules: [
142
- {
143
- http: {
144
- paths: [
145
- {
146
- pathType: 'Prefix',
147
- path: '/',
148
- backend: {
149
- service: {
150
- port: { number: 1880 }
151
- }
152
- }
153
- }
154
- ]
155
- }
156
- }
157
- ],
158
- tls: [
159
-
160
- ]
161
- }
162
- }
163
-
164
26
  const createDeployment = async (project, options) => {
165
27
  const stack = project.ProjectStack.properties
166
28
 
@@ -276,7 +138,7 @@ const createDeployment = async (project, options) => {
276
138
  })
277
139
  }
278
140
 
279
- if (this._app.config.driver.options.privateCA) {
141
+ if (this._app.config.driver.options?.privateCA) {
280
142
  localPod.spec.containers[0].volumeMounts = [
281
143
  {
282
144
  name: 'cacert',
@@ -295,6 +157,29 @@ const createDeployment = async (project, options) => {
295
157
  localPod.spec.containers[0].env.push({ name: 'NODE_EXTRA_CA_CERTS', value: '/usr/local/ssl-certs/chain.pem' })
296
158
  }
297
159
 
160
+ if (this._app.config.driver.options?.storage?.enabled) {
161
+ const volMount = {
162
+ name: 'persistence',
163
+ mountPath: '/data/storage'
164
+ }
165
+ const vol = {
166
+ name: 'persistence',
167
+ persistentVolumeClaim: {
168
+ claimName: `${project.id}-pvc`
169
+ }
170
+ }
171
+ if (Array.isArray(localPod.spec.containers[0].volumeMounts)) {
172
+ localPod.spec.containers[0].volumeMounts.push(volMount)
173
+ } else {
174
+ localPod.spec.containers[0].volumeMounts = [volMount]
175
+ }
176
+ if (Array.isArray(localPod.spec.volumes)) {
177
+ localPod.spec.volumes.push(vol)
178
+ } else {
179
+ localPod.spec.volumes = [vol]
180
+ }
181
+ }
182
+
298
183
  if (this._app.license.active() && this._cloudProvider === 'openshift') {
299
184
  localPod.spec.securityContext = {}
300
185
  }
@@ -414,6 +299,32 @@ const createCustomIngress = async (project, hostname, options) => {
414
299
  return customIngress
415
300
  }
416
301
 
302
+ const createPersistentVolumeClaim = async (project, options) => {
303
+ const namespace = this._app.config.driver.options?.projectNamespace || 'flowforge'
304
+ const pvc = JSON.parse(JSON.stringify(persistentVolumeClaimTemplate))
305
+
306
+ const drvOptions = this._app.config.driver.options
307
+
308
+ if (drvOptions?.storage?.storageClass) {
309
+ pvc.spec.storageClassName = drvOptions.storage.storageClass
310
+ } else if (drvOptions?.storage?.storageClassEFSTag) {
311
+ pvc.spec.storageClassName = await awsEFS.lookupStorageClass(drvOptions?.storage?.storageClassEFSTag)
312
+ }
313
+
314
+ if (drvOptions?.storage?.size) {
315
+ pvc.spec.resources.requests.storage = drvOptions.storage.size
316
+ }
317
+
318
+ pvc.metadata.namespace = namespace
319
+ pvc.metadata.name = `${project.id}-pvc`
320
+ pvc.metadata.labels = {
321
+ 'ff-project-id': project.id,
322
+ 'ff-project-name': project.safeName
323
+ }
324
+ console.log(`PVC: ${JSON.stringify(pvc, null, 2)}`)
325
+ return pvc
326
+ }
327
+
417
328
  const createProject = async (project, options) => {
418
329
  const namespace = this._app.config.driver.options.projectNamespace || 'flowforge'
419
330
 
@@ -421,6 +332,24 @@ const createProject = async (project, options) => {
421
332
  const localService = await createService(project, options)
422
333
  const localIngress = await createIngress(project, options)
423
334
 
335
+ if (this._app.config.driver.options?.storage?.enabled) {
336
+ const localPVC = await createPersistentVolumeClaim(project, options)
337
+ // console.log(JSON.stringify(localPVC, null, 2))
338
+ try {
339
+ await this._k8sApi.createNamespacedPersistentVolumeClaim(namespace, localPVC)
340
+ } catch (err) {
341
+ if (err.statusCode === 409) {
342
+ this._app.log.warn(`[k8s] PVC for instance ${project.id} already exists, proceeding...`)
343
+ } else {
344
+ if (project.state !== 'suspended') {
345
+ this._app.log.error(`[k8s] Instance ${project.id} - error creating PVC: ${err.toString()} ${err.statusCode}`)
346
+ // console.log(err)
347
+ throw err
348
+ }
349
+ }
350
+ }
351
+ }
352
+
424
353
  try {
425
354
  await this._k8sAppApi.createNamespacedDeployment(namespace, localDeployment)
426
355
  } catch (err) {
@@ -838,6 +767,15 @@ module.exports = {
838
767
  await this._k8sApi.deleteNamespacedPod(project.safeName, this._namespace)
839
768
  }
840
769
 
770
+ // We should not delete the PVC when the instance is suspended
771
+ // if (this._app.config.driver.options?.storage?.enabled) {
772
+ // try {
773
+ // await this._k8sApi.deleteNamespacedPersistentVolumeClaim(`${project.safeName}-pvc`, this._namespace)
774
+ // } catch (err) {
775
+ // this._app.log.error(`[k8s] Instance ${project.id} - error deleting PVC: ${err.toString()} ${err.statusCode}`)
776
+ // }
777
+ // }
778
+
841
779
  this._projects[project.id].state = 'suspended'
842
780
  return new Promise((resolve, reject) => {
843
781
  let counter = 0
@@ -921,6 +859,14 @@ module.exports = {
921
859
  }
922
860
  }
923
861
  }
862
+ if (this._app.config.driver.options?.storage?.enabled) {
863
+ try {
864
+ await this._k8sApi.deleteNamespacedPersistentVolumeClaim(`${project.id}-pvc`, this._namespace)
865
+ } catch (err) {
866
+ this._app.log.error(`[k8s] Instance ${project.id} - error deleting PVC: ${err.toString()} ${err.statusCode}`)
867
+ // console.log(err)
868
+ }
869
+ }
924
870
  delete this._projects[project.id]
925
871
  },
926
872
  /**
package/lib/aws-efs.js ADDED
@@ -0,0 +1,54 @@
1
+ const { EFSClient, DescribeFileSystemsCommand, DescribeAccessPointsCommand } = require("@aws-sdk/client-efs")
2
+
3
+ let client
4
+
5
+ async function lookupStorageClass (tagName) {
6
+
7
+ // console.log(`Looking for ${tagName}`)
8
+
9
+ if (!client) {
10
+ client = new EFSClient()
11
+ }
12
+
13
+ const fsCommand = new DescribeFileSystemsCommand()
14
+ const fsList = await client.send(fsCommand)
15
+ // console.log(JSON.stringify(fsList, null, 2))
16
+
17
+ const fileSystems = []
18
+
19
+ for (let i = 0; i<fsList.FileSystems.length; i++) {
20
+ let found = false
21
+ let storageClass = ''
22
+ for (let j = 0; j<fsList.FileSystems[i].Tags.length; j++) {
23
+ const tag = fsList.FileSystems[i].Tags[j]
24
+ if (tag.Key === tagName) {
25
+ found = true
26
+ }
27
+ if (tag.Key === 'storage-class-name') {
28
+ storageClass = tag.Value
29
+ }
30
+ }
31
+ if (found) {
32
+ // console.log(storageClass)
33
+ const apParams = {
34
+ FileSystemId: fsList.FileSystems[i].FileSystemId
35
+ }
36
+ // console.log(apParams)
37
+ const apListCommand = new DescribeAccessPointsCommand(apParams)
38
+ const apList = await client.send(apListCommand)
39
+ // fileSystems[fsList.FileSystems[i].FileSystemId]
40
+ fileSystems.push({
41
+ apCount: apList.AccessPoints.length,
42
+ storageClass
43
+ })
44
+ }
45
+ }
46
+ fileSystems.sort((a,b) => a.apCount - b.apCount)
47
+
48
+ return fileSystems[0]?.storageClass
49
+ }
50
+
51
+
52
+ module.exports = {
53
+ lookupStorageClass
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/driver-kubernetes",
3
- "version": "2.5.0",
3
+ "version": "2.5.1-8ccd098-202407021403.0",
4
4
  "description": "Kubernetes driver for FlowFuse",
5
5
  "main": "kubernetes.js",
6
6
  "scripts": {
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "license": "Apache-2.0",
23
23
  "dependencies": {
24
+ "@aws-sdk/client-efs": "^3.600.0",
24
25
  "@kubernetes/client-node": "^0.18.1",
25
26
  "got": "^11.8.0",
26
27
  "lodash": "^4.17.21"
package/templates.js ADDED
@@ -0,0 +1,171 @@
1
+ const deploymentTemplate = {
2
+ apiVersion: 'apps/v1',
3
+ kind: 'Deployment',
4
+ metadata: {
5
+ // name: "k8s-client-test-deployment",
6
+ labels: {
7
+ // name: "k8s-client-test-deployment",
8
+ nodered: 'true'
9
+ // app: "k8s-client-test-deployment"
10
+ }
11
+ },
12
+ spec: {
13
+ replicas: 1,
14
+ selector: {
15
+ matchLabels: {
16
+ // app: "k8s-client-test-deployment"
17
+ }
18
+ },
19
+ template: {
20
+ metadata: {
21
+ labels: {
22
+ // name: "k8s-client-test-deployment",
23
+ nodered: 'true'
24
+ // app: "k8s-client-test-deployment"
25
+ }
26
+ },
27
+ spec: {
28
+ securityContext: {
29
+ runAsUser: 1000,
30
+ runAsGroup: 1000,
31
+ fsGroup: 1000
32
+ },
33
+ containers: [
34
+ {
35
+ resources: {
36
+ requests: {
37
+ // 10th of a core
38
+ cpu: '100m',
39
+ memory: '128Mi'
40
+ },
41
+ limits: {
42
+ cpu: '125m',
43
+ memory: '192Mi'
44
+ }
45
+ },
46
+ name: 'node-red',
47
+ // image: "docker-pi.local:5000/bronze-node-red",
48
+ imagePullPolicy: 'Always',
49
+ env: [
50
+ // {name: "APP_NAME", value: "test"},
51
+ { name: 'TZ', value: 'Europe/London' }
52
+ ],
53
+ ports: [
54
+ { name: 'web', containerPort: 1880, protocol: 'TCP' },
55
+ { name: 'management', containerPort: 2880, protocol: 'TCP' }
56
+ ],
57
+ securityContext: {
58
+ allowPrivilegeEscalation: false
59
+ }
60
+ }
61
+ ]
62
+ },
63
+ enableServiceLinks: false
64
+ }
65
+ }
66
+ }
67
+
68
+ const serviceTemplate = {
69
+ apiVersion: 'v1',
70
+ kind: 'Service',
71
+ metadata: {
72
+ // name: "k8s-client-test-service"
73
+ },
74
+ spec: {
75
+ type: 'ClusterIP',
76
+ selector: {
77
+ // name: "k8s-client-test"
78
+ },
79
+ ports: [
80
+ { name: 'web', port: 1880, protocol: 'TCP' },
81
+ { name: 'management', port: 2880, protocol: 'TCP' }
82
+ ]
83
+ }
84
+ }
85
+
86
+ const ingressTemplate = {
87
+ apiVersion: 'networking.k8s.io/v1',
88
+ kind: 'Ingress',
89
+ metadata: {
90
+ // name: "k8s-client-test-ingress",
91
+ // namespace: 'flowforge',
92
+ annotations: process.env.INGRESS_ANNOTATIONS ? JSON.parse(process.env.INGRESS_ANNOTATIONS) : {}
93
+ },
94
+ spec: {
95
+ ingressClassName: process.env.INGRESS_CLASS_NAME ? process.env.INGRESS_CLASS_NAME : null,
96
+ rules: [
97
+ {
98
+ // host: "k8s-client-test" + "." + "ubuntu.local",
99
+ http: {
100
+ paths: [
101
+ {
102
+ pathType: 'Prefix',
103
+ path: '/',
104
+ backend: {
105
+ service: {
106
+ // name: 'k8s-client-test-service',
107
+ port: { number: 1880 }
108
+ }
109
+ }
110
+ }
111
+ ]
112
+ }
113
+ }
114
+ ]
115
+ }
116
+ }
117
+
118
+ const customIngressTemplate = {
119
+ apiVersion: 'networking.k8s.io/v1',
120
+ kind: 'Ingress',
121
+ metadata: {
122
+ annotations: {}
123
+ },
124
+ spec: {
125
+ rules: [
126
+ {
127
+ http: {
128
+ paths: [
129
+ {
130
+ pathType: 'Prefix',
131
+ path: '/',
132
+ backend: {
133
+ service: {
134
+ port: { number: 1880 }
135
+ }
136
+ }
137
+ }
138
+ ]
139
+ }
140
+ }
141
+ ],
142
+ tls: [
143
+
144
+ ]
145
+ }
146
+ }
147
+
148
+ const persistentVolumeClaimTemplate = {
149
+ apiVersion: 'v1',
150
+ kind: 'PersistentVolumeClaim',
151
+ metadata: {
152
+
153
+ },
154
+ spec: {
155
+ accessModes: [
156
+ 'ReadWriteMany' // picked for HA mode
157
+ ],
158
+ resources: {
159
+ requests: {
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ module.exports = {
166
+ deploymentTemplate,
167
+ serviceTemplate,
168
+ ingressTemplate,
169
+ customIngressTemplate,
170
+ persistentVolumeClaimTemplate
171
+ }