@r8s/nextcloud 0.2.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/__tests__/nextcloud.test.ts +409 -0
- package/examples/basic.tsx +16 -0
- package/package.json +41 -0
- package/src/index.tsx +447 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, jsx } from '@r8s/core'
|
|
3
|
+
import { OperatorContext, SecretContext, RoutingContext } from '@r8s/core/defaults'
|
|
4
|
+
import { runGuardrails, noPlaintextSecrets, validateResource } from '@r8s/core'
|
|
5
|
+
import { operators } from '@r8s/crds'
|
|
6
|
+
import type { r8sElement } from '@r8s/core'
|
|
7
|
+
|
|
8
|
+
// Nextcloud recipe tests:
|
|
9
|
+
// 1. Operator declarations (deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, all props, gateway/ingress adaptation, cron
|
|
11
|
+
// 3. Persistence: /var/www/html claim shared by app + cron
|
|
12
|
+
// 4. Security: no plaintext credentials in rendered output
|
|
13
|
+
import { Nextcloud } from '../src/index'
|
|
14
|
+
|
|
15
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
16
|
+
|
|
17
|
+
/** Render Nextcloud inside a Platform-like secrets backend (OpenBao). */
|
|
18
|
+
function renderNextcloud(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
19
|
+
return render(
|
|
20
|
+
jsx(SecretContext.Provider, {
|
|
21
|
+
value: openbao as never,
|
|
22
|
+
children: jsx(Nextcloud, props as never),
|
|
23
|
+
})
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Wrap Nextcloud in an OperatorContext (no secrets backend). */
|
|
28
|
+
function elementWithContext(ops: any[], props: Record<string, unknown>): r8sElement {
|
|
29
|
+
return jsx(OperatorContext.Provider, {
|
|
30
|
+
value: ops,
|
|
31
|
+
children: jsx(Nextcloud, props as never),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const objectStorage = {
|
|
36
|
+
endpoint: 's3.internal.example.com',
|
|
37
|
+
bucket: 'cloud-files',
|
|
38
|
+
credentialsSecret: 'cloud-files-credentials',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findAppDeployment(result: ReturnType<typeof render>, name = 'nextcloud'): any {
|
|
42
|
+
return result.resources.find(
|
|
43
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === name
|
|
44
|
+
) as any
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findCron(result: ReturnType<typeof render>, name = 'nextcloud-cron'): any {
|
|
48
|
+
return result.resources.find((r: any) => r.kind === 'CronJob' && r.metadata.name === name) as any
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('operator declarations', () => {
|
|
52
|
+
it('declares the redis operator when cache is enabled', () => {
|
|
53
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
54
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('declares the cnpg operator via the Database recipe', () => {
|
|
58
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
59
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('skips the redis operator when cache is disabled', () => {
|
|
63
|
+
const result = renderNextcloud({ host: 'cloud.example.com', cache: false })
|
|
64
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('deduplicates operators provided via context', () => {
|
|
68
|
+
const result = render(
|
|
69
|
+
elementWithContext([operators['redis-operator'](), operators['cnpg']()], {
|
|
70
|
+
host: 'cloud.example.com',
|
|
71
|
+
secretsName: 'existing-secrets',
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
const names = result.operators.map((op) => op.name)
|
|
75
|
+
expect(names.filter((n) => n === 'redis-operator')).toHaveLength(1)
|
|
76
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('rendering defaults', () => {
|
|
81
|
+
it('renders app deployment, service, ingress, database cluster, cron job, redis and html claim', () => {
|
|
82
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
83
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
84
|
+
expect(kinds).toContain('Deployment')
|
|
85
|
+
expect(kinds).toContain('Service')
|
|
86
|
+
expect(kinds).toContain('Ingress')
|
|
87
|
+
expect(kinds).toContain('Cluster')
|
|
88
|
+
expect(kinds).toContain('CronJob')
|
|
89
|
+
expect(kinds).toContain('RedisReplication')
|
|
90
|
+
expect(kinds).toContain('PersistentVolumeClaim')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders the app on nextcloud:31-apache, port 80, as a raw deployment', () => {
|
|
94
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
95
|
+
const app = findAppDeployment(result)
|
|
96
|
+
expect(app).toBeDefined()
|
|
97
|
+
const container = app.spec.template.spec.containers[0]
|
|
98
|
+
expect(container.image).toBe('nextcloud:31-apache')
|
|
99
|
+
expect(container.ports[0].containerPort).toBe(80)
|
|
100
|
+
expect(container.name).toBe('app')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('probes /status.php on port 80 for liveness and readiness', () => {
|
|
104
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
105
|
+
const container = findAppDeployment(result).spec.template.spec.containers[0]
|
|
106
|
+
expect(container.livenessProbe.httpGet).toEqual({ path: '/status.php', port: 80 })
|
|
107
|
+
expect(container.livenessProbe.initialDelaySeconds).toBe(30)
|
|
108
|
+
expect(container.readinessProbe.httpGet).toEqual({ path: '/status.php', port: 80 })
|
|
109
|
+
expect(container.readinessProbe.initialDelaySeconds).toBe(20)
|
|
110
|
+
expect(container.readinessProbe.failureThreshold).toBe(5)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('runs cron.php every 5 minutes with the same image, no busybox', () => {
|
|
114
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
115
|
+
const cron = findCron(result)
|
|
116
|
+
expect(cron).toBeDefined()
|
|
117
|
+
expect(cron.apiVersion).toBe('batch/v1')
|
|
118
|
+
expect(cron.spec.schedule).toBe('*/5 * * * *')
|
|
119
|
+
const container = cron.spec.jobTemplate.spec.template.spec.containers[0]
|
|
120
|
+
expect(container.image).toBe('nextcloud:31-apache')
|
|
121
|
+
expect(container.command).toEqual(['php', '-f', '/var/www/html/cron.php'])
|
|
122
|
+
expect(container.env.find((e: any) => e.name === 'POSTGRES_HOST').value).toBe('nextcloud-rw')
|
|
123
|
+
expect(container.env.find((e: any) => e.name === 'POSTGRES_USER').value).toBe('nextcloud')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('renders a ReadWriteMany html claim with the default 10Gi size', () => {
|
|
127
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
128
|
+
const pvc = result.resources.find(
|
|
129
|
+
(r: any) => r.kind === 'PersistentVolumeClaim' && r.metadata.name === 'nextcloud-html'
|
|
130
|
+
) as any
|
|
131
|
+
expect(pvc).toBeDefined()
|
|
132
|
+
expect(pvc.spec.accessModes).toEqual(['ReadWriteMany'])
|
|
133
|
+
expect(pvc.spec.resources.requests.storage).toBe('10Gi')
|
|
134
|
+
expect(pvc.spec.storageClassName).toBeUndefined()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('honours storage and storageClassName props on the html claim', () => {
|
|
138
|
+
const result = renderNextcloud({
|
|
139
|
+
host: 'cloud.example.com',
|
|
140
|
+
storage: '25Gi',
|
|
141
|
+
storageClassName: 'nfs-client',
|
|
142
|
+
})
|
|
143
|
+
const pvc = result.resources.find(
|
|
144
|
+
(r: any) => r.kind === 'PersistentVolumeClaim' && r.metadata.name === 'nextcloud-html'
|
|
145
|
+
) as any
|
|
146
|
+
expect(pvc.spec.resources.requests.storage).toBe('25Gi')
|
|
147
|
+
expect(pvc.spec.storageClassName).toBe('nfs-client')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('mounts the html claim in both the app deployment and the cron job', () => {
|
|
151
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
152
|
+
const appContainer = findAppDeployment(result).spec.template.spec.containers[0]
|
|
153
|
+
expect(appContainer.volumeMounts).toEqual([{ name: 'html', mountPath: '/var/www/html' }])
|
|
154
|
+
expect(findAppDeployment(result).spec.template.spec.volumes).toEqual([
|
|
155
|
+
{ name: 'html', persistentVolumeClaim: { claimName: 'nextcloud-html' } },
|
|
156
|
+
])
|
|
157
|
+
const cronPodSpec = findCron(result).spec.jobTemplate.spec.template.spec
|
|
158
|
+
expect(cronPodSpec.containers[0].volumeMounts).toEqual([
|
|
159
|
+
{ name: 'html', mountPath: '/var/www/html' },
|
|
160
|
+
])
|
|
161
|
+
expect(cronPodSpec.volumes).toEqual([
|
|
162
|
+
{ name: 'html', persistentVolumeClaim: { claimName: 'nextcloud-html' } },
|
|
163
|
+
])
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('forbids cron concurrency and never restarts jobs past the backoff limit', () => {
|
|
167
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
168
|
+
const cron = findCron(result)
|
|
169
|
+
expect(cron.spec.concurrencyPolicy).toBe('Forbid')
|
|
170
|
+
const jobSpec = cron.spec.jobTemplate.spec
|
|
171
|
+
expect(jobSpec.backoffLimit).toBe(2)
|
|
172
|
+
expect(jobSpec.template.spec.restartPolicy).toBe('OnFailure')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('renders a ClusterIP service that fronts the app on port 80', () => {
|
|
176
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
177
|
+
const service = result.resources.find(
|
|
178
|
+
(r: any) => r.kind === 'Service' && r.metadata.name === 'nextcloud'
|
|
179
|
+
) as any
|
|
180
|
+
expect(service).toBeDefined()
|
|
181
|
+
expect(service.spec.type).toBe('ClusterIP')
|
|
182
|
+
expect(service.spec.ports[0].port).toBe(80)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('skips redis resources when cache is disabled', () => {
|
|
186
|
+
const result = renderNextcloud({ host: 'cloud.example.com', cache: false })
|
|
187
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
188
|
+
expect(kinds).not.toContain('RedisReplication')
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
192
|
+
const result = render(
|
|
193
|
+
jsx(RoutingContext.Provider, {
|
|
194
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
195
|
+
children: jsx(SecretContext.Provider, {
|
|
196
|
+
value: openbao as never,
|
|
197
|
+
children: jsx(Nextcloud, { host: 'cloud.example.com' }),
|
|
198
|
+
}),
|
|
199
|
+
})
|
|
200
|
+
)
|
|
201
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
202
|
+
expect(kinds).toContain('HTTPRoute')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
206
|
+
const result = render(
|
|
207
|
+
jsx(RoutingContext.Provider, {
|
|
208
|
+
value: { mode: 'ingress' },
|
|
209
|
+
children: jsx(SecretContext.Provider, {
|
|
210
|
+
value: openbao as never,
|
|
211
|
+
children: jsx(Nextcloud, { host: 'cloud.example.com' }),
|
|
212
|
+
}),
|
|
213
|
+
})
|
|
214
|
+
)
|
|
215
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
216
|
+
expect(ingress).toBeDefined()
|
|
217
|
+
expect(ingress.spec.rules[0].host).toBe('cloud.example.com')
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('passes resource validation', () => {
|
|
221
|
+
const result = renderNextcloud({
|
|
222
|
+
host: 'cloud.example.com',
|
|
223
|
+
objectStorage,
|
|
224
|
+
})
|
|
225
|
+
for (const resource of result.resources) {
|
|
226
|
+
expect(validateResource(resource)).toEqual([])
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
describe('rendering with all props', () => {
|
|
232
|
+
it('accepts the full prop surface', () => {
|
|
233
|
+
const result = renderNextcloud({
|
|
234
|
+
name: 'cloud',
|
|
235
|
+
namespace: 'collab',
|
|
236
|
+
version: '31.0.5-apache',
|
|
237
|
+
host: 'cloud.example.com',
|
|
238
|
+
replicas: 3,
|
|
239
|
+
storage: '50Gi',
|
|
240
|
+
storageClassName: 'nfs-client',
|
|
241
|
+
objectStorage: {
|
|
242
|
+
...objectStorage,
|
|
243
|
+
bucket: 'shared',
|
|
244
|
+
region: 'eu-north-1',
|
|
245
|
+
port: 9000,
|
|
246
|
+
ssl: false,
|
|
247
|
+
},
|
|
248
|
+
resources: {
|
|
249
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
250
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
251
|
+
},
|
|
252
|
+
tls: { secretName: 'cloud-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
const app = findAppDeployment(result, 'cloud')
|
|
256
|
+
expect(app).toBeDefined()
|
|
257
|
+
expect(app.spec.replicas).toBe(3)
|
|
258
|
+
expect(app.spec.template.spec.containers[0].image).toContain('31.0.5-apache')
|
|
259
|
+
expect(app.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
|
|
260
|
+
|
|
261
|
+
const env = app.spec.template.spec.containers[0].env
|
|
262
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_BUCKET').value).toBe('shared')
|
|
263
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_REGION').value).toBe('eu-north-1')
|
|
264
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_PORT').value).toBe('9000')
|
|
265
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_SSL').value).toBe('false')
|
|
266
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_HOST').value).toBe(
|
|
267
|
+
's3.internal.example.com'
|
|
268
|
+
)
|
|
269
|
+
expect(env.find((e: any) => e.name === 'NEXTCLOUD_TRUSTED_DOMAINS').value).toBe(
|
|
270
|
+
'cloud.example.com'
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
const pvc = result.resources.find(
|
|
274
|
+
(r: any) => r.kind === 'PersistentVolumeClaim' && r.metadata.name === 'cloud-html'
|
|
275
|
+
) as any
|
|
276
|
+
expect(pvc.spec.resources.requests.storage).toBe('50Gi')
|
|
277
|
+
expect(pvc.spec.storageClassName).toBe('nfs-client')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('defaults OBJECTSTORE_S3_SSL to true and omits the port when unset', () => {
|
|
281
|
+
const result = renderNextcloud({ host: 'cloud.example.com', objectStorage })
|
|
282
|
+
const env = findAppDeployment(result).spec.template.spec.containers[0].env
|
|
283
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_SSL').value).toBe('true')
|
|
284
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_PORT')).toBeUndefined()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('wires the app and cron to redis and database by naming convention', () => {
|
|
288
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
289
|
+
const env = findAppDeployment(result).spec.template.spec.containers[0].env
|
|
290
|
+
expect(env.find((e: any) => e.name === 'REDIS_HOST').value).toBe('nextcloud-redis')
|
|
291
|
+
expect(env.find((e: any) => e.name === 'REDIS_HOST_PORT').value).toBe('6379')
|
|
292
|
+
expect(env.find((e: any) => e.name === 'POSTGRES_HOST').value).toBe('nextcloud-rw')
|
|
293
|
+
const cronEnv = findCron(result).spec.jobTemplate.spec.template.spec.containers[0].env
|
|
294
|
+
expect(cronEnv.find((e: any) => e.name === 'REDIS_HOST').value).toBe('nextcloud-redis')
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('emits the redis replication component with 3 nodes', () => {
|
|
298
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
299
|
+
const redis = result.resources.find(
|
|
300
|
+
(r: any) => r.kind === 'RedisReplication' && r.metadata.name === 'nextcloud-redis'
|
|
301
|
+
) as any
|
|
302
|
+
expect(redis).toBeDefined()
|
|
303
|
+
expect(redis.spec.clusterSize).toBe(3)
|
|
304
|
+
expect(redis.spec.kubernetesConfig.image).toBe('redis:7.2-alpine')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('keeps only POSTGRES_* database wiring (no DATABASE_URL)', () => {
|
|
308
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
309
|
+
const env = findAppDeployment(result).spec.template.spec.containers[0].env
|
|
310
|
+
expect(env.find((e: any) => e.name === 'DATABASE_URL')).toBeUndefined()
|
|
311
|
+
expect(env.find((e: any) => e.name === 'POSTGRES_HOST').value).toBe('nextcloud-rw')
|
|
312
|
+
expect(env.find((e: any) => e.name === 'POSTGRES_PORT').value).toBe('5432')
|
|
313
|
+
expect(env.find((e: any) => e.name === 'POSTGRES_DB').value).toBe('nextcloud')
|
|
314
|
+
expect(env.find((e: any) => e.name === 'POSTGRES_USER').value).toBe('nextcloud')
|
|
315
|
+
expect(env.find((e: any) => e.name === 'POSTGRES_PASSWORD').value).toBe('$(PGPASSWORD)')
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('secrets handling', () => {
|
|
320
|
+
it('provisions app secrets through a secrets backend', () => {
|
|
321
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
322
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
323
|
+
expect(kinds).toContain('OpenBaoStaticSecret')
|
|
324
|
+
const appSecret = result.resources.find(
|
|
325
|
+
(r: any) =>
|
|
326
|
+
r.kind === 'OpenBaoStaticSecret' && r.spec.destination.name === 'nextcloud-app-secrets'
|
|
327
|
+
) as any
|
|
328
|
+
expect(appSecret).toBeDefined()
|
|
329
|
+
expect(appSecret.spec.path).toBe('test/nextcloud/secrets')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('provisions app secrets through Vault', () => {
|
|
333
|
+
const result = render(
|
|
334
|
+
jsx(SecretContext.Provider, {
|
|
335
|
+
value: { backend: 'vault', mount: 'kv', path: 'apps' },
|
|
336
|
+
children: jsx(Nextcloud, { host: 'cloud.example.com' }),
|
|
337
|
+
})
|
|
338
|
+
)
|
|
339
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
340
|
+
expect(kinds).toContain('VaultStaticSecret')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('throws when no secrets backend and no secretsName', () => {
|
|
344
|
+
expect(() => render(jsx(Nextcloud, { host: 'cloud.example.com' }))).toThrow(
|
|
345
|
+
/application secrets/
|
|
346
|
+
)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('accepts an existing secretsName without a backend', () => {
|
|
350
|
+
expect(() =>
|
|
351
|
+
render(jsx(Nextcloud, { host: 'cloud.example.com', secretsName: 'existing-secrets' }))
|
|
352
|
+
).not.toThrow()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('wires credentials via secretKeyRef (never plaintext env)', () => {
|
|
356
|
+
const result = renderNextcloud({ host: 'cloud.example.com', secretsName: 'existing-secrets' })
|
|
357
|
+
const env = findAppDeployment(result).spec.template.spec.containers[0].env
|
|
358
|
+
const adminPassword = env.find((e: any) => e.name === 'NEXTCLOUD_ADMIN_PASSWORD')
|
|
359
|
+
const pgPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
360
|
+
expect(adminPassword.valueFrom.secretKeyRef.name).toBe('existing-secrets')
|
|
361
|
+
expect(adminPassword.valueFrom.secretKeyRef.key).toBe('adminPassword')
|
|
362
|
+
expect(pgPassword.valueFrom.secretKeyRef.name).toBe('nextcloud-db-credentials')
|
|
363
|
+
expect(adminPassword.value).toBeUndefined()
|
|
364
|
+
expect(pgPassword.value).toBeUndefined()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('wires object storage credentials to the referenced bucket secret', () => {
|
|
368
|
+
const result = renderNextcloud({ host: 'cloud.example.com', objectStorage })
|
|
369
|
+
const env = findAppDeployment(result).spec.template.spec.containers[0].env
|
|
370
|
+
const accessKey = env.find((e: any) => e.name === 'AWS_ACCESS_KEY_ID')
|
|
371
|
+
const secretAccessKey = env.find((e: any) => e.name === 'AWS_SECRET_ACCESS_KEY')
|
|
372
|
+
expect(accessKey.valueFrom.secretKeyRef).toEqual({
|
|
373
|
+
name: 'cloud-files-credentials',
|
|
374
|
+
key: 'accessKey',
|
|
375
|
+
})
|
|
376
|
+
expect(secretAccessKey.valueFrom.secretKeyRef).toEqual({
|
|
377
|
+
name: 'cloud-files-credentials',
|
|
378
|
+
key: 'secretKey',
|
|
379
|
+
})
|
|
380
|
+
expect(accessKey.value).toBeUndefined()
|
|
381
|
+
expect(secretAccessKey.value).toBeUndefined()
|
|
382
|
+
// Nextcloud-native envs are $(...) references to the secret-backed vars
|
|
383
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_KEY').value).toBe('$(AWS_ACCESS_KEY_ID)')
|
|
384
|
+
expect(env.find((e: any) => e.name === 'OBJECTSTORE_S3_SECRET').value).toBe(
|
|
385
|
+
'$(AWS_SECRET_ACCESS_KEY)'
|
|
386
|
+
)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('cron job reads the database password via secretKeyRef', () => {
|
|
390
|
+
const result = renderNextcloud({ host: 'cloud.example.com' })
|
|
391
|
+
const env = findCron(result).spec.jobTemplate.spec.template.spec.containers[0].env
|
|
392
|
+
const pgPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
393
|
+
expect(pgPassword.valueFrom.secretKeyRef.name).toBe('nextcloud-db-credentials')
|
|
394
|
+
expect(pgPassword.valueFrom.secretKeyRef.key).toBe('password')
|
|
395
|
+
expect(pgPassword.value).toBeUndefined()
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
399
|
+
const result = renderNextcloud({
|
|
400
|
+
host: 'cloud.example.com',
|
|
401
|
+
objectStorage,
|
|
402
|
+
})
|
|
403
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
404
|
+
if (!passed) {
|
|
405
|
+
console.error('Plaintext credential violations:', errors)
|
|
406
|
+
}
|
|
407
|
+
expect(passed).toBe(true)
|
|
408
|
+
})
|
|
409
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Platform } from '@r8s/recipes'
|
|
2
|
+
import { Nextcloud } from '@r8s/nextcloud'
|
|
3
|
+
|
|
4
|
+
export default (
|
|
5
|
+
<Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
6
|
+
<Nextcloud
|
|
7
|
+
name="cloud"
|
|
8
|
+
host="cloud.example.com"
|
|
9
|
+
objectStorage={{
|
|
10
|
+
endpoint: 's3.internal.example.com',
|
|
11
|
+
bucket: 'cloud-files',
|
|
12
|
+
credentialsSecret: 'cloud-files-credentials',
|
|
13
|
+
}}
|
|
14
|
+
/>
|
|
15
|
+
</Platform>
|
|
16
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/nextcloud",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Nextcloud file cloud — Postgres file index, Redis cache, S3-compatible primary storage, cron background jobs",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Berget AI AB",
|
|
7
|
+
"homepage": "https://r8s.berget.ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/berget-ai/r8s.git",
|
|
11
|
+
"directory": "packages/nextcloud"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"nextcloud",
|
|
16
|
+
"files",
|
|
17
|
+
"collaboration",
|
|
18
|
+
"calendar"
|
|
19
|
+
],
|
|
20
|
+
"r8s": {
|
|
21
|
+
"category": "File & Collaboration"
|
|
22
|
+
},
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc --build",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
29
|
+
},
|
|
30
|
+
"category": "Collaboration & Productivity",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@r8s/core": "^0.2.0",
|
|
33
|
+
"@r8s/crds": "^0.2.0",
|
|
34
|
+
"@r8s/k8s-types": "^0.2.0",
|
|
35
|
+
"@r8s/recipes": "^0.2.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"typescript": "^5.3.0",
|
|
39
|
+
"vitest": "^1.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { jsx, Fragment, useContext, declareOperator } from '@r8s/core'
|
|
2
|
+
import type { Deployment, EnvVar, PersistentVolumeClaim, Service } from '@r8s/k8s-types'
|
|
3
|
+
import { OperatorContext, SecretContext } from '@r8s/core/defaults'
|
|
4
|
+
import { operators } from '@r8s/crds'
|
|
5
|
+
import { Database, Endpoint } from '@r8s/recipes'
|
|
6
|
+
import { RedisReplicationComponent } from '@r8s/crds/redis'
|
|
7
|
+
import type { SecretRef } from '@r8s/recipes'
|
|
8
|
+
|
|
9
|
+
export interface NextcloudProps {
|
|
10
|
+
/** Resource name (defaults to 'nextcloud') */
|
|
11
|
+
name?: string
|
|
12
|
+
/** Kubernetes namespace (defaults to 'default') */
|
|
13
|
+
namespace?: string
|
|
14
|
+
/** Container image tag (defaults to '31-apache' — pin a version in production) */
|
|
15
|
+
version?: string
|
|
16
|
+
/** Public hostname for the web UI and WebDAV (required) */
|
|
17
|
+
host: string
|
|
18
|
+
/**
|
|
19
|
+
* Number of replicas. Safe to scale beyond 1 when `objectStorage` is
|
|
20
|
+
* configured (file blobs live in S3) and `cache` is enabled — Nextcloud
|
|
21
|
+
* becomes effectively stateless. Requires a StorageClass with
|
|
22
|
+
* ReadWriteMany support for the /var/www/html claim.
|
|
23
|
+
*/
|
|
24
|
+
replicas?: number
|
|
25
|
+
/** Provision a Redis replication set for file locking and caching (default: true) */
|
|
26
|
+
cache?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Size of the PersistentVolumeClaim backing /var/www/html (defaults to
|
|
29
|
+
* '10Gi'). Apps, config and the data directory all live in this tree.
|
|
30
|
+
*/
|
|
31
|
+
storage?: string
|
|
32
|
+
/**
|
|
33
|
+
* StorageClass for the /var/www/html PersistentVolumeClaim. Must provide
|
|
34
|
+
* ReadWriteMany when `replicas` > 1 (e.g. NFS or EFS). Defaults to the
|
|
35
|
+
* cluster default StorageClass when omitted.
|
|
36
|
+
*/
|
|
37
|
+
storageClassName?: string
|
|
38
|
+
/**
|
|
39
|
+
* S3-compatible object storage used as primary storage for files
|
|
40
|
+
* (RustFS in the platform). Reference a bucket whose credentials live
|
|
41
|
+
* in a Secret provisioned by the secrets backend (keys: accessKey,
|
|
42
|
+
* secretKey) — never plaintext.
|
|
43
|
+
*/
|
|
44
|
+
objectStorage?: {
|
|
45
|
+
/** S3 host WITHOUT protocol/scheme, e.g. s3.internal.example.com */
|
|
46
|
+
endpoint: string
|
|
47
|
+
/** Bucket used for user files */
|
|
48
|
+
bucket: string
|
|
49
|
+
/** Name of the Secret holding accessKey / secretKey */
|
|
50
|
+
credentialsSecret: string
|
|
51
|
+
/** Region string for the S3 client (defaults to 'us-east-1') */
|
|
52
|
+
region?: string
|
|
53
|
+
/** TCP port of the S3 endpoint (defaults to the provider default, typically 443) */
|
|
54
|
+
port?: number
|
|
55
|
+
/** Use TLS against the S3 endpoint (default: true) */
|
|
56
|
+
ssl?: boolean
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Name of an existing Secret holding the Nextcloud app secrets
|
|
60
|
+
* (key: adminPassword). Required unless a secrets backend
|
|
61
|
+
* (openbao/vault) is configured on the surrounding Platform — the
|
|
62
|
+
* backend then provisions them automatically. Plaintext admin
|
|
63
|
+
* passwords are not supported.
|
|
64
|
+
*/
|
|
65
|
+
secretsName?: string
|
|
66
|
+
/** Requested resources */
|
|
67
|
+
resources?: {
|
|
68
|
+
requests?: { cpu?: string; memory?: string }
|
|
69
|
+
limits?: { cpu?: string; memory?: string }
|
|
70
|
+
}
|
|
71
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
72
|
+
tls?: {
|
|
73
|
+
secretName: string
|
|
74
|
+
clusterIssuer: string
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const OPERATOR_REDIS = 'redis-operator'
|
|
79
|
+
const HTML_MOUNT = '/var/www/html'
|
|
80
|
+
const STATUS_PATH = '/status.php'
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Nextcloud — self-hosted file cloud with Postgres, Redis, S3 primary
|
|
84
|
+
* storage and background cron jobs.
|
|
85
|
+
*
|
|
86
|
+
* @title Nextcloud
|
|
87
|
+
* @category File & Collaboration
|
|
88
|
+
*
|
|
89
|
+
* Composes:
|
|
90
|
+
* - CNPG Postgres cluster (file index, shares, user data)
|
|
91
|
+
* - Redis replication set for file locking and caching (default on)
|
|
92
|
+
* - PersistentVolumeClaim backing /var/www/html (ReadWriteMany)
|
|
93
|
+
* - Nextcloud Deployment + Service + Endpoint (nextcloud:31-apache,
|
|
94
|
+
* probed via /status.php)
|
|
95
|
+
* - CronJob running `php -f /var/www/html/cron.php` every 5 minutes on
|
|
96
|
+
* the same claim (concurrency forbidden)
|
|
97
|
+
* - S3/RustFS bucket reference for primary file storage
|
|
98
|
+
* - Admin bootstrap password provisioned by the Platform secrets
|
|
99
|
+
* backend (openbao / vault), or referenced from an existing Secret
|
|
100
|
+
*
|
|
101
|
+
* The main app is a raw Deployment (not WebService) because Nextcloud
|
|
102
|
+
* needs the /var/www/html claim volume-mounted; the CronJob shares the
|
|
103
|
+
* same claim so background jobs operate on the live data tree.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* import { Platform } from '@r8s/recipes'
|
|
107
|
+
* import { Nextcloud } from '@r8s/nextcloud'
|
|
108
|
+
*
|
|
109
|
+
* export default (
|
|
110
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
111
|
+
* <Nextcloud
|
|
112
|
+
* name="cloud"
|
|
113
|
+
* host="cloud.example.com"
|
|
114
|
+
* objectStorage={{
|
|
115
|
+
* endpoint: 's3.internal.example.com',
|
|
116
|
+
* bucket: 'cloud-files',
|
|
117
|
+
* credentialsSecret: 'cloud-files-credentials',
|
|
118
|
+
* }}
|
|
119
|
+
* />
|
|
120
|
+
* </Platform>
|
|
121
|
+
* )
|
|
122
|
+
* @example
|
|
123
|
+
* import { Nextcloud } from '@r8s/nextcloud'
|
|
124
|
+
*
|
|
125
|
+
* export default (
|
|
126
|
+
* <Nextcloud
|
|
127
|
+
* name="cloud"
|
|
128
|
+
* host="${env:CLOUD_HOST}"
|
|
129
|
+
* secretsName="cloud-app-secrets"
|
|
130
|
+
* objectStorage={{
|
|
131
|
+
* endpoint: '${env:S3_ENDPOINT}',
|
|
132
|
+
* bucket: 'cloud-files',
|
|
133
|
+
* credentialsSecret: 'cloud-files-credentials',
|
|
134
|
+
* }}
|
|
135
|
+
* />
|
|
136
|
+
* )
|
|
137
|
+
*/
|
|
138
|
+
export function Nextcloud(props: NextcloudProps) {
|
|
139
|
+
const {
|
|
140
|
+
name = 'nextcloud',
|
|
141
|
+
namespace = 'default',
|
|
142
|
+
version = '31-apache',
|
|
143
|
+
host,
|
|
144
|
+
replicas = 1,
|
|
145
|
+
cache = true,
|
|
146
|
+
storage = '10Gi',
|
|
147
|
+
storageClassName,
|
|
148
|
+
objectStorage,
|
|
149
|
+
secretsName,
|
|
150
|
+
resources = {
|
|
151
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
152
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
153
|
+
},
|
|
154
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
155
|
+
} = props
|
|
156
|
+
|
|
157
|
+
const sharedOperators = useContext(OperatorContext)
|
|
158
|
+
const secretProvider = useContext(SecretContext)
|
|
159
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
160
|
+
|
|
161
|
+
const dbHost = `${name}-rw`
|
|
162
|
+
const dbCredentialsName = `${name}-db-credentials`
|
|
163
|
+
const appSecretsName = secretsName ?? `${name}-app-secrets`
|
|
164
|
+
const htmlClaim = `${name}-html`
|
|
165
|
+
const image = `nextcloud:${version}`
|
|
166
|
+
const cronSchedule = '*/5 * * * *'
|
|
167
|
+
|
|
168
|
+
// --- App secrets provisioning ---------------------------------------------
|
|
169
|
+
// The admin bootstrap password must never be rendered as plaintext. With a
|
|
170
|
+
// secrets backend it is provisioned through the backend (key `adminPassword`);
|
|
171
|
+
// otherwise reference a pre-created Secret.
|
|
172
|
+
if (!secretsName) {
|
|
173
|
+
if (
|
|
174
|
+
!secretProvider ||
|
|
175
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
176
|
+
) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Nextcloud "${name}" requires application secrets (NEXTCLOUD_ADMIN_PASSWORD).\n` +
|
|
179
|
+
`\n` +
|
|
180
|
+
`Nextcloud bootstraps its admin account from this password — it must not ` +
|
|
181
|
+
`be rendered as plaintext.\n` +
|
|
182
|
+
`\n` +
|
|
183
|
+
`Fix: configure a secrets backend on the Platform:\n` +
|
|
184
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
|
|
185
|
+
` <Nextcloud name="${name}" host="${host}" />\n` +
|
|
186
|
+
` </Platform>\n` +
|
|
187
|
+
`\n` +
|
|
188
|
+
`Or reference a pre-created Secret (key: adminPassword):\n` +
|
|
189
|
+
` <Nextcloud name="${name}" host="${host}" secretsName="${name}-app-secrets" />`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const spec = {
|
|
194
|
+
...(secretProvider.backend === 'vault'
|
|
195
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
196
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
197
|
+
mount: secretProvider.mount,
|
|
198
|
+
type: 'kv-v2' as const,
|
|
199
|
+
path: `${secretProvider.path ?? name}/${name}/secrets`,
|
|
200
|
+
destination: { create: true, name: appSecretsName },
|
|
201
|
+
}
|
|
202
|
+
resources_.push(
|
|
203
|
+
secretProvider.backend === 'vault'
|
|
204
|
+
? jsx('VaultStaticSecret', {
|
|
205
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
206
|
+
kind: 'VaultStaticSecret',
|
|
207
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
208
|
+
spec,
|
|
209
|
+
})
|
|
210
|
+
: jsx('OpenBaoStaticSecret', {
|
|
211
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
212
|
+
kind: 'OpenBaoStaticSecret',
|
|
213
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
214
|
+
spec,
|
|
215
|
+
})
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- Operators -------------------------------------------------------------
|
|
220
|
+
if (cache && !sharedOperators.some((op) => op.name === OPERATOR_REDIS)) {
|
|
221
|
+
resources_.push(declareOperator(operators[OPERATOR_REDIS]()))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Redis — file locking + caching (OT-Container-Kit operator). A
|
|
225
|
+
// RedisReplication (master/replica) is what Nextcloud's locking needs;
|
|
226
|
+
// cluster mode is overkill and the `${name}-redis` service fronts it.
|
|
227
|
+
if (cache) {
|
|
228
|
+
resources_.push(
|
|
229
|
+
RedisReplicationComponent({
|
|
230
|
+
metadata: { name: `${name}-redis`, namespace },
|
|
231
|
+
spec: {
|
|
232
|
+
clusterSize: 3,
|
|
233
|
+
kubernetesConfig: { image: 'redis:7.2-alpine' },
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- /var/www/html persistence ----------------------------------------------
|
|
240
|
+
// Apps, config and the data directory live under /var/www/html and must
|
|
241
|
+
// survive pod restarts. ReadWriteMany lets the cron job share the same
|
|
242
|
+
// tree and keeps replicas > 1 viable (requires an RWX StorageClass).
|
|
243
|
+
const htmlPvc: PersistentVolumeClaim = {
|
|
244
|
+
apiVersion: 'v1',
|
|
245
|
+
kind: 'PersistentVolumeClaim',
|
|
246
|
+
metadata: { name: htmlClaim, namespace, labels: { app: name } },
|
|
247
|
+
spec: {
|
|
248
|
+
accessModes: ['ReadWriteMany'],
|
|
249
|
+
resources: { requests: { storage } },
|
|
250
|
+
...(storageClassName && { storageClassName }),
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
resources_.push(jsx('PersistentVolumeClaim', htmlPvc))
|
|
254
|
+
|
|
255
|
+
// --- Env wiring --------------------------------------------------------------
|
|
256
|
+
// Every credential is referenced with $(VAR) expansion or secretKeyRef —
|
|
257
|
+
// no plaintext in the manifest. Secret-backed vars are declared BEFORE
|
|
258
|
+
// plain env vars so dependent $(VAR) expansion resolves. Nextcloud
|
|
259
|
+
// natively reads POSTGRES_* and OBJECTSTORE_S3_*; the S3 keys/secrets are
|
|
260
|
+
// expanded from the AWS_* secret-backed vars. (Nextcloud ignores
|
|
261
|
+
// DATABASE_URL — do not add it back.)
|
|
262
|
+
const secrets: Record<string, SecretRef | string> = {
|
|
263
|
+
PGPASSWORD: { secret: dbCredentialsName, key: 'password' },
|
|
264
|
+
NEXTCLOUD_ADMIN_PASSWORD: { secret: appSecretsName, key: 'adminPassword' },
|
|
265
|
+
...(objectStorage
|
|
266
|
+
? {
|
|
267
|
+
AWS_ACCESS_KEY_ID: { secret: objectStorage.credentialsSecret, key: 'accessKey' },
|
|
268
|
+
AWS_SECRET_ACCESS_KEY: { secret: objectStorage.credentialsSecret, key: 'secretKey' },
|
|
269
|
+
}
|
|
270
|
+
: {}),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const env: Record<string, string> = {
|
|
274
|
+
POSTGRES_HOST: dbHost,
|
|
275
|
+
POSTGRES_PORT: '5432',
|
|
276
|
+
POSTGRES_DB: name,
|
|
277
|
+
POSTGRES_USER: name,
|
|
278
|
+
POSTGRES_PASSWORD: '$(PGPASSWORD)',
|
|
279
|
+
NEXTCLOUD_ADMIN_USER: 'admin',
|
|
280
|
+
NEXTCLOUD_TRUSTED_DOMAINS: host,
|
|
281
|
+
OVERWRITEHOST: host,
|
|
282
|
+
OVERWRITEPROTOCOL: 'https',
|
|
283
|
+
OVERWRITECLIURL: `https://${host}`,
|
|
284
|
+
...(cache ? { REDIS_HOST: `${name}-redis`, REDIS_HOST_PORT: '6379' } : {}),
|
|
285
|
+
...(objectStorage
|
|
286
|
+
? {
|
|
287
|
+
OBJECTSTORE_S3_HOST: objectStorage.endpoint,
|
|
288
|
+
OBJECTSTORE_S3_BUCKET: objectStorage.bucket,
|
|
289
|
+
OBJECTSTORE_S3_REGION: objectStorage.region ?? 'us-east-1',
|
|
290
|
+
OBJECTSTORE_S3_SSL: (objectStorage.ssl ?? true) ? 'true' : 'false',
|
|
291
|
+
...(objectStorage.port !== undefined && {
|
|
292
|
+
OBJECTSTORE_S3_PORT: String(objectStorage.port),
|
|
293
|
+
}),
|
|
294
|
+
OBJECTSTORE_S3_USEPATH_STYLE: 'true',
|
|
295
|
+
OBJECTSTORE_S3_AUTOCREATE: 'true',
|
|
296
|
+
OBJECTSTORE_S3_KEY: '$(AWS_ACCESS_KEY_ID)',
|
|
297
|
+
OBJECTSTORE_S3_SECRET: '$(AWS_SECRET_ACCESS_KEY)',
|
|
298
|
+
}
|
|
299
|
+
: {}),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const envVars: EnvVar[] = Object.entries(secrets).map(([envName, ref]) => ({
|
|
303
|
+
name: envName,
|
|
304
|
+
valueFrom: {
|
|
305
|
+
secretKeyRef:
|
|
306
|
+
typeof ref === 'string'
|
|
307
|
+
? { name: ref, key: envName }
|
|
308
|
+
: { name: ref.secret, key: ref.key || envName },
|
|
309
|
+
},
|
|
310
|
+
}))
|
|
311
|
+
envVars.push(...Object.entries(env).map(([envName, value]) => ({ name: envName, value })))
|
|
312
|
+
|
|
313
|
+
// --- Database (CNPG) + app + endpoint ------------------------------------------
|
|
314
|
+
// <Database> is the parent wrapper so the CNPG operator and the Cluster
|
|
315
|
+
// piggyback on the r8s Database recipe (connect info stays by convention:
|
|
316
|
+
// host `${name}-rw`, secret `${name}-db-credentials` key `password`). The
|
|
317
|
+
// app is a raw Deployment because WebService cannot mount volumes.
|
|
318
|
+
const app: Deployment = {
|
|
319
|
+
apiVersion: 'apps/v1',
|
|
320
|
+
kind: 'Deployment',
|
|
321
|
+
metadata: { name, namespace, labels: { app: name } },
|
|
322
|
+
spec: {
|
|
323
|
+
replicas,
|
|
324
|
+
selector: { matchLabels: { app: name } },
|
|
325
|
+
template: {
|
|
326
|
+
metadata: { labels: { app: name } },
|
|
327
|
+
spec: {
|
|
328
|
+
containers: [
|
|
329
|
+
{
|
|
330
|
+
name: 'app',
|
|
331
|
+
image,
|
|
332
|
+
imagePullPolicy: 'Always',
|
|
333
|
+
ports: [{ containerPort: 80 }],
|
|
334
|
+
env: envVars,
|
|
335
|
+
resources,
|
|
336
|
+
livenessProbe: {
|
|
337
|
+
httpGet: { path: STATUS_PATH, port: 80 },
|
|
338
|
+
initialDelaySeconds: 30,
|
|
339
|
+
periodSeconds: 10,
|
|
340
|
+
},
|
|
341
|
+
readinessProbe: {
|
|
342
|
+
httpGet: { path: STATUS_PATH, port: 80 },
|
|
343
|
+
initialDelaySeconds: 20,
|
|
344
|
+
periodSeconds: 10,
|
|
345
|
+
failureThreshold: 5,
|
|
346
|
+
},
|
|
347
|
+
volumeMounts: [{ name: 'html', mountPath: HTML_MOUNT }],
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
volumes: [{ name: 'html', persistentVolumeClaim: { claimName: htmlClaim } }],
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const appService: Service = {
|
|
357
|
+
apiVersion: 'v1',
|
|
358
|
+
kind: 'Service',
|
|
359
|
+
metadata: { name, namespace },
|
|
360
|
+
spec: {
|
|
361
|
+
type: 'ClusterIP',
|
|
362
|
+
selector: { app: name },
|
|
363
|
+
ports: [{ port: 80, targetPort: 80 }],
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
resources_.push(
|
|
368
|
+
jsx(Database, {
|
|
369
|
+
name,
|
|
370
|
+
namespace,
|
|
371
|
+
storage: '10Gi',
|
|
372
|
+
children: jsx(Fragment, {
|
|
373
|
+
children: [jsx('Deployment', app), jsx('Service', appService)],
|
|
374
|
+
}),
|
|
375
|
+
})
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
// --- CronJob — background jobs (php -f cron.php) --------------------------------
|
|
379
|
+
// WebService cannot express a separate scheduled workload, so the cron job
|
|
380
|
+
// is a raw CronJob that mounts the SAME `${name}-html` claim and runs the
|
|
381
|
+
// image's own php. It cannot see DatabaseContext: host/user are hardcoded
|
|
382
|
+
// by convention and the DB password arrives via secretKeyRef.
|
|
383
|
+
resources_.push(
|
|
384
|
+
jsx('CronJob', {
|
|
385
|
+
apiVersion: 'batch/v1',
|
|
386
|
+
kind: 'CronJob',
|
|
387
|
+
metadata: { name: `${name}-cron`, namespace },
|
|
388
|
+
spec: {
|
|
389
|
+
schedule: cronSchedule,
|
|
390
|
+
concurrencyPolicy: 'Forbid',
|
|
391
|
+
successfulJobsHistoryLimit: 1,
|
|
392
|
+
failedJobsHistoryLimit: 1,
|
|
393
|
+
jobTemplate: {
|
|
394
|
+
spec: {
|
|
395
|
+
backoffLimit: 2,
|
|
396
|
+
template: {
|
|
397
|
+
metadata: { labels: { app: `${name}-cron` } },
|
|
398
|
+
spec: {
|
|
399
|
+
restartPolicy: 'OnFailure',
|
|
400
|
+
containers: [
|
|
401
|
+
{
|
|
402
|
+
name: 'nextcloud-cron',
|
|
403
|
+
image,
|
|
404
|
+
command: ['php', '-f', '/var/www/html/cron.php'],
|
|
405
|
+
env: [
|
|
406
|
+
{ name: 'POSTGRES_HOST', value: dbHost },
|
|
407
|
+
{ name: 'POSTGRES_PORT', value: '5432' },
|
|
408
|
+
{ name: 'POSTGRES_DB', value: name },
|
|
409
|
+
{ name: 'POSTGRES_USER', value: name },
|
|
410
|
+
{
|
|
411
|
+
name: 'PGPASSWORD',
|
|
412
|
+
valueFrom: {
|
|
413
|
+
secretKeyRef: { name: dbCredentialsName, key: 'password' },
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
...(cache ? [{ name: 'REDIS_HOST', value: `${name}-redis` }] : []),
|
|
417
|
+
],
|
|
418
|
+
resources: {
|
|
419
|
+
requests: { memory: '128Mi', cpu: '100m' },
|
|
420
|
+
limits: { memory: '512Mi', cpu: '500m' },
|
|
421
|
+
},
|
|
422
|
+
volumeMounts: [{ name: 'html', mountPath: HTML_MOUNT }],
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
volumes: [{ name: 'html', persistentVolumeClaim: { claimName: htmlClaim } }],
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
})
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
// --- Endpoint — web UI and WebDAV share the host --------------------------------
|
|
435
|
+
resources_.push(
|
|
436
|
+
<Endpoint
|
|
437
|
+
name={`${name}-endpoint`}
|
|
438
|
+
namespace={namespace}
|
|
439
|
+
host={host}
|
|
440
|
+
serviceName={name}
|
|
441
|
+
servicePort={80}
|
|
442
|
+
tls={tls}
|
|
443
|
+
/>
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
return jsx(Fragment, { children: resources_ })
|
|
447
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"composite": true
|
|
7
|
+
},
|
|
8
|
+
"include": ["src/**/*"],
|
|
9
|
+
"references": [
|
|
10
|
+
{ "path": "../core" },
|
|
11
|
+
{ "path": "../k8s-types" },
|
|
12
|
+
{ "path": "../crds" },
|
|
13
|
+
{ "path": "../recipes" }
|
|
14
|
+
]
|
|
15
|
+
}
|