@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.
- package/dist/index.d.ts +137 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +246 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -2
- package/__tests__/eurooffice.test.ts +0 -427
- package/examples/basic.tsx +0 -17
- package/src/index.tsx +0 -324
- package/tsconfig.json +0 -15
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
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
|
-
})
|
package/examples/basic.tsx
DELETED
|
@@ -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
|
-
}
|