@oondemand/oon-core-back 0.3.22 → 0.3.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -0,0 +1,394 @@
1
+ "use strict";
2
+
3
+ const { mongo } = require("mongoose");
4
+
5
+ const DIGITALOCEAN_API = "https://api.digitalocean.com/v2";
6
+ const DIGITALOCEAN_HOST_SUFFIX = ".mongo.ondigitalocean.com";
7
+
8
+ function randomSecret(bytes = 32) {
9
+ return require("node:crypto").randomBytes(bytes).toString("base64url");
10
+ }
11
+
12
+ function dnsLabel(value, max = 63) {
13
+ return String(value || "")
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9-]+/g, "-")
16
+ .replace(/^-+|-+$/g, "")
17
+ .slice(0, max)
18
+ .replace(/-+$/, "");
19
+ }
20
+
21
+ function mongoIdentity(appCode, environment) {
22
+ return {
23
+ dbName: dnsLabel(`${appCode}_${environment}`, 48).replace(/-/g, "_"),
24
+ username: dnsLabel(`${appCode}-${environment}`, 48),
25
+ };
26
+ }
27
+
28
+ function mongoHost(uri) {
29
+ try {
30
+ return new URL(uri).hostname.toLowerCase();
31
+ } catch {
32
+ throw new Error("MONGO_ADMIN_URI inválida.");
33
+ }
34
+ }
35
+
36
+ function resolveMongoProvisioner({ adminUri, provisioner = "auto" }) {
37
+ const normalized = String(provisioner || "auto").trim().toLowerCase();
38
+ if (!["auto", "native", "digitalocean"].includes(normalized)) {
39
+ throw new Error(
40
+ `MONGO_PROVISIONER inválido: ${normalized}. Use auto, native ou digitalocean.`,
41
+ );
42
+ }
43
+ if (normalized !== "auto") return normalized;
44
+ return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX)
45
+ ? "digitalocean"
46
+ : "native";
47
+ }
48
+
49
+ function buildRuntimeMongoUri({
50
+ adminUri,
51
+ username,
52
+ password,
53
+ dbName,
54
+ authSource,
55
+ }) {
56
+ const uri = new URL(adminUri);
57
+ uri.username = username;
58
+ uri.password = password;
59
+ uri.pathname = `/${dbName}`;
60
+ uri.searchParams.set("authSource", authSource);
61
+ return uri.toString();
62
+ }
63
+
64
+ async function validateMongoUri(
65
+ mongoUri,
66
+ {
67
+ MongoClient = mongo.MongoClient,
68
+ timeoutMs = 15000,
69
+ writeProbe = true,
70
+ } = {},
71
+ ) {
72
+ const client = new MongoClient(mongoUri, {
73
+ serverSelectionTimeoutMS: timeoutMs,
74
+ connectTimeoutMS: timeoutMs,
75
+ maxPoolSize: 2,
76
+ });
77
+
78
+ await client.connect();
79
+ try {
80
+ const db = client.db();
81
+ await db.command({ ping: 1 });
82
+ if (writeProbe) {
83
+ const probe = db.collection("__oon_runtime_probe");
84
+ const id = "provisioning";
85
+ await probe.updateOne(
86
+ { _id: id },
87
+ { $set: { checkedAt: new Date() } },
88
+ { upsert: true },
89
+ );
90
+ await probe.deleteOne({ _id: id });
91
+ }
92
+ } finally {
93
+ await client.close();
94
+ }
95
+ return true;
96
+ }
97
+
98
+ async function waitForMongoUri(
99
+ mongoUri,
100
+ {
101
+ validate = validateMongoUri,
102
+ attempts = 12,
103
+ delayMs = 5000,
104
+ } = {},
105
+ ) {
106
+ let lastError;
107
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
108
+ try {
109
+ await validate(mongoUri);
110
+ return true;
111
+ } catch (error) {
112
+ lastError = error;
113
+ if (attempt < attempts) {
114
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
115
+ }
116
+ }
117
+ }
118
+ const reason = lastError?.codeName || lastError?.code || lastError?.name;
119
+ throw new Error(
120
+ `A credencial derivada do MongoDB não passou no teste de leitura e escrita${
121
+ reason ? ` (${reason})` : ""
122
+ }.`,
123
+ );
124
+ }
125
+
126
+ async function provisionNativeMongo({ adminUri, appCode, environment }) {
127
+ const { dbName, username } = mongoIdentity(appCode, environment);
128
+ const password = randomSecret(30);
129
+ const client = new mongo.MongoClient(adminUri, {
130
+ serverSelectionTimeoutMS: 15000,
131
+ });
132
+ await client.connect();
133
+ try {
134
+ const db = client.db(dbName);
135
+ try {
136
+ await db.command({
137
+ createUser: username,
138
+ pwd: password,
139
+ roles: [{ role: "readWrite", db: dbName }],
140
+ });
141
+ } catch (error) {
142
+ const alreadyExists =
143
+ error.codeName === "DuplicateKey" ||
144
+ error.code === 51003 ||
145
+ /already exists/i.test(error.message);
146
+ if (!alreadyExists) throw error;
147
+ await db.command({
148
+ updateUser: username,
149
+ pwd: password,
150
+ roles: [{ role: "readWrite", db: dbName }],
151
+ });
152
+ }
153
+ } finally {
154
+ await client.close();
155
+ }
156
+
157
+ return {
158
+ dbName,
159
+ username,
160
+ mongoUri: buildRuntimeMongoUri({
161
+ adminUri,
162
+ username,
163
+ password,
164
+ dbName,
165
+ authSource: dbName,
166
+ }),
167
+ provider: "native",
168
+ };
169
+ }
170
+
171
+ async function digitalOceanRequest(
172
+ {
173
+ token,
174
+ clusterId,
175
+ path,
176
+ method = "GET",
177
+ body,
178
+ expected = [200],
179
+ },
180
+ { fetchImpl = globalThis.fetch } = {},
181
+ ) {
182
+ if (!fetchImpl) throw new Error("fetch não disponível para a DigitalOcean API.");
183
+ const controller = new AbortController();
184
+ const timeout = setTimeout(() => controller.abort(), 20000);
185
+ let response;
186
+ try {
187
+ response = await fetchImpl(
188
+ `${DIGITALOCEAN_API}/databases/${encodeURIComponent(clusterId)}${path}`,
189
+ {
190
+ method,
191
+ headers: {
192
+ Authorization: `Bearer ${token}`,
193
+ Accept: "application/json",
194
+ ...(body ? { "Content-Type": "application/json" } : {}),
195
+ },
196
+ body: body ? JSON.stringify(body) : undefined,
197
+ signal: controller.signal,
198
+ },
199
+ );
200
+ } catch (error) {
201
+ if (error.name === "AbortError") {
202
+ throw new Error("Timeout ao acessar a DigitalOcean API.");
203
+ }
204
+ throw new Error(`Falha ao acessar a DigitalOcean API: ${error.message}`);
205
+ } finally {
206
+ clearTimeout(timeout);
207
+ }
208
+
209
+ const payload = await response.json().catch(() => ({}));
210
+ if (!expected.includes(response.status)) {
211
+ const error = new Error(
212
+ `DigitalOcean API rejeitou ${method} ${path}: HTTP ${response.status} (${payload.id || "erro"}).`,
213
+ );
214
+ error.status = response.status;
215
+ error.payload = payload;
216
+ throw error;
217
+ }
218
+ return { status: response.status, payload };
219
+ }
220
+
221
+ async function ensureDigitalOceanDatabase(options, deps) {
222
+ const path = `/dbs/${encodeURIComponent(options.dbName)}`;
223
+ try {
224
+ await digitalOceanRequest(
225
+ { ...options, path, expected: [200] },
226
+ deps,
227
+ );
228
+ return;
229
+ } catch (error) {
230
+ if (error.status !== 404) throw error;
231
+ }
232
+
233
+ await digitalOceanRequest(
234
+ {
235
+ ...options,
236
+ path: "/dbs",
237
+ method: "POST",
238
+ body: { name: options.dbName },
239
+ expected: [201, 202],
240
+ },
241
+ deps,
242
+ );
243
+ }
244
+
245
+ function digitalOceanUserSettings(dbName) {
246
+ return {
247
+ mongo_user_settings: {
248
+ databases: [dbName],
249
+ role: "readWrite",
250
+ },
251
+ };
252
+ }
253
+
254
+ async function provisionDigitalOceanUser(options, deps) {
255
+ const userPath = `/users/${encodeURIComponent(options.username)}`;
256
+ let exists = true;
257
+ try {
258
+ await digitalOceanRequest(
259
+ { ...options, path: userPath, expected: [200] },
260
+ deps,
261
+ );
262
+ } catch (error) {
263
+ if (error.status !== 404) throw error;
264
+ exists = false;
265
+ }
266
+
267
+ const settings = digitalOceanUserSettings(options.dbName);
268
+ let result;
269
+ if (!exists) {
270
+ result = await digitalOceanRequest(
271
+ {
272
+ ...options,
273
+ path: "/users",
274
+ method: "POST",
275
+ body: { name: options.username, settings },
276
+ expected: [201, 202],
277
+ },
278
+ deps,
279
+ );
280
+ } else {
281
+ await digitalOceanRequest(
282
+ {
283
+ ...options,
284
+ path: userPath,
285
+ method: "PUT",
286
+ body: { settings },
287
+ expected: [200, 202],
288
+ },
289
+ deps,
290
+ );
291
+ result = await digitalOceanRequest(
292
+ {
293
+ ...options,
294
+ path: `${userPath}/reset_auth`,
295
+ method: "POST",
296
+ body: {},
297
+ expected: [200, 201, 202],
298
+ },
299
+ deps,
300
+ );
301
+ }
302
+
303
+ const user = result.payload.user;
304
+ if (!user?.password) {
305
+ throw new Error(
306
+ "DigitalOcean API não retornou a senha do usuário. O token precisa do escopo database:view_credentials.",
307
+ );
308
+ }
309
+ return user;
310
+ }
311
+
312
+ async function provisionDigitalOceanMongo(
313
+ {
314
+ adminUri,
315
+ appCode,
316
+ environment,
317
+ digitalOceanToken,
318
+ digitalOceanClusterId,
319
+ },
320
+ deps = {},
321
+ ) {
322
+ if (!digitalOceanToken) {
323
+ throw new Error(
324
+ "DIGITALOCEAN_TOKEN é obrigatório para provisionar usuários no MongoDB gerenciado da DigitalOcean.",
325
+ );
326
+ }
327
+ if (!digitalOceanClusterId) {
328
+ throw new Error(
329
+ "DIGITALOCEAN_DATABASE_CLUSTER_ID é obrigatório para provisionar usuários no MongoDB gerenciado da DigitalOcean.",
330
+ );
331
+ }
332
+
333
+ const { dbName, username } = mongoIdentity(appCode, environment);
334
+ const apiOptions = {
335
+ token: digitalOceanToken,
336
+ clusterId: digitalOceanClusterId,
337
+ dbName,
338
+ username,
339
+ };
340
+ await ensureDigitalOceanDatabase(apiOptions, deps);
341
+ const user = await provisionDigitalOceanUser(apiOptions, deps);
342
+ const mongoUri = buildRuntimeMongoUri({
343
+ adminUri,
344
+ username: user.name || username,
345
+ password: user.password,
346
+ dbName,
347
+ authSource: "admin",
348
+ });
349
+
350
+ await waitForMongoUri(mongoUri, {
351
+ validate: deps.validateMongoUri || validateMongoUri,
352
+ attempts: deps.validationAttempts,
353
+ delayMs: deps.validationDelayMs,
354
+ });
355
+
356
+ return {
357
+ dbName,
358
+ username: user.name || username,
359
+ mongoUri,
360
+ provider: "digitalocean",
361
+ };
362
+ }
363
+
364
+ async function provisionMongo(options, deps = {}) {
365
+ const provider = resolveMongoProvisioner(options);
366
+ if (provider === "digitalocean") {
367
+ return provisionDigitalOceanMongo(options, deps);
368
+ }
369
+ const result = await provisionNativeMongo(options);
370
+ await waitForMongoUri(result.mongoUri, {
371
+ validate: deps.validateMongoUri || validateMongoUri,
372
+ attempts: deps.validationAttempts,
373
+ delayMs: deps.validationDelayMs,
374
+ });
375
+ return result;
376
+ }
377
+
378
+ module.exports = {
379
+ DIGITALOCEAN_API,
380
+ DIGITALOCEAN_HOST_SUFFIX,
381
+ mongoIdentity,
382
+ mongoHost,
383
+ resolveMongoProvisioner,
384
+ buildRuntimeMongoUri,
385
+ validateMongoUri,
386
+ waitForMongoUri,
387
+ provisionNativeMongo,
388
+ digitalOceanRequest,
389
+ ensureDigitalOceanDatabase,
390
+ digitalOceanUserSettings,
391
+ provisionDigitalOceanUser,
392
+ provisionDigitalOceanMongo,
393
+ provisionMongo,
394
+ };
@@ -1,10 +1,10 @@
1
1
  "use strict";
