@oondemand/oon-core-back 0.7.16 → 0.7.19
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 +6 -0
- package/bin/cli.js +8 -1
- package/bin/delivery.js +1 -0
- package/package.json +1 -1
- package/src/api/access.routes.js +6 -0
- package/src/api/activation.routes.js +2 -19
- package/src/api/activationRateLimit.js +84 -0
- package/src/boot/database.js +45 -12
- package/src/boot/gracefulShutdown.js +64 -0
- package/src/boot/startServer.js +26 -3
- package/src/capabilities/transactionalEmail/models.js +3 -1
- package/src/capabilities/transactionalEmail/worker.js +9 -2
- package/src/core/database/policy.js +17 -1
- package/src/core/middlewares/auth.js +17 -0
- package/src/core/runtime/tenantDataPurge.js +166 -0
- package/src/core/storage/files.js +131 -0
- package/src/core/usage/reporter.js +193 -0
- package/src/core/utils/instanceActivation.js +20 -10
- package/src/delivery/buildImage.js +3 -0
- package/src/delivery/launchRuntime.js +28 -10
- package/src/delivery/runtime.js +133 -6
- package/src/delivery/supervisor.js +15 -4
- package/src/index.js +4 -0
- package/src/manifest/appManifest.js +4 -1
- package/src/manifest/processJobs.js +252 -0
- package/src/manifest/processRuntime.js +52 -4
- package/src/manifest/usageMetrics.js +39 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Exclusão dos dados de uma organização cuja licença do App terminou há mais
|
|
5
|
+
* de 30 dias (F1.5). A Central decide o que é devido; o runtime só executa o
|
|
6
|
+
* que ela entrega, autenticado pela credencial do deployment.
|
|
7
|
+
*
|
|
8
|
+
* Escopos:
|
|
9
|
+
* - `tenant` (App multi-tenant, runtime compartilhado): apaga os documentos
|
|
10
|
+
* com o tenantId da organização nos models com escopo `tenant` ou `user`, e
|
|
11
|
+
* os e-mails transacionais desse tenant. Models `system`/`legacy`, que não
|
|
12
|
+
* identificam a organização, ficam intactos e saem no relatório.
|
|
13
|
+
* - `full` (App single-tenant, runtime dedicado): apaga os documentos de todos
|
|
14
|
+
* os models do App, exceto os de escopo `system` (configuração do App), além
|
|
15
|
+
* dos e-mails transacionais e dos segredos do runtime. O runtime continua no
|
|
16
|
+
* ar para o próximo licenciamento.
|
|
17
|
+
*
|
|
18
|
+
* Só uma réplica executa por vez (trava no MongoDB).
|
|
19
|
+
*/
|
|
20
|
+
const crypto = require("node:crypto");
|
|
21
|
+
const axios = require("axios");
|
|
22
|
+
const mongoose = require("../database/mongoose");
|
|
23
|
+
const registry = require("../registry");
|
|
24
|
+
const ctx = require("../context");
|
|
25
|
+
const { dataScope, tenantField } = require("../tenancy");
|
|
26
|
+
const { getBaseUrl, getTimeout } = require("../utils/activationProvider");
|
|
27
|
+
const { operationalRequestHeaders } = require("../utils/instanceActivation");
|
|
28
|
+
const { isLocalRuntime } = require("./localDevelopment");
|
|
29
|
+
|
|
30
|
+
const PURGES_PATH = "/integracao/runtime/data-purges";
|
|
31
|
+
const LOCKS = "oon_runtime_locks";
|
|
32
|
+
const LOCK_ID = "tenant-data-purge";
|
|
33
|
+
const LOCK_MS = 30 * 60 * 1000;
|
|
34
|
+
const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
|
35
|
+
const EMAIL_DISPATCHES = "oon_email_dispatches";
|
|
36
|
+
const SECRETS = "oon_secrets";
|
|
37
|
+
|
|
38
|
+
const state = { owner: `${process.pid}:${crypto.randomUUID()}`, timer: null, running: false, inFlight: null };
|
|
39
|
+
|
|
40
|
+
function hasField(entry, field) {
|
|
41
|
+
return Boolean(entry?.mongooseModel?.schema?.path?.(field));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function executePurge({ scope, tenantId }, { entries = registry.listModels(), connection = mongoose.connection } = {}) {
|
|
45
|
+
if (!["tenant", "full"].includes(scope)) throw new Error(`Escopo de exclusão inválido: ${scope}`);
|
|
46
|
+
if (scope === "tenant" && !tenantId) throw new Error("Exclusão por organização exige tenantId.");
|
|
47
|
+
const deleted = {};
|
|
48
|
+
const skipped = [];
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const model = entry?.mongooseModel;
|
|
51
|
+
if (!model) continue;
|
|
52
|
+
const kind = dataScope(entry);
|
|
53
|
+
let filter = null;
|
|
54
|
+
if (scope === "full") {
|
|
55
|
+
if (kind !== "system") filter = {};
|
|
56
|
+
} else if (["tenant", "user"].includes(kind) && hasField(entry, tenantField(entry))) {
|
|
57
|
+
filter = { [tenantField(entry)]: tenantId };
|
|
58
|
+
}
|
|
59
|
+
if (!filter) { skipped.push(entry.name); continue; }
|
|
60
|
+
// eslint-disable-next-line no-await-in-loop
|
|
61
|
+
const result = await model.deleteMany(filter);
|
|
62
|
+
deleted[entry.name] = Number(result?.deletedCount || 0);
|
|
63
|
+
}
|
|
64
|
+
const emails = connection.collection(EMAIL_DISPATCHES);
|
|
65
|
+
const emailFilter = scope === "full" ? {} : { scopeKey: { $regex: `:tenant:${String(tenantId).replace(/[^A-Za-z0-9_-]/g, "")}$` } };
|
|
66
|
+
deleted.emailDispatches = Number((await emails.deleteMany(emailFilter))?.deletedCount || 0);
|
|
67
|
+
if (scope === "full") {
|
|
68
|
+
deleted.secrets = Number((await connection.collection(SECRETS).deleteMany({}))?.deletedCount || 0);
|
|
69
|
+
}
|
|
70
|
+
return { deleted, skipped };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function acquireLock(connection) {
|
|
74
|
+
const locks = connection.collection(LOCKS);
|
|
75
|
+
const expiresAt = new Date(Date.now() + LOCK_MS);
|
|
76
|
+
try {
|
|
77
|
+
await locks.insertOne({ _id: LOCK_ID, owner: state.owner, expiresAt });
|
|
78
|
+
return true;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error?.code !== 11000) throw error;
|
|
81
|
+
}
|
|
82
|
+
const result = await locks.updateOne({ _id: LOCK_ID, expiresAt: { $lte: new Date() } }, { $set: { owner: state.owner, expiresAt } });
|
|
83
|
+
return Number(result?.modifiedCount || 0) > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function releaseLock(connection) {
|
|
87
|
+
await connection.collection(LOCKS).deleteOne({ _id: LOCK_ID, owner: state.owner });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runOnce({
|
|
91
|
+
http = axios,
|
|
92
|
+
connection = mongoose.connection,
|
|
93
|
+
headers = () => operationalRequestHeaders({}),
|
|
94
|
+
baseUrl = () => getBaseUrl(),
|
|
95
|
+
execute = executePurge,
|
|
96
|
+
log = console,
|
|
97
|
+
} = {}) {
|
|
98
|
+
if (connection?.readyState !== 1) return { skipped: "database_unavailable" };
|
|
99
|
+
if (!await acquireLock(connection)) return { skipped: "locked" };
|
|
100
|
+
try {
|
|
101
|
+
const requestHeaders = await headers();
|
|
102
|
+
const base = baseUrl();
|
|
103
|
+
const response = await http.get(`${base}${PURGES_PATH}`, { headers: requestHeaders, timeout: getTimeout(), maxRedirects: 0 });
|
|
104
|
+
const purges = Array.isArray(response?.data?.purges) ? response.data.purges : [];
|
|
105
|
+
const results = [];
|
|
106
|
+
for (const purge of purges) {
|
|
107
|
+
let body;
|
|
108
|
+
try {
|
|
109
|
+
// eslint-disable-next-line no-await-in-loop
|
|
110
|
+
const report = await execute({ scope: purge.scope, tenantId: purge.tenantId }, { connection });
|
|
111
|
+
body = { ok: true, report };
|
|
112
|
+
log.log(`[OonCore] Dados da organização ${purge.tenantId} excluídos (${purge.scope}).`);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
body = { ok: false, error: String(error?.message || error).slice(0, 500) };
|
|
115
|
+
log.error(`[OonCore] Falha ao excluir dados da organização ${purge.tenantId}: ${body.error}`);
|
|
116
|
+
}
|
|
117
|
+
// eslint-disable-next-line no-await-in-loop
|
|
118
|
+
await http.post(`${base}${PURGES_PATH}/${encodeURIComponent(purge.id)}/complete`, body, { headers: requestHeaders, timeout: getTimeout(), maxRedirects: 0 });
|
|
119
|
+
results.push({ id: purge.id, ok: body.ok });
|
|
120
|
+
}
|
|
121
|
+
return { processed: results };
|
|
122
|
+
} finally {
|
|
123
|
+
await releaseLock(connection);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function intervalMs() {
|
|
128
|
+
const value = Number(process.env.OON_DATA_PURGE_INTERVAL_MS);
|
|
129
|
+
return Number.isSafeInteger(value) && value >= 60000 ? value : DEFAULT_INTERVAL_MS;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function enabled() {
|
|
133
|
+
if (isLocalRuntime()) return false;
|
|
134
|
+
if (String(process.env.OON_DATA_PURGE || "true").toLowerCase() === "false") return false;
|
|
135
|
+
// A própria Central (root) e Apps globais não recebem licenças de organizações.
|
|
136
|
+
if (ctx.config.ecosystem?.role === "root") return false;
|
|
137
|
+
const tenancy = ctx.config.tenancyModel || ctx.config.ecosystem?.tenancyModel;
|
|
138
|
+
return tenancy !== "none";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function schedule(delay) {
|
|
142
|
+
if (!state.running) return;
|
|
143
|
+
state.timer = setTimeout(() => {
|
|
144
|
+
state.inFlight = runOnce()
|
|
145
|
+
.catch((error) => console.warn(`[OonCore] Verificação de exclusões de dados falhou: ${error?.message || error}`))
|
|
146
|
+
.finally(() => { state.inFlight = null; schedule(intervalMs()); });
|
|
147
|
+
}, delay);
|
|
148
|
+
state.timer.unref?.();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startTenantDataPurges() {
|
|
152
|
+
if (state.running || !enabled()) return false;
|
|
153
|
+
state.running = true;
|
|
154
|
+
// Primeira verificação alguns minutos após o boot, com variação entre réplicas.
|
|
155
|
+
schedule(120000 + Math.floor(Math.random() * 60000));
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function stopTenantDataPurges() {
|
|
160
|
+
state.running = false;
|
|
161
|
+
clearTimeout(state.timer);
|
|
162
|
+
state.timer = null;
|
|
163
|
+
if (state.inFlight) await state.inFlight;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { executePurge, runOnce, startTenantDataPurges, stopTenantDataPurges, PURGES_PATH, LOCKS };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* F4.8 — Armazenamento compartilhado de arquivos.
|
|
5
|
+
*
|
|
6
|
+
* Substitui a pasta local `uploads` (que é de cada pod e some no reinício) por
|
|
7
|
+
* GridFS no MongoDB do próprio App: um arquivo gravado por uma réplica é lido
|
|
8
|
+
* por qualquer outra e sobrevive a reinícios e novas publicações. Cada arquivo
|
|
9
|
+
* pertence a um tenant; leitura, listagem e exclusão exigem o mesmo tenant,
|
|
10
|
+
* salvo quando o chamador declara `global: true` (Apps sem tenancy).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { Readable } = require("node:stream");
|
|
14
|
+
const mongoose = require("../database/mongoose");
|
|
15
|
+
|
|
16
|
+
const BUCKET = "oon_files";
|
|
17
|
+
const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
function storageError(message, code, statusCode) {
|
|
20
|
+
return Object.assign(new Error(message), { code, statusCode });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bucket(connection = mongoose.connection) {
|
|
24
|
+
if (!connection?.db) throw storageError("Banco de dados indisponível para arquivos.", "FILE_STORAGE_UNAVAILABLE", 503);
|
|
25
|
+
return new mongoose.mongo.GridFSBucket(connection.db, { bucketName: BUCKET });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function scope({ tenantId, global = false } = {}) {
|
|
29
|
+
if (global === true) return { global: true, tenantId: null };
|
|
30
|
+
const value = tenantId === undefined || tenantId === null ? "" : String(tenantId);
|
|
31
|
+
if (!value) throw storageError("Tenant obrigatório para arquivos.", "FILE_TENANT_REQUIRED", 400);
|
|
32
|
+
return { global: false, tenantId: value };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function objectId(id) {
|
|
36
|
+
try { return new mongoose.Types.ObjectId(String(id)); } catch { throw storageError("Arquivo não encontrado.", "FILE_NOT_FOUND", 404); }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function info(doc) {
|
|
40
|
+
return { id: String(doc._id), filename: doc.filename, length: doc.length, contentType: doc.metadata?.contentType || "application/octet-stream",
|
|
41
|
+
tenantId: doc.metadata?.tenantId || null, metadata: doc.metadata?.custom || {}, uploadDate: doc.uploadDate };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Grava um arquivo (Buffer, string ou Readable) e devolve os metadados. */
|
|
45
|
+
async function saveFile({ data, filename, contentType = "application/octet-stream", metadata = {}, maxBytes = DEFAULT_MAX_BYTES, ...owner } = {}, options = {}) {
|
|
46
|
+
const target = scope(owner);
|
|
47
|
+
const name = String(filename || "").trim();
|
|
48
|
+
if (!name || name.length > 255 || /[\\/\0]/.test(name)) throw storageError("Nome de arquivo inválido.", "FILE_NAME_INVALID", 422);
|
|
49
|
+
const source = data instanceof Readable ? data : Readable.from(Buffer.isBuffer(data) ? [data] : [Buffer.from(String(data ?? ""))]);
|
|
50
|
+
const upload = bucket(options.connection).openUploadStream(name, { metadata: { tenantId: target.tenantId, contentType: String(contentType), custom: metadata } });
|
|
51
|
+
let size = 0;
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
const fail = (error) => { source.destroy(); upload.abort().catch(() => {}).finally(() => reject(error)); };
|
|
54
|
+
source.on("data", (chunk) => {
|
|
55
|
+
size += chunk.length;
|
|
56
|
+
if (size > maxBytes) return fail(storageError(`Arquivo maior que o limite de ${maxBytes} bytes.`, "FILE_TOO_LARGE", 413));
|
|
57
|
+
if (!upload.write(chunk)) source.pause();
|
|
58
|
+
});
|
|
59
|
+
upload.on("drain", () => source.resume());
|
|
60
|
+
source.on("error", fail);
|
|
61
|
+
upload.on("error", reject);
|
|
62
|
+
upload.on("finish", resolve);
|
|
63
|
+
source.on("end", () => { if (size <= maxBytes) upload.end(); });
|
|
64
|
+
});
|
|
65
|
+
return getFileInfo(upload.id, target, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function findScoped(id, owner, options) {
|
|
69
|
+
const target = scope(owner);
|
|
70
|
+
const [doc] = await bucket(options.connection).find({ _id: objectId(id), ...(target.global ? {} : { "metadata.tenantId": target.tenantId }) }).limit(1).toArray();
|
|
71
|
+
if (!doc) throw storageError("Arquivo não encontrado.", "FILE_NOT_FOUND", 404);
|
|
72
|
+
return doc;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getFileInfo(id, owner = {}, options = {}) {
|
|
76
|
+
return info(await findScoped(id, owner, options));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Abre o conteúdo para leitura em stream, no escopo do tenant. */
|
|
80
|
+
async function openFile(id, owner = {}, options = {}) {
|
|
81
|
+
const doc = await findScoped(id, owner, options);
|
|
82
|
+
return { file: info(doc), stream: bucket(options.connection).openDownloadStream(doc._id) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readFile(id, owner = {}, options = {}) {
|
|
86
|
+
const { file, stream } = await openFile(id, owner, options);
|
|
87
|
+
const chunks = [];
|
|
88
|
+
for await (const chunk of stream) chunks.push(chunk);
|
|
89
|
+
return { file, data: Buffer.concat(chunks) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function listFiles(owner = {}, { limit = 100, filename } = {}, options = {}) {
|
|
93
|
+
const target = scope(owner);
|
|
94
|
+
const filter = { ...(target.global ? {} : { "metadata.tenantId": target.tenantId }), ...(filename ? { filename: String(filename) } : {}) };
|
|
95
|
+
const docs = await bucket(options.connection).find(filter).sort({ uploadDate: -1 }).limit(Math.min(Math.max(1, Number(limit) || 100), 500)).toArray();
|
|
96
|
+
return docs.map(info);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function deleteFile(id, owner = {}, options = {}) {
|
|
100
|
+
const doc = await findScoped(id, owner, options);
|
|
101
|
+
await bucket(options.connection).delete(doc._id);
|
|
102
|
+
return { deleted: true, id: String(doc._id) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Responde a requisição com o arquivo (download ou exibição). */
|
|
106
|
+
async function sendFile(res, id, owner = {}, { download = false } = {}, options = {}) {
|
|
107
|
+
const { file, stream } = await openFile(id, owner, options);
|
|
108
|
+
res.setHeader("Content-Type", file.contentType);
|
|
109
|
+
res.setHeader("Content-Length", String(file.length));
|
|
110
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
111
|
+
res.setHeader("Content-Disposition", `${download ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(file.filename)}`);
|
|
112
|
+
await new Promise((resolve, reject) => { stream.on("error", reject); res.on("finish", resolve); stream.pipe(res); });
|
|
113
|
+
return file;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Atalho com o tenant do request autenticado (req.accessContext). */
|
|
117
|
+
function forRequest(req) {
|
|
118
|
+
const tenantId = req?.accessContext?.tenantId;
|
|
119
|
+
const owner = tenantId ? { tenantId } : { global: true };
|
|
120
|
+
return {
|
|
121
|
+
save: (input) => saveFile({ ...input, ...owner }),
|
|
122
|
+
info: (id) => getFileInfo(id, owner),
|
|
123
|
+
open: (id) => openFile(id, owner),
|
|
124
|
+
read: (id) => readFile(id, owner),
|
|
125
|
+
list: (query) => listFiles(owner, query),
|
|
126
|
+
remove: (id) => deleteFile(id, owner),
|
|
127
|
+
send: (res, id, opts) => sendFile(res, id, owner, opts),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { BUCKET, DEFAULT_MAX_BYTES, saveFile, getFileInfo, openFile, readFile, listFiles, deleteFile, sendFile, forRequest };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* F5.3/F5.5 — consumo do App para os limites de uso do plano.
|
|
5
|
+
*
|
|
6
|
+
* `usage.record(metric, quantidade, { tenantId })` acumula em memória e, a
|
|
7
|
+
* cada `OON_USAGE_FLUSH_MS` (padrão 15 s), fecha um lote com id único. O lote
|
|
8
|
+
* é gravado na outbox `oon_usage_outbox` do MongoDB do App antes do envio,
|
|
9
|
+
* então sobrevive a reinício e qualquer réplica pode reenviá-lo. A Central
|
|
10
|
+
* conta cada id uma vez só, mesmo se o lote for reenviado.
|
|
11
|
+
*
|
|
12
|
+
* `usage.assertAvailable(metric, { tenantId })` recusa a operação (423
|
|
13
|
+
* USAGE_LIMIT_REACHED) quando o plano bloqueia ao atingir o limite. O estado
|
|
14
|
+
* vem da Central (resposta do lote ou consulta, cache de 60 s) somado ao que
|
|
15
|
+
* ainda não foi enviado. Se a Central não responde, a operação segue: o
|
|
16
|
+
* consumo continua registrado e o limite é aplicado quando ela voltar.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require("node:crypto");
|
|
20
|
+
const axios = require("axios");
|
|
21
|
+
const mongoose = require("../database/mongoose");
|
|
22
|
+
const { operationalRequestHeaders } = require("../utils/instanceActivation");
|
|
23
|
+
const { getBaseUrl, getTimeout } = require("../utils/activationProvider");
|
|
24
|
+
const { isLocalRuntime } = require("../runtime/localDevelopment");
|
|
25
|
+
|
|
26
|
+
const METRIC = /^[a-z][a-z0-9_.-]{1,62}$/;
|
|
27
|
+
const COLLECTION = "oon_usage_outbox";
|
|
28
|
+
const STATE_TTL_MS = 60 * 1000;
|
|
29
|
+
const LEASE_MS = 30 * 1000;
|
|
30
|
+
const MAX_ATTEMPTS = 20;
|
|
31
|
+
const owner = `${process.pid}:${crypto.randomUUID()}`;
|
|
32
|
+
|
|
33
|
+
const buffers = new Map(); // tenantId -> Map(metric -> quantity)
|
|
34
|
+
const memoryOutbox = []; // sem banco conectado
|
|
35
|
+
const states = new Map(); // tenantId -> { at, metrics: Map }
|
|
36
|
+
let timer = null;
|
|
37
|
+
let flushing = null;
|
|
38
|
+
|
|
39
|
+
function flushInterval() {
|
|
40
|
+
const value = Number(process.env.OON_USAGE_FLUSH_MS);
|
|
41
|
+
return Number.isSafeInteger(value) && value >= 1000 ? value : 15000;
|
|
42
|
+
}
|
|
43
|
+
function usageError(message, code, statusCode, details) {
|
|
44
|
+
return Object.assign(new Error(message), { code, statusCode, ...(details ? { details } : {}) });
|
|
45
|
+
}
|
|
46
|
+
function collection() {
|
|
47
|
+
return mongoose.connection?.readyState === 1 && mongoose.connection.db ? mongoose.connection.db.collection(COLLECTION) : null;
|
|
48
|
+
}
|
|
49
|
+
function key(tenantId) {
|
|
50
|
+
const value = tenantId === undefined || tenantId === null ? "" : String(tenantId);
|
|
51
|
+
if (!value) throw usageError("Tenant obrigatório para registrar consumo.", "USAGE_TENANT_REQUIRED", 400);
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function ensureTimer() {
|
|
55
|
+
if (timer) return;
|
|
56
|
+
timer = setInterval(() => { flush().catch(() => {}); }, flushInterval());
|
|
57
|
+
timer.unref?.();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Registra consumo (acumulado e enviado em lote). */
|
|
61
|
+
function record(metric, quantity = 1, { tenantId } = {}) {
|
|
62
|
+
if (!METRIC.test(String(metric || ""))) throw usageError("Métrica de uso inválida.", "USAGE_METRIC_INVALID", 422, { metric });
|
|
63
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1) throw usageError("Quantidade de uso inválida.", "USAGE_QUANTITY_INVALID", 422);
|
|
64
|
+
if (isLocalRuntime()) return { recorded: false, reason: "local" };
|
|
65
|
+
const tenant = key(tenantId);
|
|
66
|
+
const bucket = buffers.get(tenant) || new Map();
|
|
67
|
+
bucket.set(metric, (bucket.get(metric) || 0) + quantity);
|
|
68
|
+
buffers.set(tenant, bucket);
|
|
69
|
+
ensureTimer();
|
|
70
|
+
return { recorded: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function pendingFor(tenantId, metric) {
|
|
74
|
+
return buffers.get(String(tenantId))?.get(metric) || 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Fecha os lotes em memória, grava na outbox e envia os pendentes. */
|
|
78
|
+
async function flush() {
|
|
79
|
+
if (flushing) return flushing;
|
|
80
|
+
flushing = (async () => {
|
|
81
|
+
const at = new Date();
|
|
82
|
+
for (const [tenantId, bucket] of [...buffers.entries()]) {
|
|
83
|
+
buffers.delete(tenantId);
|
|
84
|
+
const items = [...bucket.entries()].map(([metric, quantity]) => ({ metric, quantity }));
|
|
85
|
+
if (!items.length) continue;
|
|
86
|
+
const batch = { _id: crypto.randomUUID(), tenantId, items, status: "pending", attempts: 0, nextAttemptAt: at, createdAt: at };
|
|
87
|
+
const outbox = collection();
|
|
88
|
+
if (outbox) await outbox.insertOne(batch); else memoryOutbox.push(batch);
|
|
89
|
+
}
|
|
90
|
+
return deliverPending();
|
|
91
|
+
})().finally(() => { flushing = null; });
|
|
92
|
+
return flushing;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function send(batch) {
|
|
96
|
+
const response = await axios.post(`${getBaseUrl()}/integracao/app-usage/batches`,
|
|
97
|
+
{ tenantId: batch.tenantId, batchId: batch._id, items: batch.items },
|
|
98
|
+
{ headers: await operationalRequestHeaders({ tenantId: batch.tenantId, correlationId: `usage:${batch._id}` }), timeout: getTimeout(), maxRedirects: 0 });
|
|
99
|
+
remember(batch.tenantId, response.data);
|
|
100
|
+
return response.data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function retryable(error) {
|
|
104
|
+
const status = error?.response?.status;
|
|
105
|
+
return !status || status >= 500 || status === 429 || status === 408;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function deliverPending({ limit = 20 } = {}) {
|
|
109
|
+
const outbox = collection();
|
|
110
|
+
let sent = 0, failed = 0;
|
|
111
|
+
if (!outbox) {
|
|
112
|
+
for (const batch of memoryOutbox.splice(0, limit)) {
|
|
113
|
+
try { await send(batch); sent++; } catch (error) { failed++; if (retryable(error)) memoryOutbox.push(batch); }
|
|
114
|
+
}
|
|
115
|
+
return { sent, failed };
|
|
116
|
+
}
|
|
117
|
+
for (let i = 0; i < limit; i++) {
|
|
118
|
+
const now = new Date();
|
|
119
|
+
const batch = await outbox.findOneAndUpdate(
|
|
120
|
+
{ status: "pending", nextAttemptAt: { $lte: now }, $or: [{ leaseUntil: { $exists: false } }, { leaseUntil: { $lt: now } }] },
|
|
121
|
+
{ $set: { leaseUntil: new Date(+now + LEASE_MS), leaseOwner: owner } }, { sort: { nextAttemptAt: 1 }, returnDocument: "after" });
|
|
122
|
+
const doc = batch?.value === undefined ? batch : batch.value;
|
|
123
|
+
if (!doc) break;
|
|
124
|
+
try {
|
|
125
|
+
await send(doc);
|
|
126
|
+
await outbox.deleteOne({ _id: doc._id, leaseOwner: owner });
|
|
127
|
+
sent++;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
failed++;
|
|
130
|
+
const attempts = (doc.attempts || 0) + 1;
|
|
131
|
+
const terminal = !retryable(error) || attempts >= MAX_ATTEMPTS;
|
|
132
|
+
await outbox.updateOne({ _id: doc._id, leaseOwner: owner }, {
|
|
133
|
+
$set: { attempts, status: terminal ? "rejected" : "pending", nextAttemptAt: new Date(Date.now() + Math.min(3600000, 2 ** attempts * 1000)),
|
|
134
|
+
lastError: { status: error?.response?.status || null, code: error?.response?.data?.code || error?.code || null, at: new Date() } },
|
|
135
|
+
$unset: { leaseUntil: 1, leaseOwner: 1 } });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { sent, failed };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function remember(tenantId, data) {
|
|
142
|
+
if (!data || !Array.isArray(data.metrics)) return;
|
|
143
|
+
states.set(String(tenantId), { at: Date.now(), metrics: new Map(data.metrics.map((m) => [m.metric, m])) });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function state(tenantId, { refresh = false } = {}) {
|
|
147
|
+
const tenant = key(tenantId);
|
|
148
|
+
const cached = states.get(tenant);
|
|
149
|
+
if (!refresh && cached && Date.now() - cached.at < STATE_TTL_MS) return cached;
|
|
150
|
+
const response = await axios.get(`${getBaseUrl()}/integracao/app-usage/state`, {
|
|
151
|
+
params: { tenantId: tenant }, headers: await operationalRequestHeaders({ tenantId: tenant, correlationId: `usage-state:${crypto.randomUUID()}` }),
|
|
152
|
+
timeout: getTimeout(), maxRedirects: 0 });
|
|
153
|
+
remember(tenant, response.data);
|
|
154
|
+
return states.get(tenant) || { at: Date.now(), metrics: new Map() };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Recusa a operação quando a métrica bloqueia ao atingir o limite do ciclo. */
|
|
158
|
+
async function assertAvailable(metric, { tenantId, quantity = 1 } = {}) {
|
|
159
|
+
if (isLocalRuntime()) return { available: true, reason: "local" };
|
|
160
|
+
let current;
|
|
161
|
+
try { current = await state(tenantId); } catch { return { available: true, reason: "central_unavailable" }; }
|
|
162
|
+
const limit = current.metrics.get(metric);
|
|
163
|
+
if (!limit || limit.onLimit !== "block") return { available: true };
|
|
164
|
+
const used = (limit.used || 0) + pendingFor(tenantId, metric);
|
|
165
|
+
if (used + quantity > limit.included) {
|
|
166
|
+
throw usageError(`O limite de ${limit.label || metric} do plano foi atingido neste ciclo (${limit.included}). O uso volta no próximo ciclo ou com a troca de plano.`,
|
|
167
|
+
"USAGE_LIMIT_REACHED", 423, { metric, used, included: limit.included, periodEndAt: limit.periodEndAt || null });
|
|
168
|
+
}
|
|
169
|
+
return { available: true, remaining: limit.included - used };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Middleware: confere o limite antes e registra 1 uso quando a resposta for 2xx. */
|
|
173
|
+
function limit(metric, { quantity = 1 } = {}) {
|
|
174
|
+
return async (req, res, next) => {
|
|
175
|
+
const tenantId = req.accessContext?.tenantId;
|
|
176
|
+
try {
|
|
177
|
+
await assertAvailable(metric, { tenantId, quantity });
|
|
178
|
+
res.on("finish", () => { if (res.statusCode >= 200 && res.statusCode < 300) { try { record(metric, quantity, { tenantId }); } catch { /* sem tenant */ } } });
|
|
179
|
+
return next();
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return res.status(error.statusCode || 500).json({ code: error.code, message: error.message, details: error.details });
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function stop() {
|
|
187
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
188
|
+
if (buffers.size) await flush().catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function reset() { buffers.clear(); memoryOutbox.length = 0; states.clear(); if (timer) { clearInterval(timer); timer = null; } }
|
|
192
|
+
|
|
193
|
+
module.exports = { COLLECTION, record, flush, deliverPending, state, assertAvailable, limit, stop, reset };
|
|
@@ -284,16 +284,26 @@ async function ensureInstance() {
|
|
|
284
284
|
);
|
|
285
285
|
|
|
286
286
|
if (!instance) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
287
|
+
try {
|
|
288
|
+
instance = await Instancia.create({
|
|
289
|
+
ecosystemRole: role(),
|
|
290
|
+
status: "nao_ativada",
|
|
291
|
+
appCode: getAppCode(),
|
|
292
|
+
ambiente: ambiente(),
|
|
293
|
+
ecosystemUrl: process.env.CENTRAL_ATIVACAO_URL,
|
|
294
|
+
publicUrl: process.env.PUBLIC_APP_URL,
|
|
295
|
+
coreVersion: version(),
|
|
296
|
+
appVersion: version(),
|
|
297
|
+
});
|
|
298
|
+
} catch (error) {
|
|
299
|
+
// Outra réplica criou a instância entre o findOne e o create; o índice
|
|
300
|
+
// único de singletonKey impede a duplicidade, então basta reler.
|
|
301
|
+
if (error?.code !== 11000) throw error;
|
|
302
|
+
instance = await Instancia.findOne().select(
|
|
303
|
+
"+credentialEncrypted +credentialIv +credentialAuthTag +activationRequest",
|
|
304
|
+
);
|
|
305
|
+
if (!instance) throw error;
|
|
306
|
+
}
|
|
297
307
|
}
|
|
298
308
|
|
|
299
309
|
if (instance.ecosystemRole !== role()) {
|
|
@@ -32,6 +32,7 @@ COPY --from=frontend /src/frontend/dist /usr/share/nginx/html
|
|
|
32
32
|
COPY .oon-delivery/nginx.conf /etc/nginx/oon.conf
|
|
33
33
|
COPY .oon-delivery/supervisor.js /supervisor.js
|
|
34
34
|
COPY .oon-delivery/launchRuntime.js .oon-delivery/validateImage.js /oon-delivery/
|
|
35
|
+
COPY .oon-delivery/errors.js /deploy/contracts/errors.js
|
|
35
36
|
COPY .oon-delivery/entrypoint.sh /entrypoint.sh
|
|
36
37
|
RUN chmod +x /entrypoint.sh && mkdir -p /app/backend/uploads /run/nginx && chown -R node:node /app/backend/uploads
|
|
37
38
|
EXPOSE 8080
|
|
@@ -111,6 +112,8 @@ function prepareDeliveryFiles(workspace) {
|
|
|
111
112
|
fs.mkdirSync(target, { recursive: true });
|
|
112
113
|
prepareAppManifest(workspace, target);
|
|
113
114
|
for (const file of ["supervisor.js", "launchRuntime.js", "validateImage.js"]) fs.copyFileSync(path.join(__dirname, file), path.join(target, file));
|
|
115
|
+
// launchRuntime.js requires ../deploy/contracts/errors (sanitizeMessage).
|
|
116
|
+
fs.copyFileSync(path.join(__dirname, "../deploy/contracts/errors.js"), path.join(target, "errors.js"));
|
|
114
117
|
fs.writeFileSync(path.join(target, "Dockerfile"), DOCKERFILE);
|
|
115
118
|
fs.writeFileSync(path.join(target, "nginx.conf"), NGINX);
|
|
116
119
|
fs.writeFileSync(path.join(target, "entrypoint.sh"), ENTRYPOINT, {
|
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const { createRequire } = require("node:module");
|
|
3
3
|
const { classify } = require("./validateImage");
|
|
4
|
-
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
// Copied next to this file in the image (/deploy/contracts/errors.js) so the
|
|
5
|
+
// same relative path resolves in the repository and inside the image.
|
|
6
|
+
const { sanitizeMessage } = require("../deploy/contracts/errors");
|
|
7
|
+
|
|
8
|
+
// Manifest errors (e.g. AppManifestError) carry the actionable detail in
|
|
9
|
+
// issues. Print only path and sanitized message; never the stack or raw error.
|
|
10
|
+
function startupIssueLines(error) {
|
|
11
|
+
if (!Array.isArray(error?.issues)) return [];
|
|
12
|
+
return error.issues.map((issue) => ` - ${sanitizeMessage(issue?.path || "$")}: ${sanitizeMessage(issue?.message)}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function launch() {
|
|
16
|
+
const runtimeRequire = createRequire("/app/backend/package.json");
|
|
17
|
+
// Invoke the public API of the SDK INSIDE the image; do not replace the app's
|
|
18
|
+
// dependency version. Include the error class so SyntaxError is not lost by an
|
|
19
|
+
// older CLI that prints only error.message. Never print the raw error/stack.
|
|
20
|
+
Promise.resolve().then(() => runtimeRequire("@oondemand/oon-core-back").start({ cwd: "/app/backend" }))
|
|
21
|
+
.catch((error) => {
|
|
22
|
+
for (const line of startupIssueLines(error)) console.error(line);
|
|
23
|
+
const diagnostic = classify(`${error?.name || "Error"}: ${error?.message || ""}`);
|
|
24
|
+
console.error(`OON_STARTUP_FAILURE ${JSON.stringify(diagnostic)}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (require.main === module) launch();
|
|
30
|
+
|
|
31
|
+
module.exports = { startupIssueLines };
|