@r8s/outline 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__/outline.test.ts +213 -0
- package/examples/basic.tsx +21 -0
- package/package.json +41 -0
- package/src/index.tsx +289 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,213 @@
|
|
|
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
|
+
// Outline recipe tests:
|
|
9
|
+
// 1. Operator declarations (deduped via OperatorContext)
|
|
10
|
+
// 2. Rendering: defaults, all props, gateway/ingress adaptation
|
|
11
|
+
// 3. Security: no plaintext credentials in rendered output
|
|
12
|
+
import { Outline } from '../src/index'
|
|
13
|
+
|
|
14
|
+
const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
|
|
15
|
+
|
|
16
|
+
/** Render Outline inside a Platform-like secrets backend (OpenBao). */
|
|
17
|
+
function renderOutline(props: Record<string, unknown>): ReturnType<typeof render> {
|
|
18
|
+
return render(
|
|
19
|
+
jsx(SecretContext.Provider, {
|
|
20
|
+
value: openbao as never,
|
|
21
|
+
children: jsx(Outline, props as never),
|
|
22
|
+
})
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Wrap Outline in an OperatorContext (no secrets backend). */
|
|
27
|
+
function elementWithContext(ops: any[], props: Record<string, unknown>): r8sElement {
|
|
28
|
+
return jsx(OperatorContext.Provider, {
|
|
29
|
+
value: ops,
|
|
30
|
+
children: jsx(Outline, props as never),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const objectStorage = {
|
|
35
|
+
endpoint: 'https://s3.internal.example.com',
|
|
36
|
+
bucket: 'wiki-attachments',
|
|
37
|
+
credentialsSecret: 'wiki-attachments-credentials',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sso = {
|
|
41
|
+
issuer: 'https://keycloak.example.com/realms/platform',
|
|
42
|
+
clientId: 'outline',
|
|
43
|
+
clientSecretRef: { secret: 'outline-sso', key: 'clientSecret' },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('operator declarations', () => {
|
|
47
|
+
it('declares the redis operator when cache is enabled', () => {
|
|
48
|
+
const result = renderOutline({ host: 'wiki.example.com' })
|
|
49
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('declares the cnpg operator via the Database recipe', () => {
|
|
53
|
+
const result = renderOutline({ host: 'wiki.example.com' })
|
|
54
|
+
expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('skips the redis operator when cache is disabled', () => {
|
|
58
|
+
const result = renderOutline({ host: 'wiki.example.com', cache: false })
|
|
59
|
+
expect(result.operators.some((op) => op.name === 'redis-operator')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('deduplicates operators provided via context', () => {
|
|
63
|
+
const result = render(
|
|
64
|
+
elementWithContext([operators['redis-operator'](), operators['cnpg']()], {
|
|
65
|
+
host: 'wiki.example.com',
|
|
66
|
+
secretsName: 'existing-secrets',
|
|
67
|
+
})
|
|
68
|
+
)
|
|
69
|
+
const names = result.operators.map((op) => op.name)
|
|
70
|
+
expect(names.filter((n) => n === 'redis-operator')).toHaveLength(1)
|
|
71
|
+
expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('rendering defaults', () => {
|
|
76
|
+
it('renders deployment, service, ingress, database and redis', () => {
|
|
77
|
+
const result = renderOutline({ host: 'wiki.example.com' })
|
|
78
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
79
|
+
expect(kinds).toContain('Deployment')
|
|
80
|
+
expect(kinds).toContain('Service')
|
|
81
|
+
expect(kinds).toContain('Ingress')
|
|
82
|
+
expect(kinds).toContain('Cluster')
|
|
83
|
+
expect(kinds).toContain('RedisReplication')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('renders gateway resources when platform uses gateway routing', () => {
|
|
87
|
+
const result = render(
|
|
88
|
+
jsx(RoutingContext.Provider, {
|
|
89
|
+
value: { mode: 'gateway', gatewayClassName: 'eg' },
|
|
90
|
+
children: jsx(SecretContext.Provider, {
|
|
91
|
+
value: openbao as never,
|
|
92
|
+
children: jsx(Outline, { host: 'wiki.example.com' }),
|
|
93
|
+
}),
|
|
94
|
+
})
|
|
95
|
+
)
|
|
96
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
97
|
+
expect(kinds).toContain('HTTPRoute')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('renders a valid Ingress when platform uses ingress routing', () => {
|
|
101
|
+
const result = render(
|
|
102
|
+
jsx(RoutingContext.Provider, {
|
|
103
|
+
value: { mode: 'ingress' },
|
|
104
|
+
children: jsx(SecretContext.Provider, {
|
|
105
|
+
value: openbao as never,
|
|
106
|
+
children: jsx(Outline, { host: 'wiki.example.com' }),
|
|
107
|
+
}),
|
|
108
|
+
})
|
|
109
|
+
)
|
|
110
|
+
const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
|
|
111
|
+
expect(ingress).toBeDefined()
|
|
112
|
+
expect(ingress.spec.rules[0].host).toBe('wiki.example.com')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('passes resource validation', () => {
|
|
116
|
+
const result = renderOutline({
|
|
117
|
+
host: 'wiki.example.com',
|
|
118
|
+
objectStorage,
|
|
119
|
+
sso,
|
|
120
|
+
})
|
|
121
|
+
for (const resource of result.resources) {
|
|
122
|
+
expect(validateResource(resource)).toEqual([])
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('rendering with all props', () => {
|
|
128
|
+
it('accepts the full prop surface', () => {
|
|
129
|
+
const result = renderOutline({
|
|
130
|
+
name: 'wiki',
|
|
131
|
+
namespace: 'docs',
|
|
132
|
+
version: '0.78.0',
|
|
133
|
+
host: 'docs.example.com',
|
|
134
|
+
replicas: 3,
|
|
135
|
+
objectStorage: { ...objectStorage, bucket: 'docs' },
|
|
136
|
+
sso,
|
|
137
|
+
resources: {
|
|
138
|
+
requests: { memory: '1Gi', cpu: '500m' },
|
|
139
|
+
limits: { memory: '4Gi', cpu: '2000m' },
|
|
140
|
+
},
|
|
141
|
+
tls: { secretName: 'docs-tls', clusterIssuer: 'letsencrypt-prod' },
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const app = result.resources.find(
|
|
145
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'wiki'
|
|
146
|
+
) as any
|
|
147
|
+
expect(app).toBeDefined()
|
|
148
|
+
expect(app.spec.replicas).toBe(3)
|
|
149
|
+
expect(app.spec.template.spec.containers[0].image).toContain('0.78.0')
|
|
150
|
+
expect(app.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('wires S3 and SSO on the app when configured', () => {
|
|
154
|
+
const result = renderOutline({
|
|
155
|
+
host: 'wiki.example.com',
|
|
156
|
+
objectStorage,
|
|
157
|
+
sso,
|
|
158
|
+
})
|
|
159
|
+
const app = result.resources.find(
|
|
160
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'outline'
|
|
161
|
+
) as any
|
|
162
|
+
const env = app.spec.template.spec.containers[0].env
|
|
163
|
+
expect(env.find((e: any) => e.name === 'AWS_S3_UPLOAD_BUCKET_NAME').value).toBe(
|
|
164
|
+
'wiki-attachments'
|
|
165
|
+
)
|
|
166
|
+
expect(env.find((e: any) => e.name === 'OIDC_ISSUER').value).toBe(sso.issuer)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('secrets handling', () => {
|
|
171
|
+
it('provisions app secrets through a secrets backend', () => {
|
|
172
|
+
const result = renderOutline({ host: 'wiki.example.com' })
|
|
173
|
+
const kinds = result.resources.map((r) => r.kind)
|
|
174
|
+
expect(kinds).toContain('OpenBaoStaticSecret')
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('throws when no secrets backend and no secretsName', () => {
|
|
178
|
+
expect(() => render(jsx(Outline, { host: 'wiki.example.com' }))).toThrow(/application secrets/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('accepts an existing secretsName without a backend', () => {
|
|
182
|
+
expect(() =>
|
|
183
|
+
render(jsx(Outline, { host: 'wiki.example.com', secretsName: 'existing-secrets' }))
|
|
184
|
+
).not.toThrow()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('wires credentials via secretKeyRef (never plaintext env)', () => {
|
|
188
|
+
const result = renderOutline({ host: 'wiki.example.com', secretsName: 'existing-secrets' })
|
|
189
|
+
const app = result.resources.find(
|
|
190
|
+
(r: any) => r.kind === 'Deployment' && r.metadata.name === 'outline'
|
|
191
|
+
) as any
|
|
192
|
+
const env = app.spec.template.spec.containers[0].env
|
|
193
|
+
const secretKey = env.find((e: any) => e.name === 'SECRET_KEY')
|
|
194
|
+
const pgPassword = env.find((e: any) => e.name === 'PGPASSWORD')
|
|
195
|
+
expect(secretKey.valueFrom.secretKeyRef.name).toBe('existing-secrets')
|
|
196
|
+
expect(pgPassword.valueFrom.secretKeyRef.name).toBe('outline-db-credentials')
|
|
197
|
+
expect(secretKey.value).toBeUndefined()
|
|
198
|
+
expect(pgPassword.value).toBeUndefined()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('renders no plaintext credentials anywhere', () => {
|
|
202
|
+
const result = renderOutline({
|
|
203
|
+
host: 'wiki.example.com',
|
|
204
|
+
objectStorage,
|
|
205
|
+
sso,
|
|
206
|
+
})
|
|
207
|
+
const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
|
|
208
|
+
if (!passed) {
|
|
209
|
+
console.error('Plaintext credential violations:', errors)
|
|
210
|
+
}
|
|
211
|
+
expect(passed).toBe(true)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Platform } from '@r8s/recipes'
|
|
2
|
+
import { Outline } from '@r8s/outline'
|
|
3
|
+
|
|
4
|
+
export default (
|
|
5
|
+
<Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
6
|
+
<Outline
|
|
7
|
+
name="wiki"
|
|
8
|
+
host="wiki.example.com"
|
|
9
|
+
objectStorage={{
|
|
10
|
+
endpoint: 'https://s3.internal.example.com',
|
|
11
|
+
bucket: 'wiki-attachments',
|
|
12
|
+
credentialsSecret: 'wiki-attachments-credentials',
|
|
13
|
+
}}
|
|
14
|
+
sso={{
|
|
15
|
+
issuer: 'https://keycloak.example.com/realms/platform',
|
|
16
|
+
clientId: 'outline',
|
|
17
|
+
clientSecretRef: { secret: 'outline-sso', key: 'clientSecret' },
|
|
18
|
+
}}
|
|
19
|
+
/>
|
|
20
|
+
</Platform>
|
|
21
|
+
)
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@r8s/outline",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Outline wiki — Postgres persistence, Redis queue, S3 attachments, OIDC SSO via Keycloak",
|
|
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/outline"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/berget-ai/r8s/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"outline",
|
|
16
|
+
"wiki",
|
|
17
|
+
"documentation",
|
|
18
|
+
"knowledge"
|
|
19
|
+
],
|
|
20
|
+
"r8s": {
|
|
21
|
+
"category": "Knowledge & Documentation"
|
|
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,289 @@
|
|
|
1
|
+
import { jsx, Fragment, useContext, declareOperator } from '@r8s/core'
|
|
2
|
+
import { Namespace, OperatorContext, SecretContext } from '@r8s/core/defaults'
|
|
3
|
+
import { operators } from '@r8s/crds'
|
|
4
|
+
import { Database, WebService, Endpoint } from '@r8s/recipes'
|
|
5
|
+
import { RedisReplicationComponent } from '@r8s/crds/redis'
|
|
6
|
+
import type { SecretRef } from '@r8s/recipes'
|
|
7
|
+
|
|
8
|
+
export interface OutlineProps {
|
|
9
|
+
/** Resource name (defaults to 'outline') */
|
|
10
|
+
name?: string
|
|
11
|
+
/** Kubernetes namespace (defaults to 'default') */
|
|
12
|
+
namespace?: string
|
|
13
|
+
/** Container image tag (defaults to 'latest' — pin a version in production) */
|
|
14
|
+
version?: string
|
|
15
|
+
/** Public hostname for the wiki */
|
|
16
|
+
host: string
|
|
17
|
+
/** Storage request for the CNPG Postgres cluster (defaults to '10Gi') */
|
|
18
|
+
storage?: string
|
|
19
|
+
/** Number of replicas (Outline is stateless — scale freely) */
|
|
20
|
+
replicas?: number
|
|
21
|
+
/** Provision a Redis cluster for the queue and rate limiting (default: true) */
|
|
22
|
+
cache?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* S3-compatible object storage for attachments (RustFS in the platform).
|
|
25
|
+
* Reference a bucket whose credentials live in a Secret provisioned by
|
|
26
|
+
* the secrets backend (keys: accessKey, secretKey) — never plaintext.
|
|
27
|
+
*/
|
|
28
|
+
objectStorage?: {
|
|
29
|
+
/** S3 endpoint URL, e.g. https://s3.internal.example.com */
|
|
30
|
+
endpoint: string
|
|
31
|
+
/** Bucket name for attachments */
|
|
32
|
+
bucket: string
|
|
33
|
+
/** Name of the Secret holding accessKey / secretKey */
|
|
34
|
+
credentialsSecret: string
|
|
35
|
+
/** Region string for Outline's S3 client (defaults to 'us-east-1') */
|
|
36
|
+
region?: string
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* OIDC SSO client — register Outline as a client in Keycloak (the
|
|
40
|
+
* Auth recipe) and reference the client secret through the backend.
|
|
41
|
+
*/
|
|
42
|
+
sso?: {
|
|
43
|
+
issuer: string
|
|
44
|
+
clientId: string
|
|
45
|
+
clientSecretRef: SecretRef
|
|
46
|
+
scopes?: string
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Name of an existing Secret holding `secretKey` and `utilsSecret`.
|
|
50
|
+
* Required unless a secrets backend (openbao/vault) is configured on
|
|
51
|
+
* the surrounding Platform — the backend then provisions them.
|
|
52
|
+
*/
|
|
53
|
+
secretsName?: string
|
|
54
|
+
/** Requested resources */
|
|
55
|
+
resources?: {
|
|
56
|
+
requests?: { cpu?: string; memory?: string }
|
|
57
|
+
limits?: { cpu?: string; memory?: string }
|
|
58
|
+
}
|
|
59
|
+
/** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
|
|
60
|
+
tls?: {
|
|
61
|
+
secretName: string
|
|
62
|
+
clusterIssuer: string
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const OPERATOR_REDIS = 'redis-operator'
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Outline — team wiki with Postgres, Redis, S3 attachments and OIDC SSO.
|
|
70
|
+
*
|
|
71
|
+
* @title Outline
|
|
72
|
+
* @category Knowledge & Documentation
|
|
73
|
+
*
|
|
74
|
+
* Composes:
|
|
75
|
+
* - CNPG Postgres cluster (documents, revisions, users)
|
|
76
|
+
* - Redis cluster for queue + rate limiting (default on)
|
|
77
|
+
* - Outline Deployment + Service + Endpoint
|
|
78
|
+
* - S3/RustFS bucket reference for attachments
|
|
79
|
+
* - SECRET_KEY / UTILS_SECRET provisioned by the Platform secrets
|
|
80
|
+
* backend (openbao / vault), or referenced from an existing Secret
|
|
81
|
+
* - OIDC SSO against the Keycloak `Auth` recipe
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* import { Platform } from '@r8s/recipes'
|
|
85
|
+
* import { Outline } from '@r8s/outline'
|
|
86
|
+
*
|
|
87
|
+
* export default (
|
|
88
|
+
* <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
|
|
89
|
+
* <Outline
|
|
90
|
+
* name="wiki"
|
|
91
|
+
* host="wiki.example.com"
|
|
92
|
+
* objectStorage={{
|
|
93
|
+
* endpoint: 'https://s3.internal.example.com',
|
|
94
|
+
* bucket: 'wiki-attachments',
|
|
95
|
+
* credentialsSecret: 'wiki-attachments-credentials',
|
|
96
|
+
* }}
|
|
97
|
+
* sso={{
|
|
98
|
+
* issuer: 'https://keycloak.example.com/realms/platform',
|
|
99
|
+
* clientId: 'outline',
|
|
100
|
+
* clientSecretRef: { secret: 'outline-sso', key: 'clientSecret' },
|
|
101
|
+
* }}
|
|
102
|
+
* />
|
|
103
|
+
* </Platform>
|
|
104
|
+
* )
|
|
105
|
+
*/
|
|
106
|
+
export function Outline(props: OutlineProps) {
|
|
107
|
+
const {
|
|
108
|
+
name = 'outline',
|
|
109
|
+
namespace: namespaceProp,
|
|
110
|
+
version = 'latest',
|
|
111
|
+
host,
|
|
112
|
+
storage = '10Gi',
|
|
113
|
+
replicas = 1,
|
|
114
|
+
cache = true,
|
|
115
|
+
objectStorage,
|
|
116
|
+
sso,
|
|
117
|
+
secretsName,
|
|
118
|
+
resources = {
|
|
119
|
+
requests: { memory: '512Mi', cpu: '250m' },
|
|
120
|
+
limits: { memory: '2Gi', cpu: '1000m' },
|
|
121
|
+
},
|
|
122
|
+
tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
|
|
123
|
+
} = props
|
|
124
|
+
|
|
125
|
+
const sharedOperators = useContext(OperatorContext)
|
|
126
|
+
const secretProvider = useContext(SecretContext)
|
|
127
|
+
// Inherit namespace from <Platform> context — mirrors recipes/database.tsx
|
|
128
|
+
const contextNamespace = useContext(Namespace)
|
|
129
|
+
const namespace =
|
|
130
|
+
namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
|
|
131
|
+
const resources_: ReturnType<typeof jsx>[] = []
|
|
132
|
+
|
|
133
|
+
const dbHost = `${name}-rw`
|
|
134
|
+
const dbCredentialsName = `${name}-db-credentials`
|
|
135
|
+
const platformSecretsName = secretsName ?? `${name}-app-secrets`
|
|
136
|
+
|
|
137
|
+
// --- App secrets (SECRET_KEY / UTILS_SECRET) ------------------------------
|
|
138
|
+
if (!secretsName) {
|
|
139
|
+
if (
|
|
140
|
+
!secretProvider ||
|
|
141
|
+
(secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
|
|
142
|
+
) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Outline "${name}" requires application secrets (SECRET_KEY, UTILS_SECRET).\n` +
|
|
145
|
+
`\n` +
|
|
146
|
+
`These must not be rendered as plaintext.\n` +
|
|
147
|
+
`\n` +
|
|
148
|
+
`Fix: configure a secrets backend on the Platform:\n` +
|
|
149
|
+
` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
|
|
150
|
+
` <Outline name="${name}" host="${host}" />\n` +
|
|
151
|
+
` </Platform>\n` +
|
|
152
|
+
`\n` +
|
|
153
|
+
`Or reference a pre-created Secret (keys: secretKey, utilsSecret):\n` +
|
|
154
|
+
` <Outline name="${name}" host="${host}" secretsName="${name}-app-secrets" />`
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const spec = {
|
|
159
|
+
...(secretProvider.backend === 'vault'
|
|
160
|
+
? { vaultAuthRef: secretProvider.authRef }
|
|
161
|
+
: { openbaoAuthRef: secretProvider.authRef }),
|
|
162
|
+
mount: secretProvider.mount,
|
|
163
|
+
type: 'kv-v2' as const,
|
|
164
|
+
path: `${secretProvider.path ?? name}/${name}/secrets`,
|
|
165
|
+
destination: { create: true, name: platformSecretsName },
|
|
166
|
+
}
|
|
167
|
+
resources_.push(
|
|
168
|
+
secretProvider.backend === 'vault'
|
|
169
|
+
? jsx('VaultStaticSecret', {
|
|
170
|
+
apiVersion: 'secrets.hashicorp.com/v1beta1',
|
|
171
|
+
kind: 'VaultStaticSecret',
|
|
172
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
173
|
+
spec,
|
|
174
|
+
})
|
|
175
|
+
: jsx('OpenBaoStaticSecret', {
|
|
176
|
+
apiVersion: 'secrets.openbao.org/v1beta1',
|
|
177
|
+
kind: 'OpenBaoStaticSecret',
|
|
178
|
+
metadata: { name: `${name}-secrets`, namespace },
|
|
179
|
+
spec,
|
|
180
|
+
})
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Operators -------------------------------------------------------------
|
|
185
|
+
if (cache && !sharedOperators.some((op) => op.name === OPERATOR_REDIS)) {
|
|
186
|
+
resources_.push(declareOperator(operators[OPERATOR_REDIS]()))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Redis — queue + rate limiting (OT-Container-Kit operator)
|
|
190
|
+
if (cache) {
|
|
191
|
+
resources_.push(
|
|
192
|
+
RedisReplicationComponent({
|
|
193
|
+
metadata: { name: `${name}-redis`, namespace },
|
|
194
|
+
spec: {
|
|
195
|
+
clusterSize: 3,
|
|
196
|
+
kubernetesConfig: { image: 'redis:7.2-alpine' },
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- Env wiring --------------------------------------------------------------
|
|
203
|
+
// Every credential is referenced with $(VAR) expansion or secretKeyRef —
|
|
204
|
+
// no plaintext in the manifest. The WebService declares secret-backed
|
|
205
|
+
// vars before plain env vars, so dependent expansion resolves.
|
|
206
|
+
const env: Record<string, string> = {
|
|
207
|
+
PORT: '3000',
|
|
208
|
+
// Deterministic parts inlined; password arrives via PGPASSWORD
|
|
209
|
+
DATABASE_URL: `postgresql://${name}:$(PGPASSWORD)@${dbHost}:5432/${name}`,
|
|
210
|
+
SECRET_KEY: '$(SECRET_KEY)',
|
|
211
|
+
UTILS_SECRET: '$(UTILS_SECRET)',
|
|
212
|
+
URL: `https://${host}`,
|
|
213
|
+
APP_URL: `https://${host}`,
|
|
214
|
+
PROXY_HEADERS_TRUSTED: 'true',
|
|
215
|
+
...(cache ? { REDIS_URL: `redis://${name}-redis:6379` } : {}),
|
|
216
|
+
...(objectStorage
|
|
217
|
+
? {
|
|
218
|
+
FILE_STORAGE: 's3',
|
|
219
|
+
AWS_REGION: objectStorage.region ?? 'us-east-1',
|
|
220
|
+
AWS_S3_UPLOAD_BUCKET_URL: `${objectStorage.endpoint}/${objectStorage.bucket}`,
|
|
221
|
+
AWS_S3_UPLOAD_BUCKET_NAME: objectStorage.bucket,
|
|
222
|
+
AWS_S3_FORCE_PATH_STYLE: 'true',
|
|
223
|
+
AWS_S3_ACL: 'private',
|
|
224
|
+
}
|
|
225
|
+
: {}),
|
|
226
|
+
...(sso
|
|
227
|
+
? {
|
|
228
|
+
OIDC_ISSUER: sso.issuer,
|
|
229
|
+
OIDC_CLIENT_ID: sso.clientId,
|
|
230
|
+
OIDC_CLIENT_SECRET: '$(OIDC_CLIENT_SECRET)',
|
|
231
|
+
OIDC_SCOPES: sso.scopes ?? 'openid email profile',
|
|
232
|
+
OIDC_AUTH_URI: '$(OIDC_ISSUER)/protocol/openid-connect/auth',
|
|
233
|
+
OIDC_TOKEN_URI: '$(OIDC_ISSUER)/protocol/openid-connect/token',
|
|
234
|
+
OIDC_USERINFO_URI: '$(OIDC_ISSUER)/protocol/openid-connect/userinfo',
|
|
235
|
+
}
|
|
236
|
+
: {}),
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Credentials delivered via secretKeyRef (runtime injection)
|
|
240
|
+
const secrets: Record<string, SecretRef | string> = {
|
|
241
|
+
SECRET_KEY: { secret: platformSecretsName, key: 'secretKey' },
|
|
242
|
+
UTILS_SECRET: { secret: platformSecretsName, key: 'utilsSecret' },
|
|
243
|
+
PGPASSWORD: { secret: dbCredentialsName, key: 'password' },
|
|
244
|
+
...(objectStorage
|
|
245
|
+
? {
|
|
246
|
+
AWS_ACCESS_KEY_ID: { secret: objectStorage.credentialsSecret, key: 'accessKey' },
|
|
247
|
+
AWS_SECRET_ACCESS_KEY: { secret: objectStorage.credentialsSecret, key: 'secretKey' },
|
|
248
|
+
}
|
|
249
|
+
: {}),
|
|
250
|
+
...(sso ? { OIDC_CLIENT_SECRET: sso.clientSecretRef } : {}),
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// --- Database + app + endpoint ------------------------------------------------
|
|
254
|
+
// Database wraps the app so credentials stay consistent with the r8s
|
|
255
|
+
// Database recipe (CNPG dedicated cluster provisions the secret).
|
|
256
|
+
resources_.push(
|
|
257
|
+
jsx(Database, {
|
|
258
|
+
name,
|
|
259
|
+
namespace,
|
|
260
|
+
storage,
|
|
261
|
+
children: (
|
|
262
|
+
<WebService
|
|
263
|
+
name={name}
|
|
264
|
+
namespace={namespace}
|
|
265
|
+
image={`outlinewiki/outline:${version}`}
|
|
266
|
+
port={3000}
|
|
267
|
+
replicas={replicas}
|
|
268
|
+
resources={resources}
|
|
269
|
+
probes={{ liveness: { tcp: true }, readiness: { tcp: true, initialDelaySeconds: 15 } }}
|
|
270
|
+
env={env}
|
|
271
|
+
secrets={secrets}
|
|
272
|
+
/>
|
|
273
|
+
),
|
|
274
|
+
})
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
resources_.push(
|
|
278
|
+
<Endpoint
|
|
279
|
+
name={`${name}-endpoint`}
|
|
280
|
+
namespace={namespace}
|
|
281
|
+
host={host}
|
|
282
|
+
serviceName={name}
|
|
283
|
+
servicePort={3000}
|
|
284
|
+
tls={tls}
|
|
285
|
+
/>
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return jsx(Fragment, { children: resources_ })
|
|
289
|
+
}
|
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
|
+
}
|