2
2
 
3
3
  const crypto = require("node:crypto");
4
- const { mongo } = require("mongoose");
5
4
  const { spawnSync } = require("node:child_process");
6
5
  const { kubectl } = require("./command");
7
6
  const { validateInfraFromCluster } = require("../deploy/contracts/infra-contract");
7
+ const { provisionMongo, validateMongoUri } = require("./mongoProvisioning");
8
8
 
9
9
  const RESOURCE_PROFILES = {
10
10
  nano: { cpuRequest: "50m", cpuLimit: "250m", memoryRequest: "96Mi", memoryLimit: "256Mi" },
@@ -28,28 +28,6 @@ function readSecret(namespace, name, key) {
28
28
  } catch { return null; }
29
29
  }
30
30
 
31
- async function provisionMongo({ adminUri, appCode, environment }) {
32
- const dbName = dnsLabel(`${appCode}_${environment}`, 48).replace(/-/g, "_");
33
- const username = dnsLabel(`${appCode}-${environment}`, 48);
34
- const password = randomSecret(30);
35
- const client = new mongo.MongoClient(adminUri, { serverSelectionTimeoutMS: 15000 });
36
- await client.connect();
37
- try {
38
- const db = client.db(dbName);
39
- try { await db.command({ createUser: username, pwd: password, roles: [{ role: "readWrite", db: dbName }] }); }
40
- catch (error) {
41
- if (error.codeName !== "DuplicateKey" && error.code !== 51003 && !/already exists/i.test(error.message)) throw error;
42
- await db.command({ updateUser: username, pwd: password, roles: [{ role: "readWrite", db: dbName }] });
43
- }
44
- } finally { await client.close(); }
45
- const uri = new URL(adminUri);
46
- uri.username = username;
47
- uri.password = password;
48
- uri.pathname = `/${dbName}`;
49
- uri.searchParams.set("authSource", dbName);
50
- return { dbName, mongoUri: uri.toString() };
51
- }
52
-
53
31
  function baselineManifest({ context }) {
54
32
  return `apiVersion: v1
55
33
  kind: Namespace
@@ -189,18 +167,71 @@ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {
189
167
  async function provisionRuntime({ context, extraSecrets = {} }) {
190
168
  const infra = validateInfraFromCluster().config;
191
169
  applyYaml(baselineManifest({ context, infra }));
170
+
171
+ const platformNamespace = infra.PLATFORM_NAMESPACE || "oon-system";
172
+ const adminSecretName = infra.MONGO_ADMIN_SECRET_NAME || "oon-mongo-admin";
173
+ const adminUri = readSecret(
174
+ platformNamespace,
175
+ adminSecretName,
176
+ "MONGO_ADMIN_URI",
177
+ );
178
+ if (!adminUri) {
179
+ throw new Error("MongoDB administrativo não configurado na plataforma.");
180
+ }
181
+
182
+ const mongoProvisioner =
183
+ readSecret(platformNamespace, adminSecretName, "MONGO_PROVISIONER") ||
184
+ "auto";
185
+ const digitalOceanToken = readSecret(
186
+ platformNamespace,
187
+ adminSecretName,
188
+ "DIGITALOCEAN_TOKEN",
189
+ );
190
+ const digitalOceanClusterId = readSecret(
191
+ platformNamespace,
192
+ adminSecretName,
193
+ "DIGITALOCEAN_DATABASE_CLUSTER_ID",
194
+ );
195
+
192
196
  let mongoUri = readSecret(context.namespace, "oon-runtime", "MONGO_URI");
193
197
  let databaseName = "";
198
+ let credentialRotated = false;
199
+
200
+ if (mongoUri) {
201
+ try {
202
+ await validateMongoUri(mongoUri);
203
+ } catch (error) {
204
+ console.warn(
205
+ "A credencial MongoDB persistida não passou na validação; " +
206
+ "ela será reprovisionada.",
207
+ );
208
+ mongoUri = null;
209
+ credentialRotated = true;
210
+ }
211
+ }
212
+
194
213
  if (!mongoUri) {
195
- const adminUri = readSecret(infra.PLATFORM_NAMESPACE || "oon-system", infra.MONGO_ADMIN_SECRET_NAME || "oon-mongo-admin", "MONGO_ADMIN_URI");
196
- if (!adminUri) throw new Error("MongoDB administrativo não configurado na plataforma.");
197
- const provisioned = await provisionMongo({ adminUri, appCode: context.appCode, environment: context.environment });
214
+ const provisioned = await provisionMongo({
215
+ adminUri,
216
+ appCode: context.appCode,
217
+ environment: context.environment,
218
+ provisioner: mongoProvisioner,
219
+ digitalOceanToken,
220
+ digitalOceanClusterId,
221
+ });
198
222
  mongoUri = provisioned.mongoUri;
199
223
  databaseName = provisioned.dbName;
224
+ credentialRotated = true;
200
225
  }
201
- const encryptionKey = readSecret(context.namespace, "oon-runtime", "INSTANCE_CREDENTIAL_ENCRYPTION_KEY") || randomSecret(48);
226
+
227
+ const encryptionKey =
228
+ readSecret(
229
+ context.namespace,
230
+ "oon-runtime",
231
+ "INSTANCE_CREDENTIAL_ENCRYPTION_KEY",
232
+ ) || randomSecret(48);
202
233
  applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets });
203
- return { infra, databaseName };
234
+ return { infra, databaseName, credentialRotated };
204
235
  }
205
236
 
206
237
  function deployRuntime({ context, infra, imageDigest, resourceProfile = "small" }) {
@@ -249,4 +280,4 @@ async function activateMember(context) {
249
280
  return data;
250
281
  }
251
282
 
252
- module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, provisionMongo, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
283
+ module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };