@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.
- package/dist/index.d.ts +99 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +286 -0
- package/dist/index.js.map +1 -0
- package/package.json +5 -2
- package/__tests__/odoo.test.ts +0 -324
- package/examples/basic.tsx +0 -8
- package/src/index.tsx +0 -391
- package/tsconfig.json +0 -15
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
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
|
}
|
package/__tests__/odoo.test.ts
DELETED
|
@@ -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
|
-
})
|
package/examples/basic.tsx
DELETED
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
|
-
}
|