@oondemand/oon-core-back 0.7.17 → 0.7.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
- instance = await Instancia.create({
288
- ecosystemRole: role(),
289
- status: "nao_ativada",
290
- appCode: getAppCode(),
291
- ambiente: ambiente(),
292
- ecosystemUrl: process.env.CENTRAL_ATIVACAO_URL,
293
- publicUrl: process.env.PUBLIC_APP_URL,
294
- coreVersion: version(),
295
- appVersion: version(),
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()) {
@@ -38,7 +38,40 @@ function readSecret(namespace, name, key) {
38
38
  } catch { return null; }
39
39
  }
40
40
 
41
+ // Piso da cota do namespace (valores anteriores, fixos para todo App).
42
+ const QUOTA_FLOOR = Object.freeze({ requestsCpuMilli: 2000, requestsMemoryMi: 2048, limitsCpuMilli: 4000, limitsMemoryMi: 4096, pods: 20 });
43
+
44
+ function cpuMilli(value) {
45
+ const text = String(value);
46
+ return text.endsWith("m") ? Number(text.slice(0, -1)) : Number(text) * 1000;
47
+ }
48
+
49
+ function memoryMi(value) {
50
+ const text = String(value);
51
+ if (text.endsWith("Gi")) return Number(text.slice(0, -2)) * 1024;
52
+ if (text.endsWith("Mi")) return Number(text.slice(0, -2));
53
+ throw new Error(`Unidade de memória não suportada: ${text}`);
54
+ }
55
+
56
+ /**
57
+ * Cota do namespace calculada pelas réplicas e pelo perfil de recursos, com
58
+ * espaço para o pod extra do rolling update (maxSurge 1). Nunca fica abaixo
59
+ * do piso anterior, então Apps de 1 réplica mantêm a cota de sempre.
60
+ */
61
+ function namespaceQuota({ replicas = 1, profile = "small" } = {}) {
62
+ const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
63
+ const pods = replicas + 1;
64
+ return {
65
+ requestsCpu: `${Math.max(QUOTA_FLOOR.requestsCpuMilli, pods * cpuMilli(resources.cpuRequest))}m`,
66
+ requestsMemory: `${Math.max(QUOTA_FLOOR.requestsMemoryMi, pods * memoryMi(resources.memoryRequest))}Mi`,
67
+ limitsCpu: `${Math.max(QUOTA_FLOOR.limitsCpuMilli, pods * cpuMilli(resources.cpuLimit))}m`,
68
+ limitsMemory: `${Math.max(QUOTA_FLOOR.limitsMemoryMi, pods * memoryMi(resources.memoryLimit))}Mi`,
69
+ pods: Math.max(QUOTA_FLOOR.pods, pods),
70
+ };
71
+ }
72
+
41
73
  function baselineManifest({ context }) {
74
+ const quota = namespaceQuota({ replicas: replicaCount(context), profile: context.resourceProfile });
42
75
  return `apiVersion: v1
43
76
  kind: Namespace
44
77
  metadata:
@@ -66,7 +99,7 @@ apiVersion: v1
66
99
  kind: ResourceQuota
67
100
  metadata: { name: oon-runtime-quota, namespace: ${context.namespace} }
68
101
  spec:
69
- hard: { requests.cpu: "2", requests.memory: 2Gi, limits.cpu: "4", limits.memory: 4Gi, pods: "20", services: "10" }
102
+ hard: { requests.cpu: "${quota.requestsCpu}", requests.memory: ${quota.requestsMemory}, limits.cpu: "${quota.limitsCpu}", limits.memory: ${quota.limitsMemory}, pods: "${quota.pods}", services: "10" }
70
103
  ---
71
104
  apiVersion: networking.k8s.io/v1
72
105
  kind: NetworkPolicy
@@ -83,8 +116,74 @@ spec:
83
116
  `;
84
117
  }
85
118
 
119
+ // Cada slot contratado é uma réplica. O teto acompanha a cota de pods do
120
+ // namespace (baselineManifest), com folga para o pod extra do rolling update.
121
+ const MAX_REPLICAS = 10;
122
+ // preStop + OON_SHUTDOWN_GRACE_MS do supervisor (25 s) cabem neste prazo.
123
+ const PRE_STOP_SECONDS = 5;
124
+ const TERMINATION_GRACE_SECONDS = 40;
125
+
126
+ function replicaCount(context = {}) {
127
+ const value = context.replicas ?? 1;
128
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_REPLICAS) {
129
+ throw new Error(`Número de réplicas inválido no contexto de delivery: ${JSON.stringify(value)} (esperado 1 a ${MAX_REPLICAS}).`);
130
+ }
131
+ return value;
132
+ }
133
+
134
+ // F4.2: modo de performance do ambiente (pool MongoDB 4, 8 ou 16).
135
+ const PERFORMANCE_MODES = ["min", "normal", "ultra"];
136
+ function performanceMode(context = {}) {
137
+ const value = context.performanceMode ?? "min";
138
+ if (!PERFORMANCE_MODES.includes(value)) {
139
+ throw new Error(`Modo de performance inválido no contexto de delivery: ${JSON.stringify(value)} (esperado ${PERFORMANCE_MODES.join(", ")}).`);
140
+ }
141
+ return value;
142
+ }
143
+
144
+ // F4.6: domínios personalizados do App (raiz e www), já com DNS aprovado na
145
+ // Central. Ficam num Ingress próprio, com certificado HTTP-01 do cert-manager
146
+ // (renovação automática), para que um domínio com problema não afete o host
147
+ // da plataforma.
148
+ const MAX_CUSTOM_DOMAINS = 10;
149
+ const HOSTNAME = /^(?=.{4,253}$)(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
150
+ function customDomains(context = {}) {
151
+ const value = context.customDomains ?? [];
152
+ if (!Array.isArray(value) || value.length > MAX_CUSTOM_DOMAINS
153
+ || value.some((host) => typeof host !== "string" || !HOSTNAME.test(host) || host === context.hostname)
154
+ || new Set(value).size !== value.length) {
155
+ throw new Error(`Domínios personalizados inválidos no contexto de delivery: ${JSON.stringify(value)}.`);
156
+ }
157
+ return value;
158
+ }
159
+ function customDomainIngress({ context, infra }) {
160
+ const hosts = customDomains(context);
161
+ if (!hosts.length) return "";
162
+ return `---
163
+ apiVersion: networking.k8s.io/v1
164
+ kind: Ingress
165
+ metadata:
166
+ name: ${context.appCode}-custom
167
+ namespace: ${context.namespace}
168
+ annotations: { cert-manager.io/cluster-issuer: ${infra.CUSTOM_DOMAIN_CLUSTER_ISSUER || "letsencrypt-production-http01"} }
169
+ spec:
170
+ ingressClassName: ${infra.INGRESS_CLASS}
171
+ tls: [{ hosts: [${hosts.join(", ")}], secretName: ${context.appCode}-custom-tls }]
172
+ rules:
173
+ ${hosts.map((host) => ` - host: ${host}
174
+ http:
175
+ paths:
176
+ - path: /
177
+ pathType: Prefix
178
+ backend:
179
+ service: { name: ${context.appCode}, port: { name: http } }`).join("\n")}
180
+ `;
181
+ }
182
+
86
183
  function runtimeManifest({ context, infra, imageRef, profile = "small" }) {
87
184
  const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
185
+ const replicas = replicaCount(context);
186
+ const mode = performanceMode(context);
88
187
  const releaseLabel = kubernetesLabelValue(context.releaseId);
89
188
  const releaseAnnotation = JSON.stringify(String(context.releaseId));
90
189
  return `apiVersion: apps/v1
@@ -93,7 +192,10 @@ metadata:
93
192
  name: ${context.appCode}
94
193
  namespace: ${context.namespace}
95
194
  spec:
96
- replicas: 1
195
+ replicas: ${replicas}
196
+ strategy:
197
+ type: RollingUpdate
198
+ rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }
97
199
  selector:
98
200
  matchLabels: { app.kubernetes.io/name: ${context.appCode} }
99
201
  template:
@@ -106,6 +208,13 @@ spec:
106
208
  oon.dev/release-id: ${releaseAnnotation}
107
209
  spec:
108
210
  serviceAccountName: oon-runtime
211
+ terminationGracePeriodSeconds: ${TERMINATION_GRACE_SECONDS}
212
+ topologySpreadConstraints:
213
+ - maxSkew: 1
214
+ topologyKey: kubernetes.io/hostname
215
+ whenUnsatisfiable: ScheduleAnyway
216
+ labelSelector:
217
+ matchLabels: { app.kubernetes.io/name: ${context.appCode} }
109
218
  imagePullSecrets: [{ name: ${infra.REGISTRY_PULL_SECRET_NAME || "ghcr-oon"} }]
110
219
  containers:
111
220
  - name: central
@@ -113,8 +222,10 @@ spec:
113
222
  imagePullPolicy: IfNotPresent
114
223
  ports: [{ name: http, containerPort: 8080 }]
115
224
  envFrom: [{ secretRef: { name: oon-runtime } }]
116
- ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` env:
117
- - name: OON_SECRETS_KEY
225
+ env:
226
+ - name: OON_DB_PERFORMANCE_MODE
227
+ value: "${mode}"
228
+ ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` - name: OON_SECRETS_KEY
118
229
  valueFrom:
119
230
  secretKeyRef: { name: oon-core-secrets, key: OON_SECRETS_KEY, optional: true }
120
231
  ` : ""}
@@ -127,6 +238,20 @@ ${context.functionalCapabilities?.["core.secrets"]?.enabled ? ` env:
127
238
  readinessProbe:
128
239
  httpGet: { path: /api/health/ready, port: http }
129
240
  initialDelaySeconds: 10
241
+ lifecycle:
242
+ # Dá tempo para o ingress tirar o pod dos endpoints antes do SIGTERM.
243
+ preStop:
244
+ exec: { command: ["sleep", "${PRE_STOP_SECONDS}"] }
245
+ ---
246
+ apiVersion: policy/v1
247
+ kind: PodDisruptionBudget
248
+ metadata: { name: ${context.appCode}, namespace: ${context.namespace} }
249
+ spec:
250
+ # Drains e a autoescala de nós tiram no máximo uma réplica por vez; com uma
251
+ # réplica só, o PDB não bloqueia a manutenção do nó.
252
+ maxUnavailable: 1
253
+ selector:
254
+ matchLabels: { app.kubernetes.io/name: ${context.appCode} }
130
255
  ---
131
256
  apiVersion: v1
132
257
  kind: Service
@@ -152,7 +277,28 @@ spec:
152
277
  pathType: Prefix
153
278
  backend:
154
279
  service: { name: ${context.appCode}, port: { name: http } }
155
- `;
280
+ ${customDomainIngress({ context, infra })}`;
281
+ }
282
+
283
+ /**
284
+ * F4.6: sem domínios ativos, o Ingress dos domínios personalizados sai. O
285
+ * publisher não tem permissão de delete (contrato do oon-k8s-infra), então
286
+ * só tenta remover quando o Ingress existe; se o cluster recusar, avisa e
287
+ * segue, e a remoção fica para quem administra o cluster.
288
+ */
289
+ function removeStaleCustomDomainIngress(context, run = kubectl) {
290
+ if (customDomains(context).length) return "domains";
291
+ const name = `${context.appCode}-custom`;
292
+ const found = run(["-n", context.namespace, "get", "ingress", name, "--ignore-not-found", "-o", "name"], { capture: true });
293
+ if (!found) return "absent";
294
+ try {
295
+ run(["-n", context.namespace, "delete", "ingress", name], { capture: true });
296
+ return "deleted";
297
+ } catch (error) {
298
+ if (!/forbidden/i.test(error.message)) throw error;
299
+ console.warn(`[oon-delivery] Ingress ${context.namespace}/${name} sem domínio ativo não pôde ser removido (sem permissão de delete). Remova-o manualmente.`);
300
+ return "kept";
301
+ }
156
302
  }
157
303
 
158
304
  function copyPullSecret(namespace, infra) {
@@ -264,6 +410,7 @@ function deployRuntime({ context, infra, imageDigest, resourceProfile = "small"
264
410
  copyPullSecret(context.namespace, infra);
265
411
  const imageRef = `${context.imageRepository}@${imageDigest}`;
266
412
  applyYaml(runtimeManifest({ context, infra, imageRef, profile: resourceProfile }));
413
+ removeStaleCustomDomainIngress(context);
267
414
  kubectl(["-n", context.namespace, "rollout", "status", `deployment/${context.appCode}`, "--timeout=10m"]);
268
415
  return { infraVersion: infra.INFRA_SCHEMA_VERSION, imageRef };
269
416
  }
@@ -306,4 +453,4 @@ async function activateMember(context) {
306
453
  return data;
307
454
  }
308
455
 
309
- module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
456
+ module.exports = { supportsSecretsV1: true, RESOURCE_PROFILES, MAX_REPLICAS, replicaCount, PERFORMANCE_MODES, performanceMode, MAX_CUSTOM_DOMAINS, customDomains, namespaceQuota, randomSecret, dnsLabel, kubernetesLabelValue, provisionMongo, validateMongoUri, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, removeStaleCustomDomainIngress, waitHttp, activateMember };
@@ -2,9 +2,19 @@
2
2
 
3
3
  // PID 1 owns both process groups. An unexpected exit (including status 0)
4
4
  // terminates the container; a live proxy cannot hide a dead backend.
5
+ // On SIGTERM each child gets its own stop signal (nginx: SIGQUIT = graceful)
6
+ // and `graceMs` to drain in-flight requests before SIGKILL. The Deployment's
7
+ // terminationGracePeriodSeconds must exceed preStop + graceMs.
5
8
  const { spawn } = require("node:child_process");
6
9
 
7
- function supervise({ backend, proxy, graceMs = 5000 } = {}) {
10
+ const DEFAULT_GRACE_MS = 25000;
11
+
12
+ function graceFromEnv(env = process.env) {
13
+ const value = Number(env.OON_SHUTDOWN_GRACE_MS);
14
+ return Number.isSafeInteger(value) && value > 0 ? value : DEFAULT_GRACE_MS;
15
+ }
16
+
17
+ function supervise({ backend, proxy, graceMs = graceFromEnv() } = {}) {
8
18
  const children = [];
9
19
  let stopping = false;
10
20
  let exitCode = 1;
@@ -25,7 +35,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
25
35
  if (stopping) return;
26
36
  stopping = true;
27
37
  exitCode = code;
28
- for (const child of children) signalGroup(child, "SIGTERM");
38
+ for (const child of children) signalGroup(child, child.stopSignal);
29
39
  timer = setTimeout(() => {
30
40
  for (const child of children) signalGroup(child, "SIGKILL");
31
41
  }, graceMs);
@@ -36,6 +46,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
36
46
  cwd: spec.cwd, env: { ...process.env, PORT: "4000" },
37
47
  stdio: "inherit", detached: true,
38
48
  });
49
+ child.stopSignal = spec.stopSignal || "SIGTERM";
39
50
  children.push(child);
40
51
  child.once("error", () => { child.failed = true; stop(1); finish(); });
41
52
  child.once("exit", () => { stop(1); finish(); });
@@ -46,7 +57,7 @@ function supervise({ backend, proxy, graceMs = 5000 } = {}) {
46
57
 
47
58
  if (require.main === module) supervise({
48
59
  backend: { command: process.execPath, args: ["/oon-delivery/launchRuntime.js"], cwd: "/app/backend" },
49
- proxy: { command: "nginx", args: ["-c", "/etc/nginx/oon.conf", "-g", "daemon off;"] },
60
+ proxy: { command: "nginx", args: ["-c", "/etc/nginx/oon.conf", "-g", "daemon off;"], stopSignal: "SIGQUIT" },
50
61
  });
51
62
 
52
- module.exports = { supervise };
63
+ module.exports = { supervise, graceFromEnv, DEFAULT_GRACE_MS };
package/src/index.js CHANGED
@@ -83,5 +83,9 @@ module.exports = {
83
83
  PdfRenderingError: capabilities.PdfRenderingError,
84
84
  TransactionalEmailError: capabilities.TransactionalEmailError,
85
85
  GenericError, registry,
86
+ // F4.8: arquivos compartilhados entre réplicas (GridFS no MongoDB do App).
87
+ files: require("./core/storage/files"),
88
+ // F5.3/F5.5: consumo para os limites de uso do plano do App.
89
+ usage: require("./core/usage/reporter"),
86
90
  };
87
91