@r8s/eurooffice 0.2.0 → 0.2.1

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.
@@ -0,0 +1,137 @@
1
+ import { type DatabaseProps } from '@r8s/recipes';
2
+ export interface EuroOfficeProps {
3
+ /** Resource name (defaults to 'onlyoffice') */
4
+ name?: string;
5
+ /** Kubernetes namespace (inherited from <Platform> unless set) */
6
+ namespace?: string;
7
+ /**
8
+ * DocumentServer image tag (defaults to 'v9.3.2').
9
+ * PINNED VERSION REQUIRED — the ghcr.io/euro-office/documentserver repo
10
+ * also carries CI build tags, so 'latest' is rejected.
11
+ */
12
+ version?: string;
13
+ /** Public hostname for the document server (required) — used by Odoo/WOPI integrations */
14
+ host: string;
15
+ /**
16
+ * Number of app replicas. Must be exactly 1: the DocumentServer ships
17
+ * with embedded Redis + RabbitMQ and keeps its secure-link secret and
18
+ * WOPI keys on the RWO data volume — it does not scale horizontally.
19
+ */
20
+ replicas?: number;
21
+ /**
22
+ * RWO data volume at /var/www/euro-office/Data (subPath `data`) holding
23
+ * the auto-generated secure-link secret and WOPI keys. Defaults to
24
+ * '5Gi'. Pass `false` to manage storage yourself.
25
+ */
26
+ dataStorage?: string | {
27
+ size?: string;
28
+ storageClass?: string;
29
+ } | false;
30
+ /**
31
+ * Custom UI fonts baked into the container at boot (curlimages/curl
32
+ * init container downloads static TTFs into core-fonts/berget — variable
33
+ * TTFs render under the wrong family name, so statics are used).
34
+ * Defaults to the Berget brand set (DM Sans Regular/Medium/Bold + Ovo).
35
+ * Pass `false` to disable.
36
+ */
37
+ customFonts?: {
38
+ name: string;
39
+ url: string;
40
+ }[] | false;
41
+ /**
42
+ * JWT securing the document-server API (Odoo integration). Enabled
43
+ * always (JWT_ENABLED=true, JWT_HEADER=Authorization); the secret is
44
+ * provisioned through the Platform secrets backend with a 1h refresh
45
+ * and pod restart on rotation, unless `jwtSecretName` references a
46
+ * pre-created Secret (key: JWT_SECRET).
47
+ */
48
+ jwt?: {
49
+ /** Backend path (defaults to `<provider.path>/<name>/jwt`) */
50
+ path?: string;
51
+ /** Re-sync interval (defaults to the provider's, else '1h') */
52
+ refreshAfter?: string;
53
+ /** Explicit pod restart targets on rotation (defaults to this Deployment) */
54
+ rolloutRestartTargets?: {
55
+ kind?: string;
56
+ name: string;
57
+ apiVersion?: string;
58
+ }[];
59
+ };
60
+ /** Reference a pre-created JWT Secret (keys: JWT_SECRET) instead of backend provisioning */
61
+ jwtSecretName?: string;
62
+ /** Show the /example test UI (EXAMPLE_ENABLED). Defaults to false — enable during bring-up, then turn off */
63
+ exampleEnabled?: boolean;
64
+ /**
65
+ * CNPG cluster name (also the database and user name). Defaults to
66
+ * 'eurooffice-db' — the production cluster name, deliberately distinct
67
+ * from older naming to avoid PVC collisions.
68
+ */
69
+ dbName?: string;
70
+ /** Number of CNPG instances (defaults to 2) */
71
+ dbInstances?: number;
72
+ /** CNPG data volume size (defaults to '20Gi') */
73
+ dbStorage?: string;
74
+ /** CNPG storage class (defaults to cluster default) */
75
+ dbStorageClass?: string;
76
+ /** CNPG backup configuration passed through to the Database recipe (continuous WAL + scheduled base backups) */
77
+ backup?: DatabaseProps['backup'];
78
+ /**
79
+ * SQL statements run once on a fresh cluster (CNPG postInitApplicationSQL).
80
+ * Note: v9.3.2 cannot bootstrap an empty database by itself (entrypoint
81
+ * lacks ensure_db_schema) — on a NEW cluster apply the image's
82
+ * createdb.sql, or provide it here so the package handles it for you.
83
+ */
84
+ postInitSQL?: string[];
85
+ /** Requested app resources (defaults to facit: 1Gi/500m → 4Gi/2) */
86
+ resources?: {
87
+ requests?: {
88
+ cpu?: string;
89
+ memory?: string;
90
+ };
91
+ limits?: {
92
+ cpu?: string;
93
+ memory?: string;
94
+ };
95
+ };
96
+ /** Extra annotations merged onto the Endpoint (proxy-body-size 100m + 600s timeouts are defaults) */
97
+ endpointAnnotations?: Record<string, string>;
98
+ /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
99
+ tls?: {
100
+ secretName: string;
101
+ clusterIssuer: string;
102
+ };
103
+ }
104
+ /**
105
+ * EuroOffice — Euro-Office DocumentServer (self-hosted collaborative
106
+ * document editing, facit-aligned).
107
+ *
108
+ * @title EuroOffice
109
+ * @category Productivity & Documents
110
+ *
111
+ * Composes:
112
+ * - DocumentServer Deployment + Service (port 80, `/healthcheck` probes,
113
+ * startup probe tolerating the ~10-minute first boot: schema + fonts)
114
+ * - CNPG Postgres cluster (default 2 instances, facit-tuned parameters)
115
+ * - RWO data volume for the secure-link secret + WOPI keys
116
+ * - JWT secret provisioned through the Platform secrets backend (or a
117
+ * referenced pre-created Secret) with pod restart on rotation
118
+ * - preStop lifecycle hook (documentserver-prepare4shutdown.sh — saves
119
+ * open documents and disconnects sessions before shutdown)
120
+ * - Optional brand-font init container
121
+ * - Endpoint with 100m body-size + 600s proxy timeouts
122
+ *
123
+ * The DocumentServer is strictly single-replica (embedded Redis +
124
+ * RabbitMQ, RWO volume) — `replicas` other than 1 is rejected.
125
+ *
126
+ * @example
127
+ * import { Platform } from '@r8s/recipes'
128
+ * import { EuroOffice } from '@r8s/eurooffice'
129
+ *
130
+ * export default (
131
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
132
+ * <EuroOffice host="docs.example.com" exampleEnabled />
133
+ * </Platform>
134
+ * )
135
+ */
136
+ export declare function EuroOffice(props: EuroOfficeProps): import("@r8s/core").r8sElement;
137
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAEA,OAAO,EAOL,KAAK,aAAa,EACnB,MAAM,cAAc,CAAA;AAErB,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAA;IACZ;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,KAAK,CAAA;IACvE;;;;;;OAMG;IACH,WAAW,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,EAAE,GAAG,KAAK,CAAA;IACrD;;;;;;OAMG;IACH,GAAG,CAAC,EAAE;QACJ,8DAA8D;QAC9D,IAAI,CAAC,EAAE,MAAM,CAAA;QACb,+DAA+D;QAC/D,YAAY,CAAC,EAAE,MAAM,CAAA;QACrB,6EAA6E;QAC7E,qBAAqB,CAAC,EAAE;YAAE,IAAI,CAAC,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAC;YAAC,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;KAC/E,CAAA;IACD,4FAA4F;IAC5F,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,6GAA6G;IAC7G,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iDAAiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,uDAAuD;IACvD,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,gHAAgH;IAChH,MAAM,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAA;IAChC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;IACtB,oEAAoE;IACpE,SAAS,CAAC,EAAE;QACV,QAAQ,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;QAC5C,MAAM,CAAC,EAAE;YAAE,GAAG,CAAC,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAC3C,CAAA;IACD,qGAAqG;IACrG,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5C,sEAAsE;IACtE,GAAG,CAAC,EAAE;QACJ,UAAU,EAAE,MAAM,CAAA;QAClB,aAAa,EAAE,MAAM,CAAA;KACtB,CAAA;CACF;AAwBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,kCA0OhD"}
package/dist/index.js ADDED
@@ -0,0 +1,246 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EuroOffice = EuroOffice;
4
+ const core_1 = require("@r8s/core");
5
+ const defaults_1 = require("@r8s/core/defaults");
6
+ const recipes_1 = require("@r8s/recipes");
7
+ // Berget brand fonts — static TTFs (variable fonts render under the wrong
8
+ // family name inside the document server). Downloaded by an init container
9
+ // into an emptyDir mounted over core-fonts/berget.
10
+ const DEFAULT_FONT_URLS = [
11
+ {
12
+ name: 'DMSans-Regular.ttf',
13
+ url: 'https://fonts.gstatic.com/s/dmsans/v17/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAopxhTg.ttf',
14
+ },
15
+ {
16
+ name: 'DMSans-Medium.ttf',
17
+ url: 'https://fonts.gstatic.com/s/dmsans/v17/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwAkJxhTg.ttf',
18
+ },
19
+ {
20
+ name: 'DMSans-Bold.ttf',
21
+ url: 'https://fonts.gstatic.com/s/dmsans/v17/rP2tp2ywxg089UriI5-g4vlH9VoD8CmcqZG40F9JadbnoEwARZthTg.ttf',
22
+ },
23
+ {
24
+ name: 'Ovo-Regular.ttf',
25
+ url: 'https://fonts.gstatic.com/s/ovo/v18/yYLl0h7Wyfzjyw.ttf',
26
+ },
27
+ ];
28
+ /**
29
+ * EuroOffice — Euro-Office DocumentServer (self-hosted collaborative
30
+ * document editing, facit-aligned).
31
+ *
32
+ * @title EuroOffice
33
+ * @category Productivity & Documents
34
+ *
35
+ * Composes:
36
+ * - DocumentServer Deployment + Service (port 80, `/healthcheck` probes,
37
+ * startup probe tolerating the ~10-minute first boot: schema + fonts)
38
+ * - CNPG Postgres cluster (default 2 instances, facit-tuned parameters)
39
+ * - RWO data volume for the secure-link secret + WOPI keys
40
+ * - JWT secret provisioned through the Platform secrets backend (or a
41
+ * referenced pre-created Secret) with pod restart on rotation
42
+ * - preStop lifecycle hook (documentserver-prepare4shutdown.sh — saves
43
+ * open documents and disconnects sessions before shutdown)
44
+ * - Optional brand-font init container
45
+ * - Endpoint with 100m body-size + 600s proxy timeouts
46
+ *
47
+ * The DocumentServer is strictly single-replica (embedded Redis +
48
+ * RabbitMQ, RWO volume) — `replicas` other than 1 is rejected.
49
+ *
50
+ * @example
51
+ * import { Platform } from '@r8s/recipes'
52
+ * import { EuroOffice } from '@r8s/eurooffice'
53
+ *
54
+ * export default (
55
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
56
+ * <EuroOffice host="docs.example.com" exampleEnabled />
57
+ * </Platform>
58
+ * )
59
+ */
60
+ function EuroOffice(props) {
61
+ const { name = 'onlyoffice', namespace: namespaceProp, version = 'v9.3.2', host, replicas = 1, dataStorage = '5Gi', customFonts, jwt, jwtSecretName, exampleEnabled = false, dbName = 'eurooffice-db', dbInstances = 2, dbStorage = '20Gi', dbStorageClass, backup, postInitSQL, resources = {
62
+ requests: { memory: '1Gi', cpu: '500m' },
63
+ limits: { memory: '4Gi', cpu: '2' },
64
+ }, endpointAnnotations = {}, tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' }, } = props;
65
+ const namespace = (0, defaults_1.useNamespace)(namespaceProp);
66
+ const secretProvider = (0, core_1.useContext)(defaults_1.SecretContext);
67
+ const resources_ = [];
68
+ if (version === 'latest') {
69
+ throw new Error(`EuroOffice "${name}" requires a pinned version.\n` +
70
+ `\n` +
71
+ `The ghcr.io/euro-office/documentserver repo also carries CI build tags —\n` +
72
+ `'latest' may resolve to an untested build and break document editing.\n` +
73
+ `\n` +
74
+ `Fix: <EuroOffice version="v9.3.2" ... />`);
75
+ }
76
+ if (replicas !== 1) {
77
+ throw new Error(`EuroOffice "${name}" requires replicas=1.\n` +
78
+ `\n` +
79
+ `The DocumentServer ships embedded Redis + RabbitMQ and keeps its\n` +
80
+ `secure-link secret and WOPI keys on the RWO data volume — it does\n` +
81
+ `not scale horizontally (received replicas=${replicas}).`);
82
+ }
83
+ // --- JWT secret (core of the Odoo/WOPI integration) ------------------------
84
+ const jwtSecretResourceName = jwtSecretName ?? `${name}-jwt-secret`;
85
+ if (!jwtSecretName) {
86
+ if (!(0, recipes_1.canProvisionSecrets)(secretProvider)) {
87
+ throw (0, recipes_1.secretsRequiredError)('EuroOffice', name, 'a JWT secret — it signs every document-server API call (Odoo integration)', {
88
+ propName: 'jwtSecretName',
89
+ exampleValue: `${name}-jwt-secret`,
90
+ keys: ['JWT_SECRET'],
91
+ });
92
+ }
93
+ resources_.push((0, core_1.jsx)(recipes_1.StaticSecret, {
94
+ name: `${name}-jwt`,
95
+ namespace,
96
+ path: jwt?.path ?? `${secretProvider.path ?? name}/${name}/jwt`,
97
+ secretName: jwtSecretResourceName,
98
+ refreshAfter: jwt?.refreshAfter,
99
+ keys: ['JWT_SECRET'],
100
+ restart: jwt?.rolloutRestartTargets ?? [{ kind: 'Deployment', name }],
101
+ }));
102
+ }
103
+ // --- Data volume (secure-link secret + WOPI keys) --------------------------
104
+ const dataVolumeName = `${name}-data`;
105
+ if (dataStorage) {
106
+ const size = typeof dataStorage === 'string' ? dataStorage : (dataStorage.size ?? '5Gi');
107
+ const storageClass = typeof dataStorage === 'object' ? dataStorage.storageClass : undefined;
108
+ resources_.push((0, core_1.jsx)('PersistentVolumeClaim', {
109
+ apiVersion: 'v1',
110
+ kind: 'PersistentVolumeClaim',
111
+ metadata: { name: dataVolumeName, namespace },
112
+ spec: {
113
+ accessModes: ['ReadWriteOnce'],
114
+ ...(storageClass ? { storageClassName: storageClass } : {}),
115
+ resources: { requests: { storage: size } },
116
+ },
117
+ }));
118
+ }
119
+ // --- Fonts -----------------------------------------------------------------
120
+ const fonts = customFonts === undefined ? DEFAULT_FONT_URLS : customFonts;
121
+ const fontsEnabled = fonts !== false && fonts.length > 0;
122
+ // --- Database (CNPG) — document metadata, sessions --------------------------
123
+ const dbHost = `${dbName}-rw.${namespace}.svc.cluster.local`;
124
+ const dbCredentialsName = `${dbName}-db-credentials`;
125
+ // --- Env wiring --------------------------------------------------------------
126
+ // Every credential is delivered via secretKeyRef — no plaintext in the
127
+ // manifest. DB_* uses the DocumentServer's own variable names (it does
128
+ // not read DATABASE_URL), so no WebService/Database auto-wiring.
129
+ const env = {
130
+ DB_TYPE: 'postgres',
131
+ DB_HOST: dbHost,
132
+ DB_PORT: '5432',
133
+ DB_NAME: dbName,
134
+ DB_USER: dbName,
135
+ JWT_ENABLED: 'true',
136
+ JWT_HEADER: 'Authorization',
137
+ ...(exampleEnabled ? { EXAMPLE_ENABLED: 'true' } : {}),
138
+ };
139
+ const secrets = {
140
+ DB_PWD: { secret: dbCredentialsName, key: 'password' },
141
+ JWT_SECRET: { secret: jwtSecretResourceName, key: 'JWT_SECRET' },
142
+ };
143
+ const volumes = [
144
+ ...(dataStorage
145
+ ? [{ name: 'data', persistentVolumeClaim: { claimName: dataVolumeName } }]
146
+ : []),
147
+ ...(fontsEnabled ? [{ name: 'custom-fonts', emptyDir: {} }] : []),
148
+ ];
149
+ const volumeMounts = [
150
+ ...(dataStorage
151
+ ? [{ name: 'data', mountPath: '/var/www/euro-office/Data', subPath: 'data' }]
152
+ : []),
153
+ ...(fontsEnabled
154
+ ? [
155
+ {
156
+ name: 'custom-fonts',
157
+ mountPath: '/var/www/euro-office/documentserver/core-fonts/berget',
158
+ },
159
+ ]
160
+ : []),
161
+ ];
162
+ const fontScript = fontsEnabled
163
+ ? [
164
+ 'set -e',
165
+ 'mkdir -p /fonts',
166
+ ...fonts.map((f) => `curl -fsSL -o /fonts/${f.name} "${f.url}"`),
167
+ 'echo "Downloaded fonts:"',
168
+ 'ls -la /fonts/',
169
+ ].join('\n')
170
+ : undefined;
171
+ resources_.push((0, core_1.jsx)(recipes_1.Database, {
172
+ name: dbName,
173
+ namespace,
174
+ instances: dbInstances,
175
+ storage: dbStorage,
176
+ ...(dbStorageClass ? { storageClass: dbStorageClass } : {}),
177
+ parameters: {
178
+ shared_buffers: '256MB',
179
+ max_connections: '200',
180
+ work_mem: '8MB',
181
+ maintenance_work_mem: '128MB',
182
+ effective_cache_size: '768MB',
183
+ },
184
+ ...(backup ? { backup } : {}),
185
+ ...(postInitSQL && postInitSQL.length > 0 ? { postInitSQL } : {}),
186
+ }), (0, core_1.jsx)(recipes_1.WebService, {
187
+ name,
188
+ namespace,
189
+ image: `ghcr.io/euro-office/documentserver:${version}`,
190
+ port: 80,
191
+ replicas: 1,
192
+ strategy: 'Recreate',
193
+ resources,
194
+ env,
195
+ secrets,
196
+ volumes,
197
+ volumeMounts,
198
+ ...(fontsEnabled
199
+ ? {
200
+ initContainers: [
201
+ {
202
+ name: 'custom-fonts',
203
+ image: 'curlimages/curl:8.12.0',
204
+ command: ['sh', '-c'],
205
+ args: [fontScript],
206
+ volumeMounts: [{ name: 'custom-fonts', mountPath: '/fonts' }],
207
+ },
208
+ ],
209
+ }
210
+ : {}),
211
+ // Graceful shutdown: save open documents and disconnect sessions
212
+ // before the pod goes away
213
+ lifecycle: {
214
+ preStop: {
215
+ exec: {
216
+ command: [
217
+ 'sh',
218
+ '-c',
219
+ 'command -v documentserver-prepare4shutdown.sh >/dev/null 2>&1 && documentserver-prepare4shutdown.sh || true',
220
+ ],
221
+ },
222
+ },
223
+ },
224
+ probes: {
225
+ // First boot runs schema migration + font refresh — up to ~10 min
226
+ startup: { path: '/healthcheck', periodSeconds: 10, failureThreshold: 60 },
227
+ readiness: { path: '/healthcheck', periodSeconds: 15, failureThreshold: 3 },
228
+ liveness: { path: '/healthcheck', periodSeconds: 30, failureThreshold: 3 },
229
+ },
230
+ }), (0, core_1.jsx)(recipes_1.Endpoint, {
231
+ name: `${name}-endpoint`,
232
+ namespace,
233
+ host,
234
+ serviceName: name,
235
+ servicePort: 80,
236
+ annotations: {
237
+ 'nginx.ingress.kubernetes.io/proxy-body-size': '100m',
238
+ 'nginx.ingress.kubernetes.io/proxy-read-timeout': '600',
239
+ 'nginx.ingress.kubernetes.io/proxy-send-timeout': '600',
240
+ ...endpointAnnotations,
241
+ },
242
+ tls,
243
+ }));
244
+ return (0, core_1.jsx)(core_1.Fragment, { children: resources_ });
245
+ }
246
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r8s/eurooffice",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "EuroOffice collaborative document suite — Postgres persistence, S3 blob storage, LibreOffice conversions, SMTP delivery and websocket collaboration",
5
5
  "license": "MIT",
6
6
  "author": "Berget AI AB",
@@ -37,5 +37,8 @@
37
37
  "devDependencies": {
38
38
  "typescript": "^5.3.0",
39
39
  "vitest": "^1.0.0"
40
- }
40
+ },
41
+ "files": [
42
+ "dist"
43
+ ]
41
44
  }
@@ -1,427 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { render, jsx } from '@r8s/core'
3
- import { Namespace, 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
- // EuroOffice recipe tests:
9
- // 1. Operator declarations (deduped via OperatorContext)
10
- // 2. Rendering: defaults, all props, gateway/ingress adaptation, TCP probes
11
- // 3. Namespace inheritance from the Platform context
12
- // 4. Security: no plaintext credentials in rendered output
13
- import { EuroOffice } from '../src/index'
14
-
15
- const objectStorage = {
16
- endpoint: 'https://s3.test',
17
- bucket: 'docs-blobs',
18
- credentialsSecret: 'docs-blobs-credentials',
19
- }
20
-
21
- const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
22
-
23
- /** Render EuroOffice inside a Platform-like secrets backend (OpenBao). */
24
- function renderEuroOffice(props: Record<string, unknown>): ReturnType<typeof render> {
25
- return render(
26
- jsx(SecretContext.Provider, {
27
- value: { backend: 'openbao', mount: 'kv', path: 'test' },
28
- children: jsx(EuroOffice, { objectStorage, ...props } as never),
29
- })
30
- )
31
- }
32
-
33
- /** Render EuroOffice wrapped only in an OperatorContext (no secrets backend). */
34
- function renderEuroOfficeWithContext(
35
- operators_: any[],
36
- props: Record<string, unknown>
37
- ): r8sElement {
38
- return jsx(OperatorContext.Provider, {
39
- value: operators_,
40
- children: jsx(EuroOffice, {
41
- objectStorage,
42
- secretsName: 'existing-secrets',
43
- ...props,
44
- } as never),
45
- })
46
- }
47
-
48
- /** Render EuroOffice inside a Platform-like Namespace context (no explicit namespace prop). */
49
- function renderEuroOfficeInNamespace(
50
- namespaceValue: string,
51
- props: Record<string, unknown>
52
- ): ReturnType<typeof render> {
53
- return render(
54
- jsx(Namespace.Provider, {
55
- value: namespaceValue,
56
- children: jsx(SecretContext.Provider, {
57
- value: openbao as never,
58
- children: jsx(EuroOffice, { objectStorage, ...props } as never),
59
- }),
60
- })
61
- )
62
- }
63
-
64
- describe('operator declarations', () => {
65
- it('declares the cnpg operator via the Database recipe', () => {
66
- const result = renderEuroOffice({ host: 'docs.example.com' })
67
- expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
68
- })
69
-
70
- it('adds no app-level operators beyond cnpg (Endpoint owns routing operators)', () => {
71
- const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
72
- const names = result.operators.map((op) => op.name)
73
- expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
74
- expect(names).not.toContain('redis-operator')
75
- })
76
-
77
- it('deduplicates operators provided via context', () => {
78
- const result = render(
79
- renderEuroOfficeWithContext([operators['cnpg']()], { host: 'docs.example.com' })
80
- )
81
- const names = result.operators.map((op) => op.name)
82
- expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
83
- })
84
-
85
- it('allows version overrides through context operators', () => {
86
- const result = render(renderEuroOfficeWithContext([operators['cnpg']('1.24.0')], {}))
87
- const cnpg = result.operators.filter((op) => op.name === 'cnpg')
88
- expect(cnpg).toHaveLength(1)
89
- expect(cnpg[0].version).toBe('1.24.0')
90
- })
91
- })
92
-
93
- describe('rendering defaults', () => {
94
- it('renders app deployment, service, database and endpoint', () => {
95
- const result = renderEuroOffice({ host: 'docs.example.com' })
96
- const kinds = result.resources.map((r) => r.kind)
97
- expect(kinds).toContain('Deployment')
98
- expect(kinds).toContain('Service')
99
- expect(kinds).toContain('Ingress')
100
- expect(kinds).toContain('Cluster')
101
- })
102
-
103
- it('adds LibreOffice conversion workers when conversions is set', () => {
104
- const result = renderEuroOffice({
105
- host: 'docs.example.com',
106
- conversions: true,
107
- conversionWorkers: 2,
108
- })
109
- const deployments = result.resources.filter((r) => r.kind === 'Deployment')
110
- const soffice = deployments.find((d: any) => d.metadata.name === 'eurooffice-soffice') as any
111
- expect(soffice).toBeDefined()
112
- expect(soffice.spec.replicas).toBe(2)
113
- expect(soffice.spec.template.spec.containers[0].command).toContain(
114
- '--accept=socket,host=0.0.0.0,port=2002;urp;'
115
- )
116
- const services = result.resources.filter((r) => r.kind === 'Service')
117
- expect(services.some((s: any) => s.metadata.name === 'eurooffice-soffice')).toBe(true)
118
- })
119
-
120
- it('probes the soffice workers on the UNO TCP socket (not HTTP)', () => {
121
- const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
122
- const soffice = result.resources.find(
123
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice-soffice'
124
- ) as any
125
- const container = soffice.spec.template.spec.containers[0]
126
- expect(container.livenessProbe.tcpSocket).toEqual({ port: 2002 })
127
- expect(container.readinessProbe.tcpSocket).toEqual({ port: 2002 })
128
- expect(container.readinessProbe.initialDelaySeconds).toBe(20)
129
- expect(container.livenessProbe.httpGet).toBeUndefined()
130
- expect(container.readinessProbe.httpGet).toBeUndefined()
131
- })
132
-
133
- it('does not render conversion workers by default', () => {
134
- const result = renderEuroOffice({ host: 'docs.example.com' })
135
- const deployments = result.resources
136
- .filter((r) => r.kind === 'Deployment')
137
- .map((d: any) => d.metadata.name)
138
- expect(deployments).not.toContain('eurooffice-soffice')
139
- })
140
-
141
- it('defaults app replicas to 1 when websockets are enabled (no session affinity)', () => {
142
- const result = renderEuroOffice({ host: 'docs.example.com' })
143
- const app = result.resources.find(
144
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
145
- ) as any
146
- expect(app.spec.replicas).toBe(1)
147
- })
148
-
149
- it('defaults app replicas to 2 when websockets are disabled', () => {
150
- const result = renderEuroOffice({ host: 'docs.example.com', websockets: false })
151
- const app = result.resources.find(
152
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
153
- ) as any
154
- expect(app.spec.replicas).toBe(2)
155
- })
156
-
157
- it('renders explicit replicas with websockets enabled (caller opts into sticky sessions)', () => {
158
- const result = renderEuroOffice({
159
- host: 'docs.example.com',
160
- websockets: true,
161
- replicas: 3,
162
- })
163
- const app = result.resources.find(
164
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
165
- ) as any
166
- expect(app.spec.replicas).toBe(3)
167
- })
168
-
169
- it('enables websockets by default and allows disabling them', () => {
170
- const on = renderEuroOffice({ host: 'docs.example.com' })
171
- const appOn = on.resources.find(
172
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
173
- ) as any
174
- const envOn = appOn.spec.template.spec.containers[0].env
175
- expect(envOn.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('true')
176
-
177
- const off = renderEuroOffice({ host: 'docs.example.com', websockets: false })
178
- const appOff = off.resources.find(
179
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
180
- ) as any
181
- const envOff = appOff.spec.template.spec.containers[0].env
182
- expect(envOff.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('false')
183
- })
184
-
185
- it('renders gateway resources when platform uses gateway routing', () => {
186
- const result = render(
187
- jsx(RoutingContext.Provider, {
188
- value: { mode: 'gateway', gatewayClassName: 'eg' },
189
- children: jsx(SecretContext.Provider, {
190
- value: openbao as never,
191
- children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
192
- }),
193
- })
194
- )
195
- const kinds = result.resources.map((r) => r.kind)
196
- expect(kinds).toContain('HTTPRoute')
197
- })
198
-
199
- it('renders a valid Ingress when platform uses ingress routing', () => {
200
- const result = render(
201
- jsx(RoutingContext.Provider, {
202
- value: { mode: 'ingress' },
203
- children: jsx(SecretContext.Provider, {
204
- value: openbao as never,
205
- children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
206
- }),
207
- })
208
- )
209
- const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
210
- expect(ingress).toBeDefined()
211
- expect(ingress.spec.rules[0].host).toBe('docs.example.com')
212
- })
213
-
214
- it('passes resource validation', () => {
215
- const result = renderEuroOffice({ host: 'docs.example.com', conversions: true })
216
- for (const resource of result.resources) {
217
- expect(validateResource(resource)).toEqual([])
218
- }
219
- })
220
- })
221
-
222
- describe('namespace inheritance', () => {
223
- it('inherits namespace from the Platform context when namespace prop is not set', () => {
224
- const result = renderEuroOfficeInNamespace('docs', { host: 'docs.example.com' })
225
- for (const kind of ['Deployment', 'Service', 'Cluster', 'Ingress']) {
226
- const resource = result.resources.find((r: any) => r.kind === kind)
227
- expect(resource).toBeDefined()
228
- expect(resource.metadata.namespace).toBe('docs')
229
- }
230
- })
231
-
232
- it('explicit namespace prop wins over the Platform context', () => {
233
- const result = renderEuroOfficeInNamespace('docs', {
234
- host: 'docs.example.com',
235
- namespace: 'collab',
236
- })
237
- const app = result.resources.find(
238
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
239
- ) as any
240
- expect(app.metadata.namespace).toBe('collab')
241
- })
242
-
243
- it('falls back to default when no Platform namespace is present', () => {
244
- const result = renderEuroOffice({ host: 'docs.example.com' })
245
- const app = result.resources.find(
246
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
247
- ) as any
248
- expect(app.metadata.namespace).toBe('default')
249
- })
250
- })
251
-
252
- describe('rendering with all props', () => {
253
- it('accepts the full prop surface', () => {
254
- const result = renderEuroOffice({
255
- name: 'docs',
256
- namespace: 'docs',
257
- version: '1.2.0',
258
- host: 'docs.example.com',
259
- replicas: 3,
260
- websockets: false,
261
- smtp: { host: 'smtp.example.com', port: 465, from: 'no-reply@docs.example.com' },
262
- conversions: true,
263
- conversionWorkers: 4,
264
- objectStorage: {
265
- endpoint: 'https://s3.internal.example.com',
266
- bucket: 'docs-blobs',
267
- credentialsSecret: 'docs-blobs-credentials',
268
- region: 'eu-north-1',
269
- },
270
- resources: {
271
- requests: { memory: '1Gi', cpu: '500m' },
272
- limits: { memory: '4Gi', cpu: '2000m' },
273
- },
274
- tls: { secretName: 'docs-tls', clusterIssuer: 'letsencrypt-prod' },
275
- })
276
- expect(result.resources.length).toBeGreaterThan(0)
277
- const app = result.resources.find(
278
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'docs'
279
- ) as any
280
- expect(app.spec.template.spec.containers[0].image).toBe('ghcr.io/berget-ai/eurooffice:1.2.0')
281
- expect(app.spec.replicas).toBe(3)
282
- expect(app.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
283
- const env = app.spec.template.spec.containers[0].env
284
- expect(env.find((e: any) => e.name === 'WEBSOCKETS_ENABLED').value).toBe('false')
285
- expect(env.find((e: any) => e.name === 'SMTP_HOST').value).toBe('smtp.example.com')
286
- expect(env.find((e: any) => e.name === 'SMTP_PORT').value).toBe('465')
287
- expect(env.find((e: any) => e.name === 'SMTP_FROM').value).toBe('no-reply@docs.example.com')
288
- expect(env.find((e: any) => e.name === 'AWS_REGION').value).toBe('eu-north-1')
289
- expect(env.find((e: any) => e.name === 'SOFFICE_HOST').value).toBe('docs-soffice')
290
-
291
- const soffice = result.resources.find(
292
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'docs-soffice'
293
- ) as any
294
- expect(soffice.spec.template.spec.containers[0].image).toBe(
295
- 'ghcr.io/berget-ai/eurooffice:1.2.0'
296
- )
297
- expect(soffice.spec.replicas).toBe(4)
298
- })
299
- })
300
-
301
- describe('secrets handling', () => {
302
- it('accepts an explicit secretsName without a backend', () => {
303
- expect(() =>
304
- render(
305
- jsx(EuroOffice, {
306
- host: 'docs.example.com',
307
- objectStorage,
308
- secretsName: 'existing-secrets',
309
- } as never)
310
- )
311
- ).not.toThrow()
312
-
313
- const result = render(
314
- jsx(EuroOffice, {
315
- host: 'docs.example.com',
316
- objectStorage,
317
- secretsName: 'existing-secrets',
318
- } as never)
319
- )
320
- const kinds = result.resources.map((r) => r.kind)
321
- expect(kinds).not.toContain('OpenBaoStaticSecret')
322
- const app = result.resources.find(
323
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
324
- ) as any
325
- const env = app.spec.template.spec.containers[0].env
326
- const appSecret = env.find((e: any) => e.name === 'APP_SECRET')
327
- expect(appSecret.valueFrom.secretKeyRef.name).toBe('existing-secrets')
328
- expect(appSecret.valueFrom.secretKeyRef.key).toBe('secretKey')
329
- })
330
-
331
- it('provisions the app secrets bundle through a secrets backend', () => {
332
- const result = renderEuroOffice({ host: 'docs.example.com' })
333
- const secretCr = result.resources.find((r) => r.kind === 'OpenBaoStaticSecret') as any
334
- expect(secretCr).toBeDefined()
335
- expect(secretCr.metadata.name).toBe('eurooffice-secrets')
336
- expect(secretCr.spec.destination.name).toBe('eurooffice-secrets')
337
- expect(secretCr.spec.path).toBe('test/eurooffice/secrets')
338
- })
339
-
340
- it('provisions the app secrets bundle through Vault', () => {
341
- const result = render(
342
- jsx(SecretContext.Provider, {
343
- value: { backend: 'vault', mount: 'kv', path: 'apps' },
344
- children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
345
- })
346
- )
347
- const kinds = result.resources.map((r) => r.kind)
348
- expect(kinds).toContain('VaultStaticSecret')
349
- const secretCr = result.resources.find((r) => r.kind === 'VaultStaticSecret') as any
350
- expect(secretCr.spec.path).toBe('apps/eurooffice/secrets')
351
- })
352
-
353
- it('wires credentials via secretKeyRef (never plaintext env)', () => {
354
- const result = renderEuroOffice({
355
- host: 'docs.example.com',
356
- smtp: { host: 'smtp.example.com' },
357
- })
358
- const app = result.resources.find(
359
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
360
- ) as any
361
- const env = app.spec.template.spec.containers[0].env
362
- const smtpPassword = env.find((e: any) => e.name === 'SMTP_PASSWORD')
363
- const appSecret = env.find((e: any) => e.name === 'APP_SECRET')
364
- const accessKey = env.find((e: any) => e.name === 'AWS_ACCESS_KEY_ID')
365
- const secretAccessKey = env.find((e: any) => e.name === 'AWS_SECRET_ACCESS_KEY')
366
- expect(smtpPassword.valueFrom.secretKeyRef.name).toBe('eurooffice-secrets')
367
- expect(smtpPassword.valueFrom.secretKeyRef.key).toBe('smtpPassword')
368
- expect(appSecret.valueFrom.secretKeyRef.name).toBe('eurooffice-secrets')
369
- expect(appSecret.valueFrom.secretKeyRef.key).toBe('secretKey')
370
- expect(accessKey.valueFrom.secretKeyRef.name).toBe('docs-blobs-credentials')
371
- expect(accessKey.valueFrom.secretKeyRef.key).toBe('accessKey')
372
- expect(secretAccessKey.valueFrom.secretKeyRef.name).toBe('docs-blobs-credentials')
373
- expect(secretAccessKey.valueFrom.secretKeyRef.key).toBe('secretKey')
374
- expect(smtpPassword.value).toBeUndefined()
375
- expect(appSecret.value).toBeUndefined()
376
- expect(accessKey.value).toBeUndefined()
377
- expect(secretAccessKey.value).toBeUndefined()
378
- })
379
-
380
- it('auto-wires DATABASE_URL from the DatabaseContext (no manual connection string)', () => {
381
- const result = renderEuroOffice({ host: 'docs.example.com' })
382
- const app = result.resources.find(
383
- (r: any) => r.kind === 'Deployment' && r.metadata.name === 'eurooffice'
384
- ) as any
385
- const env = app.spec.template.spec.containers[0].env
386
- const databaseUrl = env.find((e: any) => e.name === 'DATABASE_URL')
387
- expect(databaseUrl.value).toBe(
388
- 'postgresql://$(PGUSER):$(PGPASSWORD)@$(PGHOST):$(PGPORT)/$(PGDATABASE)'
389
- )
390
- const dbPassword = env.find((e: any) => e.name === 'PGPASSWORD')
391
- expect(dbPassword.valueFrom.secretKeyRef.name).toBe('eurooffice-db-credentials')
392
- expect(dbPassword.value).toBeUndefined()
393
- })
394
-
395
- it('renders no plaintext credentials anywhere', () => {
396
- const result = renderEuroOffice({
397
- host: 'docs.example.com',
398
- smtp: { host: 'smtp.example.com', port: 587, from: 'no-reply@docs.example.com' },
399
- conversions: true,
400
- conversionWorkers: 2,
401
- })
402
- const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
403
- if (!passed) {
404
- console.error('Plaintext credential violations:', errors)
405
- }
406
- expect(passed).toBe(true)
407
- })
408
- })
409
-
410
- describe('validation errors', () => {
411
- it('throws when no secrets backend and no secrets name', () => {
412
- expect(() =>
413
- render(jsx(EuroOffice, { host: 'docs.example.com', objectStorage } as never))
414
- ).toThrow(/application secrets/)
415
- })
416
-
417
- it('throws for unknown secrets backends', () => {
418
- expect(() =>
419
- render(
420
- jsx(SecretContext.Provider, {
421
- value: { backend: 'unknown' as never },
422
- children: jsx(EuroOffice, { objectStorage, host: 'docs.example.com' } as never),
423
- })
424
- )
425
- ).toThrow(/application secrets/)
426
- })
427
- })
@@ -1,17 +0,0 @@
1
- import { Platform } from '@r8s/recipes'
2
- import { EuroOffice } from '@r8s/eurooffice'
3
-
4
- export default (
5
- <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
6
- <EuroOffice
7
- name="docs"
8
- host="docs.example.com"
9
- objectStorage={{
10
- endpoint: 'https://s3.internal.example.com',
11
- bucket: 'docs-blobs',
12
- credentialsSecret: 'docs-blobs-credentials',
13
- }}
14
- smtp={{ host: 'smtp.example.com', port: 587, from: 'no-reply@${env:MAIL_DOMAIN}' }}
15
- />
16
- </Platform>
17
- )
package/src/index.tsx DELETED
@@ -1,324 +0,0 @@
1
- import { jsx, Fragment, useContext } from '@r8s/core'
2
- import { Namespace, SecretContext } from '@r8s/core/defaults'
3
- import { Database, WebService, Endpoint } from '@r8s/recipes'
4
- import type { SecretRef } from '@r8s/recipes'
5
-
6
- export interface EuroOfficeProps {
7
- /** Resource name (defaults to 'eurooffice') */
8
- name?: string
9
- /** Kubernetes namespace (defaults to 'default') */
10
- namespace?: string
11
- /** Container image tag (defaults to 'latest' — pin a version in production) */
12
- version?: string
13
- /** Public hostname for the document suite (required) */
14
- host: string
15
- /**
16
- * Number of app replicas.
17
- *
18
- * With `websockets` enabled (the default) this defaults to **1**:
19
- * websocket sessions are pinned to the pod that accepted the
20
- * connection and the workload has no session affinity, so >1 replicas
21
- * means collaborators on different pods stop seeing each other live.
22
- * An explicitly set `replicas` is rendered as asked — pair it with a
23
- * sticky-session (source-IP) ingress strategy for live co-editing.
24
- * With `websockets: false` the default is 2 (the app is stateless —
25
- * scale freely).
26
- */
27
- replicas?: number
28
- /**
29
- * Collaborative editing over websockets (default: true). Disable only
30
- * for single-user or read-only deployments — document presence,
31
- * cursor sharing and live co-editing all rely on websockets.
32
- */
33
- websockets?: boolean
34
- /**
35
- * S3-compatible object storage for document blobs and attachments
36
- * (RustFS in the platform). Required — reference a bucket whose
37
- * credentials live in a Secret provisioned by the secrets backend
38
- * (keys: accessKey, secretKey) — never plaintext.
39
- */
40
- objectStorage: {
41
- /** S3 endpoint URL, e.g. https://s3.internal.example.com */
42
- endpoint: string
43
- /** Bucket name for document blobs */
44
- bucket: string
45
- /** Name of the Secret holding accessKey / secretKey */
46
- credentialsSecret: string
47
- /** Region string for the S3 client (defaults to 'us-east-1') */
48
- region?: string
49
- }
50
- /**
51
- * Outgoing SMTP for invitations and notifications. The SMTP password is
52
- * delivered via secretKeyRef from the `${name}-secrets` bundle
53
- * (key: smtpPassword) — never plaintext.
54
- */
55
- smtp?: {
56
- /** SMTP server hostname, e.g. smtp.example.com */
57
- host: string
58
- /** SMTP port (defaults to 587) */
59
- port?: number
60
- /** From address for outgoing mail, e.g. no-reply@example.com */
61
- from?: string
62
- }
63
- /**
64
- * LibreOffice headless document-conversion workers (same app image,
65
- * which must include soffice). Adds a `${name}-soffice` Deployment +
66
- * Service; the app reaches it at SOFFICE_HOST:SOFFICE_PORT. Workers
67
- * serve the UNO socket (TCP), not HTTP — their probes probe the socket.
68
- */
69
- conversions?: boolean
70
- /** LibreOffice conversion worker replicas (defaults to 1, only used with conversions) */
71
- conversionWorkers?: number
72
- /**
73
- * Name of an existing Secret holding `secretKey` and `smtpPassword`.
74
- * Required unless a secrets backend (openbao/vault) is configured on
75
- * the surrounding Platform — the backend then provisions them.
76
- * Plaintext values are not supported.
77
- */
78
- secretsName?: string
79
- /** Requested app resources */
80
- resources?: {
81
- requests?: { cpu?: string; memory?: string }
82
- limits?: { cpu?: string; memory?: string }
83
- }
84
- /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
85
- tls?: {
86
- secretName: string
87
- clusterIssuer: string
88
- }
89
- }
90
-
91
- /**
92
- * EuroOffice — collaborative document suite.
93
- *
94
- * @title EuroOffice
95
- * @category Productivity & Documents
96
- *
97
- * Composes:
98
- * - CNPG Postgres cluster (documents, revisions, users; DATABASE_URL is
99
- * auto-wired from the DatabaseContext — no manual connection string)
100
- * - EuroOffice Deployment + Service + Endpoint (websockets enabled by
101
- * default for live co-editing)
102
- * - Required S3/RustFS object storage for document blobs
103
- * - Optional LibreOffice headless conversion workers (same image; probed
104
- * on their UNO TCP socket)
105
- * - Optional SMTP delivery; password via the `${name}-secrets` bundle
106
- * - APP_SECRET (and SMTP_PASSWORD) provisioned through the Platform
107
- * secrets backend (openbao / vault), or referenced from an existing
108
- * Secret via `secretsName`
109
- *
110
- * The namespace is inherited from the surrounding `<Platform>` (via the
111
- * Namespace context) unless set explicitly.
112
- *
113
- * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
114
- * the app secrets bundle is provisioned for you. Without a backend you
115
- * must point `secretsName` at a pre-created Secret.
116
- *
117
- * @example
118
- * import { Platform } from '@r8s/recipes'
119
- * import { EuroOffice } from '@r8s/eurooffice'
120
- *
121
- * export default (
122
- * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
123
- * <EuroOffice
124
- * name="docs"
125
- * host="docs.example.com"
126
- * objectStorage={{
127
- * endpoint: 'https://s3.internal.example.com',
128
- * bucket: 'docs-blobs',
129
- * credentialsSecret: 'docs-blobs-credentials',
130
- * }}
131
- * smtp={{ host: 'smtp.example.com', port: 587, from: 'no-reply@${env:MAIL_DOMAIN}' }}
132
- * />
133
- * </Platform>
134
- * )
135
- */
136
- export function EuroOffice(props: EuroOfficeProps) {
137
- const {
138
- name = 'eurooffice',
139
- namespace: namespaceProp,
140
- version = 'latest',
141
- host,
142
- replicas: replicasProp,
143
- websockets = true,
144
- objectStorage,
145
- smtp,
146
- conversions = false,
147
- conversionWorkers = 1,
148
- secretsName,
149
- resources = {
150
- requests: { memory: '512Mi', cpu: '250m' },
151
- limits: { memory: '2Gi', cpu: '1000m' },
152
- },
153
- tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
154
- } = props
155
-
156
- // Inherit namespace from <Platform> context if not explicitly set
157
- const contextNamespace = useContext(Namespace)
158
- const namespace =
159
- namespaceProp ?? (contextNamespace !== 'default' ? contextNamespace : undefined) ?? 'default'
160
-
161
- // Live co-editing runs over websockets pinned to a single pod (no session
162
- // affinity) — default to 1 replica unless the caller explicitly scales.
163
- const replicas = replicasProp ?? (websockets ? 1 : 2)
164
-
165
- const secretProvider = useContext(SecretContext)
166
- const resources_: ReturnType<typeof jsx>[] = []
167
-
168
- const platformSecretsName = secretsName ?? `${name}-secrets`
169
-
170
- // --- App secrets (APP_SECRET / SMTP_PASSWORD) -----------------------------
171
- // The app secret signs sessions and collaborator tokens — never render
172
- // it as plaintext. With a secrets backend the bundle is provisioned
173
- // through the backend (keys: secretKey, smtpPassword); otherwise
174
- // reference a pre-created Secret.
175
- if (!secretsName) {
176
- if (
177
- !secretProvider ||
178
- (secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
179
- ) {
180
- throw new Error(
181
- `EuroOffice "${name}" requires application secrets (APP_SECRET, SMTP_PASSWORD).\n` +
182
- `\n` +
183
- `The app secret signs sessions and collaborator tokens — it must ` +
184
- `not be rendered as plaintext.\n` +
185
- `\n` +
186
- `Fix: configure a secrets backend on the Platform:\n` +
187
- ` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
188
- ` <EuroOffice name="${name}" host="${host}" />\n` +
189
- ` </Platform>\n` +
190
- `\n` +
191
- `Or reference a pre-created Secret (keys: secretKey, smtpPassword):\n` +
192
- ` <EuroOffice name="${name}" host="${host}" secretsName="${name}-secrets" />`
193
- )
194
- }
195
-
196
- const spec = {
197
- ...(secretProvider.backend === 'vault'
198
- ? { vaultAuthRef: secretProvider.authRef }
199
- : { openbaoAuthRef: secretProvider.authRef }),
200
- mount: secretProvider.mount,
201
- type: 'kv-v2' as const,
202
- path: `${secretProvider.path ?? name}/${name}/secrets`,
203
- destination: { create: true, name: platformSecretsName },
204
- }
205
- resources_.push(
206
- secretProvider.backend === 'vault'
207
- ? jsx('VaultStaticSecret', {
208
- apiVersion: 'secrets.hashicorp.com/v1beta1',
209
- kind: 'VaultStaticSecret',
210
- metadata: { name: `${name}-secrets`, namespace },
211
- spec,
212
- })
213
- : jsx('OpenBaoStaticSecret', {
214
- apiVersion: 'secrets.openbao.org/v1beta1',
215
- kind: 'OpenBaoStaticSecret',
216
- metadata: { name: `${name}-secrets`, namespace },
217
- spec,
218
- })
219
- )
220
- }
221
-
222
- // --- Env wiring -------------------------------------------------------------
223
- // Every credential is delivered via secretKeyRef — no plaintext in the
224
- // manifest. DATABASE_URL is auto-wired by WebService from the
225
- // DatabaseContext (PG* vars + $(VAR) template), so it is not declared here.
226
- const env: Record<string, string> = {
227
- PORT: '3000',
228
- APP_URL: `https://${host}`,
229
- WEBSOCKETS_ENABLED: websockets ? 'true' : 'false',
230
- AWS_REGION: objectStorage.region ?? 'us-east-1',
231
- AWS_S3_UPLOAD_BUCKET_URL: `${objectStorage.endpoint}/${objectStorage.bucket}`,
232
- AWS_S3_UPLOAD_BUCKET_NAME: objectStorage.bucket,
233
- AWS_S3_ENDPOINT: objectStorage.endpoint,
234
- AWS_S3_FORCE_PATH_STYLE: 'true',
235
- ...(smtp
236
- ? {
237
- SMTP_HOST: smtp.host,
238
- SMTP_PORT: String(smtp.port ?? 587),
239
- ...(smtp.from && { SMTP_FROM: smtp.from }),
240
- }
241
- : {}),
242
- ...(conversions
243
- ? {
244
- SOFFICE_HOST: `${name}-soffice`,
245
- SOFFICE_PORT: '2002',
246
- }
247
- : {}),
248
- }
249
-
250
- // Credentials delivered via secretKeyRef (runtime injection)
251
- const secrets: Record<string, SecretRef | string> = {
252
- APP_SECRET: { secret: platformSecretsName, key: 'secretKey' },
253
- ...(smtp
254
- ? { SMTP_PASSWORD: { secret: platformSecretsName, key: 'smtpPassword' as const } }
255
- : {}),
256
- AWS_ACCESS_KEY_ID: { secret: objectStorage.credentialsSecret, key: 'accessKey' },
257
- AWS_SECRET_ACCESS_KEY: { secret: objectStorage.credentialsSecret, key: 'secretKey' },
258
- }
259
-
260
- // --- Database + app + conversions + endpoint --------------------------------
261
- // Database wraps the app so credentials stay consistent with the r8s
262
- // Database recipe (CNPG dedicated cluster provisions the secret) and
263
- // WebService auto-wires DATABASE_URL from the DatabaseContext.
264
- resources_.push(
265
- jsx(Database, {
266
- name,
267
- namespace,
268
- storage: '10Gi',
269
- children: (
270
- <WebService
271
- name={name}
272
- namespace={namespace}
273
- image={`ghcr.io/berget-ai/eurooffice:${version}`}
274
- port={3000}
275
- replicas={replicas}
276
- resources={resources}
277
- env={env}
278
- secrets={secrets}
279
- />
280
- ),
281
- })
282
- )
283
-
284
- // LibreOffice headless conversion workers — same app image (must include
285
- // soffice), listening for UNO socket connections. The workers know HTTP
286
- // health endpoints no better than we do, so probe the UNO TCP socket
287
- // directly (httpGet /health would crash-loop them).
288
- if (conversions) {
289
- resources_.push(
290
- <WebService
291
- name={`${name}-soffice`}
292
- namespace={namespace}
293
- image={`ghcr.io/berget-ai/eurooffice:${version}`}
294
- port={2002}
295
- replicas={conversionWorkers}
296
- command={[
297
- 'soffice',
298
- '--headless',
299
- '--norestore',
300
- '--accept=socket,host=0.0.0.0,port=2002;urp;',
301
- ]}
302
- env={{ SOFFICE_MODE: 'worker' }}
303
- probes={{
304
- liveness: { tcp: true, port: 2002 },
305
- readiness: { tcp: true, port: 2002, initialDelaySeconds: 20 },
306
- }}
307
- />
308
- )
309
- }
310
-
311
- // --- Endpoint — collaborative editing shares the app host -------------------
312
- resources_.push(
313
- <Endpoint
314
- name={`${name}-endpoint`}
315
- namespace={namespace}
316
- host={host}
317
- serviceName={name}
318
- servicePort={3000}
319
- tls={tls}
320
- />
321
- )
322
-
323
- return jsx(Fragment, { children: resources_ })
324
- }
package/tsconfig.json DELETED
@@ -1,15 +0,0 @@
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
- }