@oondemand/oon-core-back 0.4.23 → 0.4.25
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/README.md +7 -0
- package/package.json +1 -1
- package/src/api/status.routes.js +52 -8
- package/src/delivery/mongoProvisioning.js +104 -33
package/README.md
CHANGED
|
@@ -15,6 +15,13 @@ O Core suporta `ecosystem.role` em `central.config.js`: `root` para a Central de
|
|
|
15
15
|
|
|
16
16
|
Compatibilidade temporária: `CENTRAL_ATIVACAO_BACKEND_URL` e `MEUS_APPS_BACKEND_URL` continuam aceitas como aliases da URL da API. Nunca aponte a URL da API para o próprio Core.
|
|
17
17
|
|
|
18
|
+
## Saúde operacional
|
|
19
|
+
|
|
20
|
+
- `GET /health/ready`: retorna HTTP 200 somente quando o MongoDB está conectado; retorna 503 enquanto o runtime não estiver pronto.
|
|
21
|
+
- `GET /health/version`: expõe as versões do OonCore e da Central, commit, release, build e ambiente da publicação.
|
|
22
|
+
|
|
23
|
+
Na imagem de entrega, essas rotas são publicadas pelo Nginx sob `/api/health/ready` e `/api/health/version`.
|
|
24
|
+
|
|
18
25
|
## Fluxo
|
|
19
26
|
|
|
20
27
|
`GET /ativacao/status` informa estado sem segredos. `POST /ativacao/validar-codigo` valida sem consumir. `POST /ativacao/concluir` revalida, chama `/ativar`, criptografa imediatamente o token da instância, executa hooks declarativos, chama `/concluir` e marca a instância como `ativa`. `POST /ativacao/tentar-novamente` retoma uma ativação já registrada sem exigir novo código.
|
package/package.json
CHANGED
package/src/api/status.routes.js
CHANGED
|
@@ -1,21 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
1
3
|
const express = require("express");
|
|
2
4
|
const mongoose = require("mongoose");
|
|
3
5
|
const ctx = require("../core/context");
|
|
6
|
+
const { runtimeSummary } = require("./core.routes");
|
|
4
7
|
|
|
5
8
|
const router = express.Router();
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const DATABASE_STATES = Object.freeze({
|
|
11
|
+
0: { code: "disconnected", label: "Desconectado" },
|
|
12
|
+
1: { code: "connected", label: "Conectado" },
|
|
13
|
+
2: { code: "connecting", label: "Conectando" },
|
|
14
|
+
3: { code: "disconnecting", label: "Desconectando" },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function databaseState(readyState = mongoose.connection.readyState) {
|
|
18
|
+
return DATABASE_STATES[readyState] || {
|
|
19
|
+
code: "unknown",
|
|
20
|
+
label: "Status desconhecido",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readinessSummary(readyState = mongoose.connection.readyState) {
|
|
25
|
+
const database = databaseState(readyState);
|
|
26
|
+
const ready = readyState === 1;
|
|
27
|
+
return {
|
|
28
|
+
status: ready ? "ready" : "not_ready",
|
|
29
|
+
service: ctx.config.serviceName,
|
|
30
|
+
database: {
|
|
31
|
+
ready,
|
|
32
|
+
state: database.code,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function versionSummary() {
|
|
38
|
+
return {
|
|
39
|
+
status: "ok",
|
|
40
|
+
service: ctx.config.serviceName,
|
|
41
|
+
...runtimeSummary(),
|
|
13
42
|
};
|
|
14
|
-
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
router.get("/", (_req, res) => {
|
|
46
|
+
const database = databaseState();
|
|
15
47
|
res.status(200).json({
|
|
16
48
|
message: `${ctx.config.serviceName} rodando - vs ${ctx.config.serviceVersion}`,
|
|
17
|
-
database:
|
|
49
|
+
database: database.label,
|
|
18
50
|
});
|
|
19
51
|
});
|
|
20
52
|
|
|
53
|
+
router.get("/health/ready", (_req, res) => {
|
|
54
|
+
const summary = readinessSummary();
|
|
55
|
+
res.status(summary.database.ready ? 200 : 503).json(summary);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
router.get("/health/version", (_req, res) => {
|
|
59
|
+
res.status(200).json(versionSummary());
|
|
60
|
+
});
|
|
61
|
+
|
|
21
62
|
module.exports = router;
|
|
63
|
+
module.exports.databaseState = databaseState;
|
|
64
|
+
module.exports.readinessSummary = readinessSummary;
|
|
65
|
+
module.exports.versionSummary = versionSummary;
|
|
@@ -4,6 +4,12 @@ const { mongo } = require("mongoose");
|
|
|
4
4
|
|
|
5
5
|
const DIGITALOCEAN_API = "https://api.digitalocean.com/v2";
|
|
6
6
|
const DIGITALOCEAN_HOST_SUFFIX = ".mongo.ondigitalocean.com";
|
|
7
|
+
const DIGITALOCEAN_RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
8
|
+
const DIGITALOCEAN_SERVER_STATUS = new Set([500, 502, 503, 504]);
|
|
9
|
+
|
|
10
|
+
function delay(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
7
13
|
|
|
8
14
|
function randomSecret(bytes = 32) {
|
|
9
15
|
return require("node:crypto").randomBytes(bytes).toString("base64url");
|
|
@@ -177,58 +183,108 @@ async function digitalOceanRequest(
|
|
|
177
183
|
body,
|
|
178
184
|
expected = [200],
|
|
179
185
|
},
|
|
180
|
-
{
|
|
186
|
+
{
|
|
187
|
+
fetchImpl = globalThis.fetch,
|
|
188
|
+
sleepImpl = delay,
|
|
189
|
+
retryAttempts = 3,
|
|
190
|
+
retryBaseDelayMs = 1000,
|
|
191
|
+
} = {},
|
|
181
192
|
) {
|
|
182
193
|
if (!fetchImpl) throw new Error("fetch não disponível para a DigitalOcean API.");
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
let
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
194
|
+
const attempts = Math.max(1, Number(retryAttempts) || 3);
|
|
195
|
+
|
|
196
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const timeout = setTimeout(() => controller.abort(), 20000);
|
|
199
|
+
let response;
|
|
200
|
+
try {
|
|
201
|
+
response = await fetchImpl(
|
|
202
|
+
`${DIGITALOCEAN_API}/databases/${encodeURIComponent(clusterId)}${path}`,
|
|
203
|
+
{
|
|
204
|
+
method,
|
|
205
|
+
headers: {
|
|
206
|
+
Authorization: `Bearer ${token}`,
|
|
207
|
+
Accept: "application/json",
|
|
208
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
209
|
+
},
|
|
210
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
211
|
+
signal: controller.signal,
|
|
195
212
|
},
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
213
|
+
);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (attempt < attempts) {
|
|
216
|
+
await sleepImpl(Math.min(10000, retryBaseDelayMs * 2 ** (attempt - 1)));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (error.name === "AbortError") {
|
|
220
|
+
throw new Error("Timeout ao acessar a DigitalOcean API.");
|
|
221
|
+
}
|
|
222
|
+
throw new Error(`Falha ao acessar a DigitalOcean API: ${error.message}`);
|
|
223
|
+
} finally {
|
|
224
|
+
clearTimeout(timeout);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const payload = await response.json().catch(() => ({}));
|
|
228
|
+
if (expected.includes(response.status)) {
|
|
229
|
+
return { status: response.status, payload };
|
|
203
230
|
}
|
|
204
|
-
throw new Error(`Falha ao acessar a DigitalOcean API: ${error.message}`);
|
|
205
|
-
} finally {
|
|
206
|
-
clearTimeout(timeout);
|
|
207
|
-
}
|
|
208
231
|
|
|
209
|
-
|
|
210
|
-
|
|
232
|
+
if (
|
|
233
|
+
DIGITALOCEAN_RETRYABLE_STATUS.has(response.status)
|
|
234
|
+
&& attempt < attempts
|
|
235
|
+
) {
|
|
236
|
+
const retryAfter = Number(response.headers?.get?.("retry-after"));
|
|
237
|
+
const backoff = Number.isFinite(retryAfter) && retryAfter > 0
|
|
238
|
+
? retryAfter * 1000
|
|
239
|
+
: retryBaseDelayMs * 2 ** (attempt - 1);
|
|
240
|
+
await sleepImpl(Math.min(10000, backoff));
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const providerMessage = String(payload.message || payload.id || "erro")
|
|
245
|
+
.replace(/[\r\n]+/g, " ")
|
|
246
|
+
.slice(0, 300);
|
|
247
|
+
const providerRequestId = String(payload.request_id || "")
|
|
248
|
+
.replace(/[^A-Za-z0-9_.:-]/g, "")
|
|
249
|
+
.slice(0, 120);
|
|
211
250
|
const error = new Error(
|
|
212
|
-
`DigitalOcean API rejeitou ${method} ${path}: HTTP ${response.status} (${
|
|
251
|
+
`DigitalOcean API rejeitou ${method} ${path}: HTTP ${response.status} (${providerMessage})`
|
|
252
|
+
+ (providerRequestId ? ` [requestId: ${providerRequestId}]` : "")
|
|
253
|
+
+ ".",
|
|
213
254
|
);
|
|
214
255
|
error.status = response.status;
|
|
215
256
|
error.payload = payload;
|
|
257
|
+
error.providerRequestId = providerRequestId || undefined;
|
|
216
258
|
throw error;
|
|
217
259
|
}
|
|
218
|
-
|
|
260
|
+
|
|
261
|
+
throw new Error("DigitalOcean API não concluiu a solicitação.");
|
|
219
262
|
}
|
|
220
263
|
|
|
221
264
|
async function ensureDigitalOceanDatabase(options, deps) {
|
|
222
265
|
const path = `/dbs/${encodeURIComponent(options.dbName)}`;
|
|
266
|
+
let exists = false;
|
|
223
267
|
try {
|
|
224
268
|
await digitalOceanRequest(
|
|
225
269
|
{ ...options, path, expected: [200] },
|
|
226
270
|
deps,
|
|
227
271
|
);
|
|
228
|
-
|
|
272
|
+
exists = true;
|
|
229
273
|
} catch (error) {
|
|
230
|
-
if (error.status
|
|
274
|
+
if (error.status === 404) {
|
|
275
|
+
exists = false;
|
|
276
|
+
} else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
|
|
277
|
+
const listed = await digitalOceanRequest(
|
|
278
|
+
{ ...options, path: "/dbs", expected: [200] },
|
|
279
|
+
deps,
|
|
280
|
+
);
|
|
281
|
+
exists = Array.isArray(listed.payload.dbs)
|
|
282
|
+
&& listed.payload.dbs.some((item) => item?.name === options.dbName);
|
|
283
|
+
} else {
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
231
286
|
}
|
|
287
|
+
if (exists) return;
|
|
232
288
|
|
|
233
289
|
await digitalOceanRequest(
|
|
234
290
|
{
|
|
@@ -253,15 +309,28 @@ function digitalOceanUserSettings(dbName) {
|
|
|
253
309
|
|
|
254
310
|
async function provisionDigitalOceanUser(options, deps) {
|
|
255
311
|
const userPath = `/users/${encodeURIComponent(options.username)}`;
|
|
256
|
-
let exists =
|
|
312
|
+
let exists = false;
|
|
257
313
|
try {
|
|
258
314
|
await digitalOceanRequest(
|
|
259
315
|
{ ...options, path: userPath, expected: [200] },
|
|
260
316
|
deps,
|
|
261
317
|
);
|
|
318
|
+
exists = true;
|
|
262
319
|
} catch (error) {
|
|
263
|
-
if (error.status
|
|
264
|
-
|
|
320
|
+
if (error.status === 404) {
|
|
321
|
+
exists = false;
|
|
322
|
+
} else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
|
|
323
|
+
const listed = await digitalOceanRequest(
|
|
324
|
+
{ ...options, path: "/users", expected: [200] },
|
|
325
|
+
deps,
|
|
326
|
+
);
|
|
327
|
+
exists = Array.isArray(listed.payload.users)
|
|
328
|
+
&& listed.payload.users.some(
|
|
329
|
+
(item) => item?.name === options.username,
|
|
330
|
+
);
|
|
331
|
+
} else {
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
265
334
|
}
|
|
266
335
|
|
|
267
336
|
const settings = digitalOceanUserSettings(options.dbName);
|
|
@@ -378,6 +447,8 @@ async function provisionMongo(options, deps = {}) {
|
|
|
378
447
|
module.exports = {
|
|
379
448
|
DIGITALOCEAN_API,
|
|
380
449
|
DIGITALOCEAN_HOST_SUFFIX,
|
|
450
|
+
DIGITALOCEAN_RETRYABLE_STATUS,
|
|
451
|
+
DIGITALOCEAN_SERVER_STATUS,
|
|
381
452
|
mongoIdentity,
|
|
382
453
|
mongoHost,
|
|
383
454
|
resolveMongoProvisioner,
|