@r8s/odoo 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,99 @@
1
+ export interface OdooProps {
2
+ /** Resource name (defaults to 'odoo') */
3
+ name?: string;
4
+ /** Kubernetes namespace (defaults to 'default') */
5
+ namespace?: string;
6
+ /** Container image tag (defaults to '18' — pin a version in production) */
7
+ version?: string;
8
+ /** Public hostname for the ERP web UI (required) */
9
+ host: string;
10
+ /**
11
+ * Number of replicas. Must stay at 1 — the filestore PVC is
12
+ * ReadWriteOnce and cannot attach to multiple pods (defaults to 1).
13
+ */
14
+ replicas?: number;
15
+ /**
16
+ * Size of the filestore PersistentVolumeClaim (defaults to '20Gi').
17
+ * Odoo stores attachments and binary fields here.
18
+ */
19
+ filestore?: string;
20
+ /**
21
+ * Odoo process-level worker processes, rendered into odoo.conf
22
+ * (defaults to 2). Rule of thumb: (CPU threads * 2) + 1, accounting
23
+ * for cron workers.
24
+ */
25
+ workers?: number;
26
+ /**
27
+ * Name of an existing Secret containing key `masterPassword`. Odoo
28
+ * requires this to manage the super-admin (`/web/database/manager`).
29
+ *
30
+ * Required unless a secrets backend (openbao/vault) is configured on
31
+ * the surrounding Platform — the backend then provisions the password
32
+ * automatically. Plaintext passwords are not supported.
33
+ */
34
+ masterPasswordSecretName?: string;
35
+ /** Requested resources */
36
+ resources?: {
37
+ requests?: {
38
+ cpu?: string;
39
+ memory?: string;
40
+ };
41
+ limits?: {
42
+ cpu?: string;
43
+ memory?: string;
44
+ };
45
+ };
46
+ /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
47
+ tls?: {
48
+ secretName: string;
49
+ clusterIssuer: string;
50
+ };
51
+ }
52
+ /**
53
+ * Odoo — open-source ERP / business applications suite.
54
+ *
55
+ * @title Odoo
56
+ * @category Business Applications
57
+ *
58
+ * Composes:
59
+ * - CNPG Postgres cluster (Odoo data model) via the Database recipe
60
+ * - Odoo Deployment + Service + Endpoint (port 8069 — HTTP mode serves
61
+ * web and websockets on the same port in this image)
62
+ * - odoo.conf ConfigMap mounted at /etc/odoo/odoo.conf (proxy mode,
63
+ * workers, memory-derived worker limits, request recycling)
64
+ * - Filestore PVC for attachments and binary fields
65
+ * - Master password provisioned through the Platform secrets backend
66
+ * (openbao / vault), or referenced from an existing Secret
67
+ *
68
+ * The main app is a raw Deployment (not WebService) because Odoo needs
69
+ * the filestore PVC volume-mounted at /var/lib/odoo — the WebService
70
+ * recipe cannot express volume mounts.
71
+ *
72
+ * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
73
+ * the MASTER_PASSWORD is provisioned for you. Without a backend you
74
+ * must point `masterPasswordSecretName` at a pre-created Secret.
75
+ *
76
+ * @example
77
+ * import { Platform } from '@r8s/recipes'
78
+ * import { Odoo } from '@r8s/odoo'
79
+ *
80
+ * export default (
81
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
82
+ * <Odoo name="erp" host="erp.example.com" workers={4} />
83
+ * </Platform>
84
+ * )
85
+ *
86
+ * @example
87
+ * import { Odoo } from '@r8s/odoo'
88
+ *
89
+ * export default (
90
+ * <Odoo
91
+ * name="erp"
92
+ * host="erp.example.com"
93
+ * filestore="100Gi"
94
+ * masterPasswordSecretName="odoo-master-password"
95
+ * />
96
+ * )
97
+ */
98
+ export declare function Odoo(props: OdooProps): import("@r8s/core").r8sElement;
99
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAYA,MAAM,WAAW,SAAS;IACxB,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,mDAAmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,oDAAoD;IACpD,IAAI,EAAE,MAAM,CAAA;IACZ;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;;;;;OAOG;IACH,wBAAwB,CAAC,EAAE,MAAM,CAAA;IACjC,0BAA0B;IAC1B,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,sEAAsE;IACtE,GAAG,CAAC,EAAE;QACJ,UAAU,EAAE,MAAM,CAAA;QAClB,aAAa,EAAE,MAAM,CAAA;KACtB,CAAA;CACF;AAiDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6CG;AACH,wBAAgB,IAAI,CAAC,KAAK,EAAE,SAAS,kCA+OpC"}
package/dist/index.js ADDED
@@ -0,0 +1,286 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Odoo = Odoo;
4
+ const jsx_runtime_1 = require("@r8s/core/jsx-runtime");
5
+ const core_1 = require("@r8s/core");
6
+ const defaults_1 = require("@r8s/core/defaults");
7
+ const recipes_1 = require("@r8s/recipes");
8
+ const APP_PORT = 8069;
9
+ const HEALTH_PATH = '/web/health';
10
+ const MEMORY_UNIT_BYTES = {
11
+ '': 1,
12
+ B: 1,
13
+ K: 1_000,
14
+ KB: 1_000,
15
+ M: 1_000_000,
16
+ MB: 1_000_000,
17
+ G: 1_000_000_000,
18
+ GB: 1_000_000_000,
19
+ T: 1_000_000_000_000,
20
+ TB: 1_000_000_000_000,
21
+ P: 1_000_000_000_000_000,
22
+ PB: 1_000_000_000_000_000,
23
+ E: 1_000_000_000_000_000_000,
24
+ EB: 1_000_000_000_000_000_000,
25
+ KI: 1 << 10,
26
+ MI: 1 << 20,
27
+ GI: 1 << 30,
28
+ TI: 2 ** 40,
29
+ PI: 2 ** 50,
30
+ EI: 2 ** 60,
31
+ };
32
+ /** Parse a Kubernetes memory quantity ("2Gi", "512Mi", "1G") into bytes. */
33
+ function memoryToBytes(memory) {
34
+ const match = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)?$/.exec(memory.trim());
35
+ if (!match) {
36
+ throw new Error(`Odoo: cannot parse memory quantity "${memory}" — use quantities like "2Gi" or "512Mi"`);
37
+ }
38
+ const amount = Number(match[1]);
39
+ const unit = (match[2] ?? '').toUpperCase();
40
+ const multiplier = MEMORY_UNIT_BYTES[unit];
41
+ if (multiplier === undefined) {
42
+ throw new Error(`Odoo: unsupported memory unit in "${memory}" — use B, K/Ki, M/Mi, G/Gi, T/Ti, P/Pi or E/Ei`);
43
+ }
44
+ return Math.round(amount * multiplier);
45
+ }
46
+ /**
47
+ * Odoo — open-source ERP / business applications suite.
48
+ *
49
+ * @title Odoo
50
+ * @category Business Applications
51
+ *
52
+ * Composes:
53
+ * - CNPG Postgres cluster (Odoo data model) via the Database recipe
54
+ * - Odoo Deployment + Service + Endpoint (port 8069 — HTTP mode serves
55
+ * web and websockets on the same port in this image)
56
+ * - odoo.conf ConfigMap mounted at /etc/odoo/odoo.conf (proxy mode,
57
+ * workers, memory-derived worker limits, request recycling)
58
+ * - Filestore PVC for attachments and binary fields
59
+ * - Master password provisioned through the Platform secrets backend
60
+ * (openbao / vault), or referenced from an existing Secret
61
+ *
62
+ * The main app is a raw Deployment (not WebService) because Odoo needs
63
+ * the filestore PVC volume-mounted at /var/lib/odoo — the WebService
64
+ * recipe cannot express volume mounts.
65
+ *
66
+ * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
67
+ * the MASTER_PASSWORD is provisioned for you. Without a backend you
68
+ * must point `masterPasswordSecretName` at a pre-created Secret.
69
+ *
70
+ * @example
71
+ * import { Platform } from '@r8s/recipes'
72
+ * import { Odoo } from '@r8s/odoo'
73
+ *
74
+ * export default (
75
+ * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
76
+ * <Odoo name="erp" host="erp.example.com" workers={4} />
77
+ * </Platform>
78
+ * )
79
+ *
80
+ * @example
81
+ * import { Odoo } from '@r8s/odoo'
82
+ *
83
+ * export default (
84
+ * <Odoo
85
+ * name="erp"
86
+ * host="erp.example.com"
87
+ * filestore="100Gi"
88
+ * masterPasswordSecretName="odoo-master-password"
89
+ * />
90
+ * )
91
+ */
92
+ function Odoo(props) {
93
+ const { name = 'odoo', namespace = 'default', version = '18', host, replicas = 1, filestore = '20Gi', workers = 2, masterPasswordSecretName, resources = {
94
+ requests: { memory: '512Mi', cpu: '250m' },
95
+ limits: { memory: '2Gi', cpu: '1000m' },
96
+ }, tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' }, } = props;
97
+ const secretProvider = (0, core_1.useContext)(defaults_1.SecretContext);
98
+ const resources_ = [];
99
+ const dbHost = `${name}-rw`;
100
+ const dbCredentialsName = `${name}-db-credentials`;
101
+ const masterSecretName = masterPasswordSecretName ?? `${name}-master-password`;
102
+ const configMapName = `${name}-config`;
103
+ const filestoreClaim = `${name}-filestore`;
104
+ if (replicas > 1) {
105
+ throw new Error(`Odoo "${name}" cannot run with replicas: ${replicas}.\n` +
106
+ `\n` +
107
+ `The filestore PersistentVolumeClaim "${filestoreClaim}" is ReadWriteOnce ` +
108
+ `and can only attach to a single pod at a time — every pod beyond the first ` +
109
+ `stays Pending with a Multi-Attach error.\n` +
110
+ `\n` +
111
+ `Fix: keep replicas at 1 (the default), or provide read-write-many shared ` +
112
+ `storage for the filestore and override the claim's accessMode before scaling.`);
113
+ }
114
+ // --- Secret provisioning ---------------------------------------------------
115
+ // The Odoo master password unlocks /web/database/manager — never render
116
+ // it as plaintext. With a secrets backend it is provisioned through the
117
+ // backend (key `masterPassword`); otherwise reference a pre-created Secret.
118
+ if (!masterPasswordSecretName) {
119
+ if (!secretProvider ||
120
+ (secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')) {
121
+ throw new Error(`Odoo "${name}" requires a master password.\n` +
122
+ `\n` +
123
+ `Odoo uses MASTER_PASSWORD to manage the super-admin — ` +
124
+ `it must not be rendered as plaintext.\n` +
125
+ `\n` +
126
+ `Fix: configure a secrets backend on the Platform:\n` +
127
+ ` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
128
+ ` <Odoo name="${name}" host="${host}" />\n` +
129
+ ` </Platform>\n` +
130
+ `\n` +
131
+ `Or reference a pre-created Secret (key: masterPassword):\n` +
132
+ ` <Odoo name="${name}" host="${host}" masterPasswordSecretName="${name}-master-password" />`);
133
+ }
134
+ const spec = {
135
+ ...(secretProvider.backend === 'vault'
136
+ ? { vaultAuthRef: secretProvider.authRef }
137
+ : { openbaoAuthRef: secretProvider.authRef }),
138
+ mount: secretProvider.mount,
139
+ type: 'kv-v2',
140
+ path: `${secretProvider.path ?? name}/${name}/master`,
141
+ destination: { create: true, name: masterSecretName },
142
+ };
143
+ resources_.push(secretProvider.backend === 'vault'
144
+ ? (0, core_1.jsx)('VaultStaticSecret', {
145
+ apiVersion: 'secrets.hashicorp.com/v1beta1',
146
+ kind: 'VaultStaticSecret',
147
+ metadata: { name: `${name}-master-password`, namespace },
148
+ spec,
149
+ })
150
+ : (0, core_1.jsx)('OpenBaoStaticSecret', {
151
+ apiVersion: 'secrets.openbao.org/v1beta1',
152
+ kind: 'OpenBaoStaticSecret',
153
+ metadata: { name: `${name}-master-password`, namespace },
154
+ spec,
155
+ }));
156
+ }
157
+ // --- Operators --------------------------------------------------------------
158
+ // None declared directly: cnpg comes from the <Database> recipe (which
159
+ // checks OperatorContext and dedupes), routing operators come from
160
+ // <Endpoint>.
161
+ // --- Env wiring ---------------------------------------------------------------
162
+ // Upstream odoo:18 entrypoint env names (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD).
163
+ // Credentials are declared FIRST via secretKeyRef so plain env values could
164
+ // reference them with Kubernetes $(VAR) dependent expansion. No plaintext.
165
+ const env = [
166
+ {
167
+ name: 'MASTER_PASSWORD',
168
+ valueFrom: { secretKeyRef: { name: masterSecretName, key: 'masterPassword' } },
169
+ },
170
+ {
171
+ name: 'DB_PASSWORD',
172
+ valueFrom: { secretKeyRef: { name: dbCredentialsName, key: 'password' } },
173
+ },
174
+ { name: 'DB_HOST', value: dbHost },
175
+ { name: 'DB_PORT', value: '5432' },
176
+ { name: 'DB_USER', value: name },
177
+ { name: 'ODOO_DB', value: name },
178
+ ];
179
+ // --- odoo.conf -----------------------------------------------------------------
180
+ // worker memory limits are derived from the container memory limit so the
181
+ // aggregator cannot slowly OOM-kill the pod: soft limit (worker recycled)
182
+ // at 80% of hard (worker killed outright).
183
+ const limitMemoryHard = memoryToBytes(resources.limits?.memory ?? '2Gi');
184
+ const limitMemorySoft = Math.round(limitMemoryHard * 0.8);
185
+ const odooConf = [
186
+ '[options]',
187
+ 'proxy_mode = True',
188
+ `workers = ${workers}`,
189
+ `limit_memory_hard = ${limitMemoryHard}`,
190
+ `limit_memory_soft = ${limitMemorySoft}`,
191
+ 'max_requests = 80',
192
+ '',
193
+ ].join('\n');
194
+ const configMap = {
195
+ apiVersion: 'v1',
196
+ kind: 'ConfigMap',
197
+ metadata: { name: configMapName, namespace, labels: { app: name } },
198
+ data: { 'odoo.conf': odooConf },
199
+ };
200
+ resources_.push((0, core_1.jsx)('ConfigMap', configMap));
201
+ // --- Filestore PVC ------------------------------------------------------------
202
+ // Odoo stores attachments and binary fields in the filestore
203
+ // (/var/lib/odoo). This is the whole reason the app is a raw Deployment:
204
+ // WebService cannot mount volumes. ReadWriteOnce keeps a single writer,
205
+ // which is also why replicas must stay at 1 (enforced above).
206
+ const filestorePvc = {
207
+ apiVersion: 'v1',
208
+ kind: 'PersistentVolumeClaim',
209
+ metadata: { name: filestoreClaim, namespace, labels: { app: name } },
210
+ spec: {
211
+ accessModes: ['ReadWriteOnce'],
212
+ resources: { requests: { storage: filestore } },
213
+ },
214
+ };
215
+ resources_.push((0, core_1.jsx)('PersistentVolumeClaim', filestorePvc));
216
+ // --- Database + Odoo Deployment + Service --------------------------------------
217
+ // <Database> is the parent wrapper so the CNPG operator and the Cluster
218
+ // piggyback on the r8s Database recipe; the raw Deployment references the
219
+ // connection info by convention (host `${name}-rw`, credentials secret
220
+ // `${name}-db-credentials` key `password`).
221
+ const configMount = {
222
+ name: 'config',
223
+ mountPath: '/etc/odoo/odoo.conf',
224
+ subPath: 'odoo.conf',
225
+ };
226
+ const deployment = {
227
+ apiVersion: 'apps/v1',
228
+ kind: 'Deployment',
229
+ metadata: { name, namespace, labels: { app: name } },
230
+ spec: {
231
+ replicas,
232
+ selector: { matchLabels: { app: name } },
233
+ template: {
234
+ metadata: { labels: { app: name } },
235
+ spec: {
236
+ containers: [
237
+ {
238
+ name: 'odoo',
239
+ image: `odoo:${version}`,
240
+ ports: [{ name: 'http', containerPort: APP_PORT }],
241
+ env,
242
+ resources,
243
+ livenessProbe: {
244
+ httpGet: { path: HEALTH_PATH, port: APP_PORT },
245
+ initialDelaySeconds: 60,
246
+ periodSeconds: 30,
247
+ },
248
+ readinessProbe: {
249
+ httpGet: { path: HEALTH_PATH, port: APP_PORT },
250
+ initialDelaySeconds: 30,
251
+ periodSeconds: 10,
252
+ },
253
+ volumeMounts: [{ name: 'filestore', mountPath: '/var/lib/odoo' }, configMount],
254
+ },
255
+ ],
256
+ volumes: [
257
+ { name: 'filestore', persistentVolumeClaim: { claimName: filestoreClaim } },
258
+ { name: 'config', configMap: { name: configMapName } },
259
+ ],
260
+ },
261
+ },
262
+ },
263
+ };
264
+ const service = {
265
+ apiVersion: 'v1',
266
+ kind: 'Service',
267
+ metadata: { name, namespace, labels: { app: name } },
268
+ spec: {
269
+ type: 'ClusterIP',
270
+ selector: { app: name },
271
+ ports: [{ name: 'http', port: APP_PORT, targetPort: APP_PORT }],
272
+ },
273
+ };
274
+ resources_.push((0, core_1.jsx)(recipes_1.Database, {
275
+ name,
276
+ namespace,
277
+ storage: '10Gi',
278
+ children: (0, core_1.jsx)(core_1.Fragment, {
279
+ children: [(0, core_1.jsx)('Deployment', deployment), (0, core_1.jsx)('Service', service)],
280
+ }),
281
+ }));
282
+ // --- Endpoint — public web UI ---------------------------------------------------
283
+ resources_.push((0, jsx_runtime_1.jsx)(recipes_1.Endpoint, { name: `${name}-endpoint`, namespace: namespace, host: host, serviceName: name, servicePort: APP_PORT, tls: tls }));
284
+ return (0, core_1.jsx)(core_1.Fragment, { children: resources_ });
285
+ }
286
+ //# 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/odoo",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Odoo ERP — Postgres persistence, filestore PVC, worker tuning, master password via secrets backend",
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,324 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { render, jsx } from '@r8s/core'
3
- import { OperatorContext, SecretContext, RoutingContext } from '@r8s/core/defaults'
4
- import { runGuardrails, noPlaintextSecrets, validateResource } from '@r8s/core'
5
- import { operators } from '@r8s/crds'
6
- import type { r8sElement } from '@r8s/core'
7
-
8
- // Odoo recipe tests:
9
- // 1. Operator declarations (deduped via OperatorContext)
10
- // 2. Rendering: defaults, all props, gateway/ingress adaptation, odoo.conf
11
- // 3. Security: no plaintext credentials in rendered output
12
- // 4. Validation: replica constraint and missing secrets throw
13
- import { Odoo } from '../src/index'
14
-
15
- /** Render Odoo inside a Platform-like secrets backend (OpenBao). */
16
- function renderOdoo(props: Record<string, unknown>): ReturnType<typeof render> {
17
- return render(
18
- jsx(SecretContext.Provider, {
19
- value: { backend: 'openbao', mount: 'kv', path: 'test' },
20
- children: jsx(Odoo, props as never),
21
- })
22
- )
23
- }
24
-
25
- /** Render Odoo wrapped only in an OperatorContext (no secrets backend). */
26
- function renderOdooWithContext(operators_: any[], props: Record<string, unknown>): r8sElement {
27
- return jsx(OperatorContext.Provider, {
28
- value: operators_,
29
- children: jsx(Odoo, props as never),
30
- })
31
- }
32
-
33
- function findDeployment(result: ReturnType<typeof render>, name = 'odoo'): any {
34
- return result.resources.find(
35
- (r: any) => r.kind === 'Deployment' && r.metadata.name === name
36
- ) as any
37
- }
38
-
39
- const openbao = { backend: 'openbao', mount: 'kv', path: 'test' }
40
-
41
- describe('operator declarations', () => {
42
- it('declares the cnpg operator via the Database recipe', () => {
43
- const result = renderOdoo({ host: 'erp.example.com' })
44
- expect(result.operators.some((op) => op.name === 'cnpg')).toBe(true)
45
- })
46
-
47
- it('deduplicates operators provided via context', () => {
48
- const result = render(
49
- renderOdooWithContext([operators['cnpg']()], {
50
- host: 'erp.example.com',
51
- masterPasswordSecretName: 'existing-master',
52
- })
53
- )
54
- const names = result.operators.map((op) => op.name)
55
- expect(names.filter((n) => n === 'cnpg')).toHaveLength(1)
56
- })
57
-
58
- it('allows version overrides through context operators', () => {
59
- const result = render(
60
- renderOdooWithContext([operators['cnpg']('1.25.0')], {
61
- host: 'erp.example.com',
62
- masterPasswordSecretName: 'existing-master',
63
- })
64
- )
65
- const cnpg = result.operators.filter((op) => op.name === 'cnpg')
66
- expect(cnpg).toHaveLength(1)
67
- expect(cnpg[0].version).toBe('1.25.0')
68
- })
69
- })
70
-
71
- describe('rendering defaults', () => {
72
- it('renders deployment, odoo.conf configmap, filestore PVC, service, database and endpoint', () => {
73
- const result = renderOdoo({ host: 'erp.example.com' })
74
- const kinds = result.resources.map((r) => r.kind)
75
- expect(kinds).toContain('Deployment')
76
- expect(kinds).toContain('ConfigMap')
77
- expect(kinds).toContain('PersistentVolumeClaim')
78
- expect(kinds).toContain('Service')
79
- expect(kinds).toContain('Ingress')
80
- expect(kinds).toContain('Cluster')
81
- })
82
-
83
- it('renders a ReadWriteOnce filestore PVC with the default 20Gi size', () => {
84
- const result = renderOdoo({ host: 'erp.example.com' })
85
- const pvc = result.resources.find((r: any) => r.kind === 'PersistentVolumeClaim') as any
86
- expect(pvc.metadata.name).toBe('odoo-filestore')
87
- expect(pvc.spec.accessModes).toEqual(['ReadWriteOnce'])
88
- expect(pvc.spec.resources.requests.storage).toBe('20Gi')
89
- })
90
-
91
- it('mounts the filestore at /var/lib/odoo and probes /web/health on 8069', () => {
92
- const result = renderOdoo({ host: 'erp.example.com' })
93
- const deployment = findDeployment(result)
94
- const container = deployment.spec.template.spec.containers[0]
95
- expect(container.livenessProbe.httpGet).toEqual({ path: '/web/health', port: 8069 })
96
- expect(container.livenessProbe.initialDelaySeconds).toBe(60)
97
- expect(container.readinessProbe.httpGet).toEqual({ path: '/web/health', port: 8069 })
98
- expect(container.readinessProbe.initialDelaySeconds).toBe(30)
99
- })
100
-
101
- it('exposes only the http port 8069 (no gevent 8072 declaration)', () => {
102
- const result = renderOdoo({ host: 'erp.example.com' })
103
- const deployment = findDeployment(result)
104
- expect(deployment.spec.template.spec.containers[0].ports).toEqual([
105
- { name: 'http', containerPort: 8069 },
106
- ])
107
- const service = result.resources.find(
108
- (r: any) => r.kind === 'Service' && r.metadata.name === 'odoo'
109
- ) as any
110
- expect(service.spec.ports).toEqual([{ name: 'http', port: 8069, targetPort: 8069 }])
111
- })
112
-
113
- it('wires upstream odoo entrypoint env vars by default', () => {
114
- const result = renderOdoo({ host: 'erp.example.com' })
115
- const env = findDeployment(result).spec.template.spec.containers[0].env
116
- const byName = (v: string) => env.find((e: any) => e.name === v)
117
- expect(byName('DB_HOST').value).toBe('odoo-rw')
118
- expect(byName('DB_PORT').value).toBe('5432')
119
- expect(byName('DB_USER').value).toBe('odoo')
120
- expect(byName('ODOO_DB').value).toBe('odoo')
121
- })
122
-
123
- it('does not emit the legacy ODOO_* env names or ODOO_WORKERS', () => {
124
- const result = renderOdoo({ host: 'erp.example.com' })
125
- const env = findDeployment(result).spec.template.spec.containers[0].env
126
- const names = env.map((e: any) => e.name)
127
- expect(names).not.toContain('ODOO_DB_HOST')
128
- expect(names).not.toContain('ODOO_DB_PORT')
129
- expect(names).not.toContain('ODOO_DB_USER')
130
- expect(names).not.toContain('ODOO_DB_PASSWORD')
131
- expect(names).not.toContain('ODOO_MASTER_PASSWORD')
132
- expect(names).not.toContain('ODOO_WORKERS')
133
- })
134
-
135
- it('renders a ConfigMap with the expected odoo.conf markers', () => {
136
- const result = renderOdoo({ host: 'erp.example.com' })
137
- const configMap = result.resources.find(
138
- (r: any) => r.kind === 'ConfigMap' && r.metadata.name === 'odoo-config'
139
- ) as any
140
- expect(configMap).toBeDefined()
141
- const conf = configMap.data['odoo.conf']
142
- expect(conf).toContain('[options]')
143
- expect(conf).toContain('proxy_mode = True')
144
- expect(conf).toContain('workers = 2')
145
- expect(conf).toContain(`limit_memory_hard = ${2 * 1024 ** 3}`)
146
- expect(conf).toContain(`limit_memory_soft = ${Math.round(2 * 1024 ** 3 * 0.8)}`)
147
- expect(conf).toContain('max_requests = 80')
148
- })
149
-
150
- it('mounts the odoo.conf ConfigMap at /etc/odoo/odoo.conf', () => {
151
- const result = renderOdoo({ host: 'erp.example.com' })
152
- const deployment = findDeployment(result)
153
- const container = deployment.spec.template.spec.containers[0]
154
- expect(container.volumeMounts).toEqual([
155
- { name: 'filestore', mountPath: '/var/lib/odoo' },
156
- { name: 'config', mountPath: '/etc/odoo/odoo.conf', subPath: 'odoo.conf' },
157
- ])
158
- expect(deployment.spec.template.spec.volumes).toEqual([
159
- { name: 'filestore', persistentVolumeClaim: { claimName: 'odoo-filestore' } },
160
- { name: 'config', configMap: { name: 'odoo-config' } },
161
- ])
162
- })
163
-
164
- it('renders gateway resources when platform uses gateway routing', () => {
165
- const result = render(
166
- jsx(RoutingContext.Provider, {
167
- value: { mode: 'gateway', gatewayClassName: 'eg' },
168
- children: jsx(SecretContext.Provider, {
169
- value: openbao as never,
170
- children: jsx(Odoo, { host: 'erp.example.com' }),
171
- }),
172
- })
173
- )
174
- const kinds = result.resources.map((r) => r.kind)
175
- expect(kinds).toContain('HTTPRoute')
176
- })
177
-
178
- it('renders a valid Ingress when platform uses ingress routing', () => {
179
- const result = render(
180
- jsx(RoutingContext.Provider, {
181
- value: { mode: 'ingress' },
182
- children: jsx(SecretContext.Provider, {
183
- value: openbao as never,
184
- children: jsx(Odoo, { host: 'erp.example.com' }),
185
- }),
186
- })
187
- )
188
- const ingress = result.resources.find((r) => r.kind === 'Ingress') as any
189
- expect(ingress).toBeDefined()
190
- expect(ingress.spec.rules[0].host).toBe('erp.example.com')
191
- })
192
-
193
- it('passes resource validation', () => {
194
- const result = renderOdoo({ host: 'erp.example.com' })
195
- for (const resource of result.resources) {
196
- expect(validateResource(resource)).toEqual([])
197
- }
198
- })
199
- })
200
-
201
- describe('rendering with all props', () => {
202
- it('accepts the full prop surface at the maximum replica count of 1', () => {
203
- const result = renderOdoo({
204
- name: 'erp',
205
- namespace: 'erp',
206
- version: '17.0',
207
- host: 'erp.example.com',
208
- filestore: '50Gi',
209
- workers: 4,
210
- resources: {
211
- requests: { memory: '1Gi', cpu: '500m' },
212
- limits: { memory: '4Gi', cpu: '2000m' },
213
- },
214
- tls: { secretName: 'erp-tls', clusterIssuer: 'letsencrypt-prod' },
215
- })
216
- expect(result.resources.length).toBeGreaterThan(0)
217
- const deployment = findDeployment(result, 'erp')
218
- expect(deployment.spec.replicas).toBe(1)
219
- expect(deployment.spec.template.spec.containers[0].image).toBe('odoo:17.0')
220
- expect(deployment.spec.template.spec.containers[0].resources.limits.memory).toBe('4Gi')
221
- const configMap = result.resources.find(
222
- (r: any) => r.kind === 'ConfigMap' && r.metadata.name === 'erp-config'
223
- ) as any
224
- const conf = configMap.data['odoo.conf']
225
- expect(conf).toContain('workers = 4')
226
- expect(conf).toContain(`limit_memory_hard = ${4 * 1024 ** 3}`)
227
- expect(conf).toContain(`limit_memory_soft = ${Math.round(4 * 1024 ** 3 * 0.8)}`)
228
- const pvc = result.resources.find((r: any) => r.kind === 'PersistentVolumeClaim') as any
229
- expect(pvc.metadata.name).toBe('erp-filestore')
230
- expect(pvc.spec.resources.requests.storage).toBe('50Gi')
231
- })
232
-
233
- it('throws a descriptive error when replicas > 1 (RWO filestore)', () => {
234
- expect(() => renderOdoo({ host: 'erp.example.com', replicas: 2 })).toThrow(
235
- /filestore.*ReadWriteOnce/
236
- )
237
- expect(() => renderOdoo({ host: 'erp.example.com', name: 'erp', replicas: 3 })).toThrow(
238
- /Fix: keep replicas at 1/
239
- )
240
- })
241
-
242
- it('throws when the memory limit cannot be parsed', () => {
243
- expect(() =>
244
- renderOdoo({
245
- host: 'erp.example.com',
246
- masterPasswordSecretName: 'existing-master',
247
- resources: { limits: { memory: 'lots' } },
248
- })
249
- ).toThrow(/cannot parse memory quantity/)
250
- })
251
- })
252
-
253
- describe('secrets handling', () => {
254
- it('accepts an explicit masterPasswordSecretName without a backend', () => {
255
- expect(() =>
256
- render(jsx(Odoo, { host: 'erp.example.com', masterPasswordSecretName: 'existing-master' }))
257
- ).not.toThrow()
258
- })
259
-
260
- it('provisions the master password through a secrets backend', () => {
261
- const result = renderOdoo({ host: 'erp.example.com' })
262
- const kinds = result.resources.map((r) => r.kind)
263
- expect(kinds).toContain('OpenBaoStaticSecret')
264
- const secret = result.resources.find((r: any) => r.kind === 'OpenBaoStaticSecret') as any
265
- expect(secret.metadata.name).toBe('odoo-master-password')
266
- expect(secret.spec.path).toBe('test/odoo/master')
267
- expect(secret.spec.destination).toEqual({ create: true, name: 'odoo-master-password' })
268
- })
269
-
270
- it('provisions the master password through Vault', () => {
271
- const result = render(
272
- jsx(SecretContext.Provider, {
273
- value: { backend: 'vault', mount: 'kv', path: 'apps' },
274
- children: jsx(Odoo, { host: 'erp.example.com' }),
275
- })
276
- )
277
- const kinds = result.resources.map((r) => r.kind)
278
- expect(kinds).toContain('VaultStaticSecret')
279
- const secret = result.resources.find((r: any) => r.kind === 'VaultStaticSecret') as any
280
- expect(secret.spec.path).toBe('apps/odoo/master')
281
- })
282
-
283
- it('wires credentials via upstream env names and secretKeyRef (never plaintext)', () => {
284
- const result = renderOdoo({
285
- host: 'erp.example.com',
286
- masterPasswordSecretName: 'existing-master',
287
- })
288
- const env = findDeployment(result).spec.template.spec.containers[0].env
289
- const masterPassword = env.find((e: any) => e.name === 'MASTER_PASSWORD')
290
- const dbPassword = env.find((e: any) => e.name === 'DB_PASSWORD')
291
- expect(masterPassword.valueFrom.secretKeyRef.name).toBe('existing-master')
292
- expect(masterPassword.valueFrom.secretKeyRef.key).toBe('masterPassword')
293
- expect(dbPassword.valueFrom.secretKeyRef.name).toBe('odoo-db-credentials')
294
- expect(dbPassword.valueFrom.secretKeyRef.key).toBe('password')
295
- expect(masterPassword.value).toBeUndefined()
296
- expect(dbPassword.value).toBeUndefined()
297
- })
298
-
299
- it('renders no plaintext credentials anywhere', () => {
300
- const result = renderOdoo({ host: 'erp.example.com', filestore: '50Gi', workers: 4 })
301
- const { passed, errors } = runGuardrails(result.resources as any[], [noPlaintextSecrets])
302
- if (!passed) {
303
- console.error('Plaintext credential violations:', errors)
304
- }
305
- expect(passed).toBe(true)
306
- })
307
- })
308
-
309
- describe('validation errors', () => {
310
- it('throws when no secrets backend and no master password secret', () => {
311
- expect(() => render(jsx(Odoo, { host: 'erp.example.com' }))).toThrow(/master password/)
312
- })
313
-
314
- it('throws for unknown secrets backends', () => {
315
- expect(() =>
316
- render(
317
- jsx(SecretContext.Provider, {
318
- value: { backend: 'unknown' as never },
319
- children: jsx(Odoo, { host: 'erp.example.com' }),
320
- })
321
- )
322
- ).toThrow(/master password/)
323
- })
324
- })
@@ -1,8 +0,0 @@
1
- import { Platform } from '@r8s/recipes'
2
- import { Odoo } from '@r8s/odoo'
3
-
4
- export default (
5
- <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
6
- <Odoo name="erp" host="erp.example.com" workers={4} />
7
- </Platform>
8
- )
package/src/index.tsx DELETED
@@ -1,391 +0,0 @@
1
- import { jsx, Fragment, useContext } from '@r8s/core'
2
- import { SecretContext } from '@r8s/core/defaults'
3
- import type {
4
- ConfigMap,
5
- Deployment,
6
- EnvVar,
7
- PersistentVolumeClaim,
8
- Service,
9
- VolumeMount,
10
- } from '@r8s/k8s-types'
11
- import { Database, Endpoint } from '@r8s/recipes'
12
-
13
- export interface OdooProps {
14
- /** Resource name (defaults to 'odoo') */
15
- name?: string
16
- /** Kubernetes namespace (defaults to 'default') */
17
- namespace?: string
18
- /** Container image tag (defaults to '18' — pin a version in production) */
19
- version?: string
20
- /** Public hostname for the ERP web UI (required) */
21
- host: string
22
- /**
23
- * Number of replicas. Must stay at 1 — the filestore PVC is
24
- * ReadWriteOnce and cannot attach to multiple pods (defaults to 1).
25
- */
26
- replicas?: number
27
- /**
28
- * Size of the filestore PersistentVolumeClaim (defaults to '20Gi').
29
- * Odoo stores attachments and binary fields here.
30
- */
31
- filestore?: string
32
- /**
33
- * Odoo process-level worker processes, rendered into odoo.conf
34
- * (defaults to 2). Rule of thumb: (CPU threads * 2) + 1, accounting
35
- * for cron workers.
36
- */
37
- workers?: number
38
- /**
39
- * Name of an existing Secret containing key `masterPassword`. Odoo
40
- * requires this to manage the super-admin (`/web/database/manager`).
41
- *
42
- * Required unless a secrets backend (openbao/vault) is configured on
43
- * the surrounding Platform — the backend then provisions the password
44
- * automatically. Plaintext passwords are not supported.
45
- */
46
- masterPasswordSecretName?: string
47
- /** Requested resources */
48
- resources?: {
49
- requests?: { cpu?: string; memory?: string }
50
- limits?: { cpu?: string; memory?: string }
51
- }
52
- /** TLS configuration (defaults to letsencrypt-prod cluster issuer) */
53
- tls?: {
54
- secretName: string
55
- clusterIssuer: string
56
- }
57
- }
58
-
59
- const APP_PORT = 8069
60
- const HEALTH_PATH = '/web/health'
61
-
62
- type VolumeMountWithSubPath = VolumeMount & { subPath?: string }
63
-
64
- const MEMORY_UNIT_BYTES: Record<string, number> = {
65
- '': 1,
66
- B: 1,
67
- K: 1_000,
68
- KB: 1_000,
69
- M: 1_000_000,
70
- MB: 1_000_000,
71
- G: 1_000_000_000,
72
- GB: 1_000_000_000,
73
- T: 1_000_000_000_000,
74
- TB: 1_000_000_000_000,
75
- P: 1_000_000_000_000_000,
76
- PB: 1_000_000_000_000_000,
77
- E: 1_000_000_000_000_000_000,
78
- EB: 1_000_000_000_000_000_000,
79
- KI: 1 << 10,
80
- MI: 1 << 20,
81
- GI: 1 << 30,
82
- TI: 2 ** 40,
83
- PI: 2 ** 50,
84
- EI: 2 ** 60,
85
- }
86
-
87
- /** Parse a Kubernetes memory quantity ("2Gi", "512Mi", "1G") into bytes. */
88
- function memoryToBytes(memory: string): number {
89
- const match = /^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)?$/.exec(memory.trim())
90
- if (!match) {
91
- throw new Error(
92
- `Odoo: cannot parse memory quantity "${memory}" — use quantities like "2Gi" or "512Mi"`
93
- )
94
- }
95
- const amount = Number(match[1])
96
- const unit = (match[2] ?? '').toUpperCase()
97
- const multiplier = MEMORY_UNIT_BYTES[unit]
98
- if (multiplier === undefined) {
99
- throw new Error(
100
- `Odoo: unsupported memory unit in "${memory}" — use B, K/Ki, M/Mi, G/Gi, T/Ti, P/Pi or E/Ei`
101
- )
102
- }
103
- return Math.round(amount * multiplier)
104
- }
105
-
106
- /**
107
- * Odoo — open-source ERP / business applications suite.
108
- *
109
- * @title Odoo
110
- * @category Business Applications
111
- *
112
- * Composes:
113
- * - CNPG Postgres cluster (Odoo data model) via the Database recipe
114
- * - Odoo Deployment + Service + Endpoint (port 8069 — HTTP mode serves
115
- * web and websockets on the same port in this image)
116
- * - odoo.conf ConfigMap mounted at /etc/odoo/odoo.conf (proxy mode,
117
- * workers, memory-derived worker limits, request recycling)
118
- * - Filestore PVC for attachments and binary fields
119
- * - Master password provisioned through the Platform secrets backend
120
- * (openbao / vault), or referenced from an existing Secret
121
- *
122
- * The main app is a raw Deployment (not WebService) because Odoo needs
123
- * the filestore PVC volume-mounted at /var/lib/odoo — the WebService
124
- * recipe cannot express volume mounts.
125
- *
126
- * Wrap the component in `<Platform secrets={{ backend: 'openbao' }}>` and
127
- * the MASTER_PASSWORD is provisioned for you. Without a backend you
128
- * must point `masterPasswordSecretName` at a pre-created Secret.
129
- *
130
- * @example
131
- * import { Platform } from '@r8s/recipes'
132
- * import { Odoo } from '@r8s/odoo'
133
- *
134
- * export default (
135
- * <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>
136
- * <Odoo name="erp" host="erp.example.com" workers={4} />
137
- * </Platform>
138
- * )
139
- *
140
- * @example
141
- * import { Odoo } from '@r8s/odoo'
142
- *
143
- * export default (
144
- * <Odoo
145
- * name="erp"
146
- * host="erp.example.com"
147
- * filestore="100Gi"
148
- * masterPasswordSecretName="odoo-master-password"
149
- * />
150
- * )
151
- */
152
- export function Odoo(props: OdooProps) {
153
- const {
154
- name = 'odoo',
155
- namespace = 'default',
156
- version = '18',
157
- host,
158
- replicas = 1,
159
- filestore = '20Gi',
160
- workers = 2,
161
- masterPasswordSecretName,
162
- resources = {
163
- requests: { memory: '512Mi', cpu: '250m' },
164
- limits: { memory: '2Gi', cpu: '1000m' },
165
- },
166
- tls = { secretName: `${name}-tls`, clusterIssuer: 'letsencrypt-prod' },
167
- } = props
168
-
169
- const secretProvider = useContext(SecretContext)
170
- const resources_: ReturnType<typeof jsx>[] = []
171
-
172
- const dbHost = `${name}-rw`
173
- const dbCredentialsName = `${name}-db-credentials`
174
- const masterSecretName = masterPasswordSecretName ?? `${name}-master-password`
175
- const configMapName = `${name}-config`
176
- const filestoreClaim = `${name}-filestore`
177
-
178
- if (replicas > 1) {
179
- throw new Error(
180
- `Odoo "${name}" cannot run with replicas: ${replicas}.\n` +
181
- `\n` +
182
- `The filestore PersistentVolumeClaim "${filestoreClaim}" is ReadWriteOnce ` +
183
- `and can only attach to a single pod at a time — every pod beyond the first ` +
184
- `stays Pending with a Multi-Attach error.\n` +
185
- `\n` +
186
- `Fix: keep replicas at 1 (the default), or provide read-write-many shared ` +
187
- `storage for the filestore and override the claim's accessMode before scaling.`
188
- )
189
- }
190
-
191
- // --- Secret provisioning ---------------------------------------------------
192
- // The Odoo master password unlocks /web/database/manager — never render
193
- // it as plaintext. With a secrets backend it is provisioned through the
194
- // backend (key `masterPassword`); otherwise reference a pre-created Secret.
195
- if (!masterPasswordSecretName) {
196
- if (
197
- !secretProvider ||
198
- (secretProvider.backend !== 'vault' && secretProvider.backend !== 'openbao')
199
- ) {
200
- throw new Error(
201
- `Odoo "${name}" requires a master password.\n` +
202
- `\n` +
203
- `Odoo uses MASTER_PASSWORD to manage the super-admin — ` +
204
- `it must not be rendered as plaintext.\n` +
205
- `\n` +
206
- `Fix: configure a secrets backend on the Platform:\n` +
207
- ` <Platform secrets={{ backend: 'openbao', mount: 'kv', path: 'apps' }}>\n` +
208
- ` <Odoo name="${name}" host="${host}" />\n` +
209
- ` </Platform>\n` +
210
- `\n` +
211
- `Or reference a pre-created Secret (key: masterPassword):\n` +
212
- ` <Odoo name="${name}" host="${host}" masterPasswordSecretName="${name}-master-password" />`
213
- )
214
- }
215
-
216
- const spec = {
217
- ...(secretProvider.backend === 'vault'
218
- ? { vaultAuthRef: secretProvider.authRef }
219
- : { openbaoAuthRef: secretProvider.authRef }),
220
- mount: secretProvider.mount,
221
- type: 'kv-v2' as const,
222
- path: `${secretProvider.path ?? name}/${name}/master`,
223
- destination: { create: true, name: masterSecretName },
224
- }
225
- resources_.push(
226
- secretProvider.backend === 'vault'
227
- ? jsx('VaultStaticSecret', {
228
- apiVersion: 'secrets.hashicorp.com/v1beta1',
229
- kind: 'VaultStaticSecret',
230
- metadata: { name: `${name}-master-password`, namespace },
231
- spec,
232
- })
233
- : jsx('OpenBaoStaticSecret', {
234
- apiVersion: 'secrets.openbao.org/v1beta1',
235
- kind: 'OpenBaoStaticSecret',
236
- metadata: { name: `${name}-master-password`, namespace },
237
- spec,
238
- })
239
- )
240
- }
241
-
242
- // --- Operators --------------------------------------------------------------
243
- // None declared directly: cnpg comes from the <Database> recipe (which
244
- // checks OperatorContext and dedupes), routing operators come from
245
- // <Endpoint>.
246
-
247
- // --- Env wiring ---------------------------------------------------------------
248
- // Upstream odoo:18 entrypoint env names (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD).
249
- // Credentials are declared FIRST via secretKeyRef so plain env values could
250
- // reference them with Kubernetes $(VAR) dependent expansion. No plaintext.
251
- const env: EnvVar[] = [
252
- {
253
- name: 'MASTER_PASSWORD',
254
- valueFrom: { secretKeyRef: { name: masterSecretName, key: 'masterPassword' } },
255
- },
256
- {
257
- name: 'DB_PASSWORD',
258
- valueFrom: { secretKeyRef: { name: dbCredentialsName, key: 'password' } },
259
- },
260
- { name: 'DB_HOST', value: dbHost },
261
- { name: 'DB_PORT', value: '5432' },
262
- { name: 'DB_USER', value: name },
263
- { name: 'ODOO_DB', value: name },
264
- ]
265
-
266
- // --- odoo.conf -----------------------------------------------------------------
267
- // worker memory limits are derived from the container memory limit so the
268
- // aggregator cannot slowly OOM-kill the pod: soft limit (worker recycled)
269
- // at 80% of hard (worker killed outright).
270
- const limitMemoryHard = memoryToBytes(resources.limits?.memory ?? '2Gi')
271
- const limitMemorySoft = Math.round(limitMemoryHard * 0.8)
272
- const odooConf = [
273
- '[options]',
274
- 'proxy_mode = True',
275
- `workers = ${workers}`,
276
- `limit_memory_hard = ${limitMemoryHard}`,
277
- `limit_memory_soft = ${limitMemorySoft}`,
278
- 'max_requests = 80',
279
- '',
280
- ].join('\n')
281
-
282
- const configMap: ConfigMap = {
283
- apiVersion: 'v1',
284
- kind: 'ConfigMap',
285
- metadata: { name: configMapName, namespace, labels: { app: name } },
286
- data: { 'odoo.conf': odooConf },
287
- }
288
- resources_.push(jsx('ConfigMap', configMap))
289
-
290
- // --- Filestore PVC ------------------------------------------------------------
291
- // Odoo stores attachments and binary fields in the filestore
292
- // (/var/lib/odoo). This is the whole reason the app is a raw Deployment:
293
- // WebService cannot mount volumes. ReadWriteOnce keeps a single writer,
294
- // which is also why replicas must stay at 1 (enforced above).
295
- const filestorePvc: PersistentVolumeClaim = {
296
- apiVersion: 'v1',
297
- kind: 'PersistentVolumeClaim',
298
- metadata: { name: filestoreClaim, namespace, labels: { app: name } },
299
- spec: {
300
- accessModes: ['ReadWriteOnce'],
301
- resources: { requests: { storage: filestore } },
302
- },
303
- }
304
- resources_.push(jsx('PersistentVolumeClaim', filestorePvc))
305
-
306
- // --- Database + Odoo Deployment + Service --------------------------------------
307
- // <Database> is the parent wrapper so the CNPG operator and the Cluster
308
- // piggyback on the r8s Database recipe; the raw Deployment references the
309
- // connection info by convention (host `${name}-rw`, credentials secret
310
- // `${name}-db-credentials` key `password`).
311
- const configMount: VolumeMountWithSubPath = {
312
- name: 'config',
313
- mountPath: '/etc/odoo/odoo.conf',
314
- subPath: 'odoo.conf',
315
- }
316
-
317
- const deployment: Deployment = {
318
- apiVersion: 'apps/v1',
319
- kind: 'Deployment',
320
- metadata: { name, namespace, labels: { app: name } },
321
- spec: {
322
- replicas,
323
- selector: { matchLabels: { app: name } },
324
- template: {
325
- metadata: { labels: { app: name } },
326
- spec: {
327
- containers: [
328
- {
329
- name: 'odoo',
330
- image: `odoo:${version}`,
331
- ports: [{ name: 'http', containerPort: APP_PORT }],
332
- env,
333
- resources,
334
- livenessProbe: {
335
- httpGet: { path: HEALTH_PATH, port: APP_PORT },
336
- initialDelaySeconds: 60,
337
- periodSeconds: 30,
338
- },
339
- readinessProbe: {
340
- httpGet: { path: HEALTH_PATH, port: APP_PORT },
341
- initialDelaySeconds: 30,
342
- periodSeconds: 10,
343
- },
344
- volumeMounts: [{ name: 'filestore', mountPath: '/var/lib/odoo' }, configMount],
345
- },
346
- ],
347
- volumes: [
348
- { name: 'filestore', persistentVolumeClaim: { claimName: filestoreClaim } },
349
- { name: 'config', configMap: { name: configMapName } },
350
- ],
351
- },
352
- },
353
- },
354
- }
355
-
356
- const service: Service = {
357
- apiVersion: 'v1',
358
- kind: 'Service',
359
- metadata: { name, namespace, labels: { app: name } },
360
- spec: {
361
- type: 'ClusterIP',
362
- selector: { app: name },
363
- ports: [{ name: 'http', port: APP_PORT, targetPort: APP_PORT }],
364
- },
365
- }
366
-
367
- resources_.push(
368
- jsx(Database, {
369
- name,
370
- namespace,
371
- storage: '10Gi',
372
- children: jsx(Fragment, {
373
- children: [jsx('Deployment', deployment), jsx('Service', service)],
374
- }),
375
- })
376
- )
377
-
378
- // --- Endpoint — public web UI ---------------------------------------------------
379
- resources_.push(
380
- <Endpoint
381
- name={`${name}-endpoint`}
382
- namespace={namespace}
383
- host={host}
384
- serviceName={name}
385
- servicePort={APP_PORT}
386
- tls={tls}
387
- />
388
- )
389
-
390
- return jsx(Fragment, { children: resources_ })
391
- }
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
- }