@oondemand/oon-core-back 0.7.1 → 0.7.3
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 +18 -0
- package/bin/build-delivery-image.js +3 -1
- package/bin/check-delivery-source.js +10 -0
- package/package.json +1 -1
- package/src/core/define.js +3 -0
- package/src/core/middlewares/requestLog.js +4 -2
- package/src/core/registry.js +37 -0
- package/src/delivery/buildImage.js +39 -11
- package/src/delivery/launchRuntime.js +13 -0
- package/src/delivery/supervisor.js +52 -0
- package/src/delivery/testSource.js +64 -0
- package/src/delivery/validateImage.js +124 -0
package/README.md
CHANGED
|
@@ -73,3 +73,21 @@ defineModel({
|
|
|
73
73
|
```
|
|
74
74
|
|
|
75
75
|
Use `populateRefs: true` para todas as referências da model ou informe uma lista explícita. O CRUD devolve somente `_id` e campos usuais de identificação, como nome, razão social, descrição, código, e-mail e campos pesquisáveis da model referenciada. Exportações mantêm os identificadores originais.
|
|
76
|
+
|
|
77
|
+
## Requisições com corpo sensível
|
|
78
|
+
|
|
79
|
+
Rotas privadas declaradas com `defineRoutes` que recebem credenciais ou outros segredos devem marcar a operação mutante com `sensitiveBody: true`:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
defineRoutes("/integracao", (router) => {
|
|
83
|
+
router.private.put("/credencial", {
|
|
84
|
+
permission: "integracao.config.write",
|
|
85
|
+
sensitiveBody: true,
|
|
86
|
+
}, async (req, res) => {
|
|
87
|
+
// Validar e persistir com segurança; devolver somente metadados sanitizados.
|
|
88
|
+
res.json({ configured: true });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
O Core omite todo o corpo de entrada e resposta no `requestLog` para esse método e caminho, inclusive se a rota responder erro. Método, URL, status, usuário e correlationId continuam registrados. A opção não cifra o banco nem substitui autorização, validação e respostas sanitizadas. Nunca retorne o segredo pela rota, pelo erro ou pela auditoria da mutação. A opção não se aplica automaticamente a endpoints de CRUD gerados.
|
|
@@ -8,4 +8,6 @@ const workspace = path.resolve(flag("--workspace") || process.cwd());
|
|
|
8
8
|
const tag = flag("--tag");
|
|
9
9
|
const output = flag("--output");
|
|
10
10
|
if (!tag || !output) { console.error("--tag e --output são obrigatórios"); process.exit(2); }
|
|
11
|
-
buildImage({ workspace, tag, output: path.resolve(output) })
|
|
11
|
+
buildImage({ workspace, tag, output: path.resolve(output), diagnosticOutput: flag("--diagnostic-output") })
|
|
12
|
+
.catch((error) => { console.error(error.code || "IMAGE_BUILD_FAILED"); process.exitCode = 1; });
|
|
13
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { testSource } = require("../src/delivery/testSource");
|
|
5
|
+
function flag(name) { const i = process.argv.indexOf(name); return i < 0 ? undefined : process.argv[i + 1]; }
|
|
6
|
+
const output = flag("--diagnostic-output");
|
|
7
|
+
if (!output) throw new Error("--diagnostic-output é obrigatório");
|
|
8
|
+
const result = testSource({ workspace: path.resolve(flag("--workspace") || process.cwd()), output });
|
|
9
|
+
console.log(JSON.stringify({ status: result.status, suites: result.suites }));
|
|
10
|
+
if (result.status === "failed") process.exitCode = 1;
|
package/package.json
CHANGED
package/src/core/define.js
CHANGED
|
@@ -161,6 +161,9 @@ function defineRoutes(basePath, builder) {
|
|
|
161
161
|
const required = opts.permission || opts.permissions;
|
|
162
162
|
if (required) chain.push(requirePermission(required));
|
|
163
163
|
else chain.push(rbac(opts.roles || []));
|
|
164
|
+
if (["post", "put", "patch", "delete"].includes(method) && opts.sensitiveBody === true) {
|
|
165
|
+
registry.registerSensitiveRequest(method, basePath, path);
|
|
166
|
+
}
|
|
164
167
|
if (["post", "put", "patch", "delete"].includes(method) && opts.audit) {
|
|
165
168
|
chain.push(mutationAudit(opts.audit));
|
|
166
169
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const Log = require("../models/Log");
|
|
2
|
+
const registry = require("../registry");
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Registra requisições mutantes (não-GET) na model interna Log.
|
|
@@ -7,18 +8,19 @@ const Log = require("../models/Log");
|
|
|
7
8
|
const requestLog = async (req, res, next) => {
|
|
8
9
|
if (req.method === "GET") return next();
|
|
9
10
|
|
|
11
|
+
const sensitiveBody = await registry.isSensitiveRequest(req.method, req.url || req.originalUrl);
|
|
10
12
|
const log = new Log({
|
|
11
13
|
usuario: req.usuario || null,
|
|
12
14
|
endpoint: req.originalUrl,
|
|
13
15
|
metodo: req.method,
|
|
14
16
|
ip: req.ip || req.headers["x-forwarded-for"] || req.connection?.remoteAddress,
|
|
15
17
|
correlationId: req.correlationId,
|
|
16
|
-
dadosRequisicao: req.body,
|
|
18
|
+
dadosRequisicao: sensitiveBody ? { omitted: true } : req.body,
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
res.on("finish", () => {
|
|
20
22
|
log.statusResposta = res.statusCode;
|
|
21
|
-
log.dadosResposta = res.locals.body || null;
|
|
23
|
+
log.dadosResposta = sensitiveBody ? null : (res.locals.body || null);
|
|
22
24
|
log.save().catch(() => {});
|
|
23
25
|
});
|
|
24
26
|
|
package/src/core/registry.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const express = require("express");
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Model Registry — coração técnico do Core (Seção 17).
|
|
3
5
|
*
|
|
@@ -17,6 +19,7 @@ const store = {
|
|
|
17
19
|
pipelines: new Map(),
|
|
18
20
|
menus: [],
|
|
19
21
|
features: new Map(),
|
|
22
|
+
sensitiveRequests: [], // Express routers used only to classify sensitive paths
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
function registerModel(entry) {
|
|
@@ -68,6 +71,37 @@ const simpleRegister = (mapName) => (key, value) => {
|
|
|
68
71
|
};
|
|
69
72
|
const simpleList = (mapName) => () => [...store[mapName].values()];
|
|
70
73
|
|
|
74
|
+
function registerSensitiveRequest(method, basePath, path) {
|
|
75
|
+
// Use the same mount and route semantics as defineRoutes/createApp.
|
|
76
|
+
const route = express.Router();
|
|
77
|
+
route[method.toLowerCase()](path, (req) => req.sensitiveMatch());
|
|
78
|
+
const mounted = express.Router();
|
|
79
|
+
mounted.use(basePath, route);
|
|
80
|
+
store.sensitiveRequests.push(mounted);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function isSensitiveRequest(method, url) {
|
|
84
|
+
for (const router of store.sensitiveRequests) {
|
|
85
|
+
const matched = await new Promise((resolve) => {
|
|
86
|
+
// A separate request prevents classification from modifying the real request.
|
|
87
|
+
const request = {
|
|
88
|
+
method: String(method).toUpperCase(),
|
|
89
|
+
url: String(url || '/'),
|
|
90
|
+
headers: {},
|
|
91
|
+
sensitiveMatch: () => resolve(true),
|
|
92
|
+
};
|
|
93
|
+
// Routing errors fail closed: omit bodies instead of risking disclosure.
|
|
94
|
+
try {
|
|
95
|
+
router.handle(request, {}, (error) => resolve(Boolean(error)));
|
|
96
|
+
} catch {
|
|
97
|
+
resolve(true);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
if (matched) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
function registerMenu(item) {
|
|
72
106
|
store.menus.push(item);
|
|
73
107
|
}
|
|
@@ -82,6 +116,7 @@ function reset() {
|
|
|
82
116
|
store.pipelines.clear();
|
|
83
117
|
store.menus.length = 0;
|
|
84
118
|
store.features.clear();
|
|
119
|
+
store.sensitiveRequests.length = 0;
|
|
85
120
|
}
|
|
86
121
|
|
|
87
122
|
module.exports = {
|
|
@@ -104,6 +139,8 @@ module.exports = {
|
|
|
104
139
|
registerFeature: simpleRegister("features"),
|
|
105
140
|
listFeatures: simpleList("features"),
|
|
106
141
|
registerMenu,
|
|
142
|
+
registerSensitiveRequest,
|
|
143
|
+
isSensitiveRequest,
|
|
107
144
|
listMenus: () => store.menus,
|
|
108
145
|
reset,
|
|
109
146
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const { run } = require("./command");
|
|
6
|
+
const { validateImage } = require("./validateImage");
|
|
6
7
|
|
|
7
8
|
const DOCKERFILE = `# syntax=docker/dockerfile:1.7
|
|
8
9
|
FROM node:20-alpine AS frontend
|
|
@@ -28,19 +29,32 @@ ENV NODE_ENV=production
|
|
|
28
29
|
COPY --from=backend /app/backend /app/backend
|
|
29
30
|
COPY .oon-delivery/app-manifest/ /app/
|
|
30
31
|
COPY --from=frontend /src/frontend/dist /usr/share/nginx/html
|
|
31
|
-
COPY .oon-delivery/nginx.conf /etc/nginx/
|
|
32
|
+
COPY .oon-delivery/nginx.conf /etc/nginx/oon.conf
|
|
33
|
+
COPY .oon-delivery/supervisor.js /supervisor.js
|
|
34
|
+
COPY .oon-delivery/launchRuntime.js .oon-delivery/validateImage.js /oon-delivery/
|
|
32
35
|
COPY .oon-delivery/entrypoint.sh /entrypoint.sh
|
|
33
|
-
RUN chmod +x /entrypoint.sh && mkdir -p /app/backend/uploads /run/nginx
|
|
36
|
+
RUN chmod +x /entrypoint.sh && mkdir -p /app/backend/uploads /run/nginx && chown -R node:node /app/backend/uploads
|
|
34
37
|
EXPOSE 8080
|
|
35
38
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD curl -fsS http://127.0.0.1:8080/health/live || exit 1
|
|
36
39
|
ENTRYPOINT ["/entrypoint.sh"]
|
|
37
40
|
`;
|
|
38
41
|
|
|
39
|
-
const NGINX = `
|
|
42
|
+
const NGINX = `pid /tmp/nginx.pid;
|
|
43
|
+
error_log /dev/stderr;
|
|
44
|
+
events {}
|
|
45
|
+
http {
|
|
46
|
+
include /etc/nginx/mime.types;
|
|
47
|
+
access_log /dev/stdout;
|
|
48
|
+
client_body_temp_path /tmp/client_temp;
|
|
49
|
+
proxy_temp_path /tmp/proxy_temp;
|
|
50
|
+
fastcgi_temp_path /tmp/fastcgi_temp;
|
|
51
|
+
uwsgi_temp_path /tmp/uwsgi_temp;
|
|
52
|
+
scgi_temp_path /tmp/scgi_temp;
|
|
53
|
+
server {
|
|
40
54
|
listen 8080;
|
|
41
55
|
server_name _;
|
|
42
56
|
root /usr/share/nginx/html;
|
|
43
|
-
location = /health/live { access_log off;
|
|
57
|
+
location = /health/live { access_log off; proxy_pass http://127.0.0.1:4000/; }
|
|
44
58
|
location /api/ {
|
|
45
59
|
proxy_pass http://127.0.0.1:4000/;
|
|
46
60
|
proxy_http_version 1.1;
|
|
@@ -50,16 +64,12 @@ const NGINX = `server {
|
|
|
50
64
|
}
|
|
51
65
|
location / { try_files $uri $uri/ /index.html; }
|
|
52
66
|
}
|
|
67
|
+
}
|
|
53
68
|
`;
|
|
54
69
|
|
|
55
70
|
const ENTRYPOINT = `#!/bin/sh
|
|
56
71
|
set -eu
|
|
57
|
-
|
|
58
|
-
export PORT=4000
|
|
59
|
-
./node_modules/.bin/oonCore-back start &
|
|
60
|
-
backend_pid=$!
|
|
61
|
-
trap 'kill $backend_pid 2>/dev/null || true' TERM INT EXIT
|
|
62
|
-
exec nginx -g 'daemon off;'
|
|
72
|
+
exec node /supervisor.js
|
|
63
73
|
`;
|
|
64
74
|
|
|
65
75
|
function prepareAppManifest(workspace, deliveryDirectory) {
|
|
@@ -96,19 +106,36 @@ function prepareAppManifest(workspace, deliveryDirectory) {
|
|
|
96
106
|
|
|
97
107
|
function prepareDeliveryFiles(workspace) {
|
|
98
108
|
const target = path.join(workspace, ".oon-delivery");
|
|
109
|
+
if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) throw new Error("Diretório de delivery não pode ser symlink.");
|
|
110
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
99
111
|
fs.mkdirSync(target, { recursive: true });
|
|
100
112
|
prepareAppManifest(workspace, target);
|
|
113
|
+
for (const file of ["supervisor.js", "launchRuntime.js", "validateImage.js"]) fs.copyFileSync(path.join(__dirname, file), path.join(target, file));
|
|
101
114
|
fs.writeFileSync(path.join(target, "Dockerfile"), DOCKERFILE);
|
|
102
115
|
fs.writeFileSync(path.join(target, "nginx.conf"), NGINX);
|
|
103
116
|
fs.writeFileSync(path.join(target, "entrypoint.sh"), ENTRYPOINT, {
|
|
104
117
|
mode: 0o755,
|
|
105
118
|
});
|
|
119
|
+
// Dockerfile-specific ignore rules win over the root ignore file. Preserve
|
|
120
|
+
// user rules, then append mandatory exclusions so local installs and secrets
|
|
121
|
+
// cannot overwrite production dependencies or leak into the final image.
|
|
122
|
+
const userIgnore = path.join(workspace, ".dockerignore");
|
|
123
|
+
const ignore = fs.existsSync(userIgnore) ? fs.readFileSync(userIgnore, "utf8") : "";
|
|
124
|
+
fs.writeFileSync(path.join(target, "Dockerfile.dockerignore"), ignore + "\n**/node_modules\n**/.git\n**/.env\n**/.env.*\n**/*.pem\n**/*.key\n**/.npmrc\n!frontend/.env.production\n");
|
|
106
125
|
return path.join(target, "Dockerfile");
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
function buildImage({ workspace, tag, output }) {
|
|
128
|
+
async function buildImage({ workspace, tag, output, diagnosticOutput }) {
|
|
129
|
+
fs.rmSync(output, { force: true });
|
|
110
130
|
const dockerfile = prepareDeliveryFiles(workspace);
|
|
111
131
|
run("docker", ["build", "-f", dockerfile, "-t", tag, workspace]);
|
|
132
|
+
const validation = await validateImage({ tag });
|
|
133
|
+
if (diagnosticOutput) fs.writeFileSync(diagnosticOutput, JSON.stringify(validation, null, 2));
|
|
134
|
+
if (validation.status !== "passed") {
|
|
135
|
+
const error = new Error(validation.cause);
|
|
136
|
+
error.code = validation.code;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
112
139
|
run("docker", ["save", "-o", output, tag]);
|
|
113
140
|
return { dockerfile, output, tag };
|
|
114
141
|
}
|
|
@@ -121,3 +148,4 @@ module.exports = {
|
|
|
121
148
|
prepareDeliveryFiles,
|
|
122
149
|
buildImage,
|
|
123
150
|
};
|
|
151
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const { createRequire } = require("node:module");
|
|
3
|
+
const { classify } = require("./validateImage");
|
|
4
|
+
const runtimeRequire = createRequire("/app/backend/package.json");
|
|
5
|
+
// Invoke the public API of the SDK INSIDE the image; do not replace the app's
|
|
6
|
+
// dependency version. Include the error class so SyntaxError is not lost by an
|
|
7
|
+
// older CLI that prints only error.message. Never print the raw error/stack.
|
|
8
|
+
Promise.resolve().then(() => runtimeRequire("@oondemand/oon-core-back").start({ cwd: "/app/backend" }))
|
|
9
|
+
.catch((error) => {
|
|
10
|
+
const diagnostic = classify(`${error?.name || "Error"}: ${error?.message || ""}`);
|
|
11
|
+
console.error(`OON_STARTUP_FAILURE ${JSON.stringify(diagnostic)}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// PID 1 owns both process groups. An unexpected exit (including status 0)
|
|
4
|
+
// terminates the container; a live proxy cannot hide a dead backend.
|
|
5
|
+
const { spawn } = require("node:child_process");
|
|
6
|
+
|
|
7
|
+
function supervise({ backend, proxy, graceMs = 5000 } = {}) {
|
|
8
|
+
const children = [];
|
|
9
|
+
let stopping = false;
|
|
10
|
+
let exitCode = 1;
|
|
11
|
+
let timer;
|
|
12
|
+
const signalGroup = (child, signal) => {
|
|
13
|
+
if (!child.pid) return;
|
|
14
|
+
try { process.kill(-child.pid, signal); } catch (error) {
|
|
15
|
+
if (error.code !== "ESRCH") throw error;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const finish = () => {
|
|
19
|
+
if (stopping && children.every((child) => child.failed || child.exitCode !== null || child.signalCode !== null)) {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
process.exitCode = exitCode;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const stop = (code) => {
|
|
25
|
+
if (stopping) return;
|
|
26
|
+
stopping = true;
|
|
27
|
+
exitCode = code;
|
|
28
|
+
for (const child of children) signalGroup(child, "SIGTERM");
|
|
29
|
+
timer = setTimeout(() => {
|
|
30
|
+
for (const child of children) signalGroup(child, "SIGKILL");
|
|
31
|
+
}, graceMs);
|
|
32
|
+
finish();
|
|
33
|
+
};
|
|
34
|
+
for (const spec of [backend, proxy]) {
|
|
35
|
+
const child = spawn(spec.command, spec.args || [], {
|
|
36
|
+
cwd: spec.cwd, env: { ...process.env, PORT: "4000" },
|
|
37
|
+
stdio: "inherit", detached: true,
|
|
38
|
+
});
|
|
39
|
+
children.push(child);
|
|
40
|
+
child.once("error", () => { child.failed = true; stop(1); finish(); });
|
|
41
|
+
child.once("exit", () => { stop(1); finish(); });
|
|
42
|
+
}
|
|
43
|
+
process.once("SIGTERM", () => stop(0));
|
|
44
|
+
process.once("SIGINT", () => stop(0));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (require.main === module) supervise({
|
|
48
|
+
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;"] },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
module.exports = { supervise };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
const { spawnSync } = require("node:child_process");
|
|
5
|
+
const { randomUUID } = require("node:crypto");
|
|
6
|
+
const { prepareDeliveryFiles } = require("./buildImage");
|
|
7
|
+
|
|
8
|
+
function testPlan(workspace) {
|
|
9
|
+
const scopes = [".", "backend", "frontend"].map((scope) => {
|
|
10
|
+
const file = path.join(workspace, scope, "package.json");
|
|
11
|
+
const declared = fs.existsSync(file) && typeof JSON.parse(fs.readFileSync(file, "utf8")).scripts?.test === "string";
|
|
12
|
+
return { scope, status: declared ? "declared" : "not_declared" };
|
|
13
|
+
});
|
|
14
|
+
// A root suite is the orchestration entrypoint. Components are run separately
|
|
15
|
+
// only when no root test is declared. This avoids executing a suite twice.
|
|
16
|
+
if (scopes[0].status === "declared") {
|
|
17
|
+
for (const item of scopes.slice(1)) if (item.status === "declared") item.status = "root_orchestrated";
|
|
18
|
+
}
|
|
19
|
+
return scopes;
|
|
20
|
+
}
|
|
21
|
+
function testSource({ workspace, output }) {
|
|
22
|
+
const suites = testPlan(workspace);
|
|
23
|
+
const result = { schemaVersion: 1, stage: "source_tests", deployStarted: false, suites };
|
|
24
|
+
const selected = suites.filter((s) => s.status === "declared");
|
|
25
|
+
if (!selected.length) {
|
|
26
|
+
result.status = "not_declared";
|
|
27
|
+
fs.writeFileSync(output, JSON.stringify(result));
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
prepareDeliveryFiles(workspace);
|
|
31
|
+
const target = path.join(workspace, ".oon-delivery");
|
|
32
|
+
const dockerfile = path.join(target, "Dockerfile.test");
|
|
33
|
+
fs.copyFileSync(path.join(target, "Dockerfile.dockerignore"), `${dockerfile}.dockerignore`);
|
|
34
|
+
fs.writeFileSync(dockerfile, `FROM node:20-alpine\nWORKDIR /work\nCOPY . .\nRUN for scope in . backend frontend; do if [ -f "$scope/package.json" ]; then (cd "$scope" && if [ -f package-lock.json ]; then npm ci --ignore-scripts; else npm install --ignore-scripts; fi); fi; done\nRUN chown -R node:node /work\nUSER node\n`);
|
|
35
|
+
const name = `oon-tests-${randomUUID()}`;
|
|
36
|
+
const invoke = (args) => spawnSync("docker", args, { encoding: "utf8", timeout: 600000, maxBuffer: 1024 * 1024 });
|
|
37
|
+
try {
|
|
38
|
+
const build = invoke(["build", "-f", dockerfile, "-t", name, workspace]);
|
|
39
|
+
if (build.error || build.status !== 0) {
|
|
40
|
+
result.status = "failed"; result.category = "unknown"; result.code = "SOURCE_TEST_SETUP_FAILED";
|
|
41
|
+
result.cause = "Não foi possível preparar as dependências dos testes.";
|
|
42
|
+
result.guidance = "Confira lockfiles, dependências e disponibilidade do registry. Scripts de instalação não são executados nesta etapa.";
|
|
43
|
+
} else {
|
|
44
|
+
for (const suite of selected) {
|
|
45
|
+
const run = invoke(["run", "--rm", "--name", name, "--network", "none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--memory=1g", "--cpus=2", "--pids-limit=256", "--workdir", path.posix.join("/work", suite.scope), name, "npm", "test"]);
|
|
46
|
+
suite.status = run.status === 0 && !run.error ? "passed" : "failed";
|
|
47
|
+
if (suite.status === "failed") break;
|
|
48
|
+
}
|
|
49
|
+
for (const suite of selected) if (suite.status === "declared") suite.status = "not_run";
|
|
50
|
+
result.status = suites.some((s) => s.status === "failed") ? "failed" : "passed";
|
|
51
|
+
if (result.status === "failed") {
|
|
52
|
+
result.category = "unknown"; result.code = "SOURCE_TEST_FAILED";
|
|
53
|
+
result.cause = "A suíte declarada não passou no ambiente isolado.";
|
|
54
|
+
result.guidance = "Execute os testes declarados e confira dependências; serviços externos não são acessíveis durante os testes.";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
invoke(["rm", "-f", "-v", name]);
|
|
59
|
+
invoke(["image", "rm", "-f", name]);
|
|
60
|
+
fs.writeFileSync(output, JSON.stringify(result, null, 2));
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
module.exports = { testSource, testPlan };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("node:child_process");
|
|
4
|
+
const { randomUUID } = require("node:crypto");
|
|
5
|
+
|
|
6
|
+
// No arbitrary logs cross the build/privileged-job boundary. Only a bounded
|
|
7
|
+
// allowlist of paths and stable codes is returned; messages are platform-owned.
|
|
8
|
+
function safePath(value) {
|
|
9
|
+
const text = String(value || "").replace(/^\/app\//, "");
|
|
10
|
+
return text.length <= 240 && /^(?:[.@a-zA-Z0-9_-]+\/)*[.@a-zA-Z0-9_-]+$/.test(text)
|
|
11
|
+
&& !/(?:secret|token|password|credential|\.env)/i.test(text) ? text : undefined;
|
|
12
|
+
}
|
|
13
|
+
function classify(log = "") {
|
|
14
|
+
const recorded = log.match(/OON_STARTUP_FAILURE (\{[^\r\n]+\})/);
|
|
15
|
+
if (recorded) {
|
|
16
|
+
try {
|
|
17
|
+
const input = JSON.parse(recorded[1]);
|
|
18
|
+
const known = ["IMAGE_MODULE_NOT_FOUND", "IMAGE_SYNTAX_ERROR", "IMAGE_DEPENDENCY_UNAVAILABLE", "IMAGE_STARTUP_FAILED"];
|
|
19
|
+
if (known.includes(input.code)) {
|
|
20
|
+
// Reconstruct through the same bounded classifier; no arbitrary fields.
|
|
21
|
+
const representative = input.code === "IMAGE_MODULE_NOT_FOUND"
|
|
22
|
+
? `Cannot find module '${safePath(input.path) || "unavailable"}'\nRequire stack:\n- ${safePath(input.importer) || "unavailable"}`
|
|
23
|
+
: input.code === "IMAGE_SYNTAX_ERROR" ? "SyntaxError:" : input.code === "IMAGE_DEPENDENCY_UNAVAILABLE" ? "ECONNREFUSED" : "";
|
|
24
|
+
return classify(representative);
|
|
25
|
+
}
|
|
26
|
+
} catch { /* Treat malformed logs as indeterminate. */ }
|
|
27
|
+
}
|
|
28
|
+
const missing = log.match(/Cannot find module ['"]([^'"\r\n]+)['"]/);
|
|
29
|
+
if (missing) return {
|
|
30
|
+
category: "application", code: "IMAGE_MODULE_NOT_FOUND",
|
|
31
|
+
cause: "Módulo ou asset necessário não está disponível na imagem de produção.",
|
|
32
|
+
path: safePath(missing[1]),
|
|
33
|
+
importer: safePath(log.match(/Require stack:\s*\n- ([^\r\n]+)/)?.[1]),
|
|
34
|
+
guidance: "Inclua assets de runtime em backend e declare dependências em dependencies. Confira o importador e o contrato de empacotamento.",
|
|
35
|
+
};
|
|
36
|
+
if (/SyntaxError:/.test(log)) return {
|
|
37
|
+
category: "application", code: "IMAGE_SYNTAX_ERROR",
|
|
38
|
+
cause: "Um módulo da imagem contém sintaxe inválida.",
|
|
39
|
+
guidance: "Revise a sintaxe dos módulos e a compatibilidade com Node 20.",
|
|
40
|
+
};
|
|
41
|
+
if (/Mongo(?:ose)?ServerSelectionError|ECONNREFUSED|ENOTFOUND|ETIMEDOUT/.test(log)) return {
|
|
42
|
+
category: "configuration", code: "IMAGE_DEPENDENCY_UNAVAILABLE",
|
|
43
|
+
cause: "A aplicação tentou acessar uma dependência indisponível na validação isolada.",
|
|
44
|
+
guidance: "Confira a configuração de inicialização. Serviços reais não são acessíveis durante esta validação.",
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
category: "unknown", code: "IMAGE_STARTUP_FAILED",
|
|
48
|
+
cause: "A imagem não concluiu a inicialização; a causa não foi determinada.",
|
|
49
|
+
guidance: "Reproduza a inicialização em ambiente isolado e confira a configuração e os módulos carregados.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function docker(args, { timeout = 30000, allowFailure = false } = {}) {
|
|
53
|
+
const r = spawnSync("docker", args, { encoding: "utf8", timeout, maxBuffer: 1024 * 1024 });
|
|
54
|
+
if (!allowFailure && (r.error || r.status !== 0)) {
|
|
55
|
+
const error = new Error("O executor de validação não concluiu uma operação Docker.");
|
|
56
|
+
error.code = "IMAGE_VALIDATION_INFRASTRUCTURE";
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return r;
|
|
60
|
+
}
|
|
61
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
|
+
|
|
63
|
+
async function validateImage({ tag, timeoutMs = 90000, execute = docker, sleep = delay }) {
|
|
64
|
+
const id = `oon-validation-${randomUUID()}`;
|
|
65
|
+
const database = `${id}-db`, app = `${id}-app`;
|
|
66
|
+
const base = { schemaVersion: 1, stage: "image_validation", deployStarted: false };
|
|
67
|
+
let imageId;
|
|
68
|
+
let infrastructureStep = "image_inspect";
|
|
69
|
+
try {
|
|
70
|
+
imageId = execute(["image", "inspect", "--format", "{{.Id}}", tag]).stdout.trim();
|
|
71
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(imageId)) throw new Error("Invalid image identity");
|
|
72
|
+
// Pull BEFORE attaching services to an internal-only network. No host mounts,
|
|
73
|
+
// published ports, host networking, Docker socket, or real credentials.
|
|
74
|
+
infrastructureStep = "database_image";
|
|
75
|
+
execute(["pull", "mongo:7"], { timeout: 180000 });
|
|
76
|
+
infrastructureStep = "database_startup";
|
|
77
|
+
execute(["run", "-d", "--name", database, "--network", "none", "--user", "999:999", "--cap-drop=ALL",
|
|
78
|
+
"--security-opt=no-new-privileges", "--memory=1g", "--cpus=1", "--pids-limit=128",
|
|
79
|
+
"--read-only", "--tmpfs", "/data/db:rw,uid=999,gid=999,size=536870912",
|
|
80
|
+
"--tmpfs", "/data/configdb:rw,uid=999,gid=999,size=16777216",
|
|
81
|
+
"--tmpfs", "/tmp:rw,size=67108864", "mongo:7", "mongod", "--bind_ip_all", "--wiredTigerCacheSizeGB", "0.25"]);
|
|
82
|
+
let databaseReady = false;
|
|
83
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
84
|
+
const ping = execute(["exec", database, "mongosh", "--quiet", "--eval", "quit(db.adminCommand({ping:1}).ok ? 0 : 1)"], { allowFailure: true, timeout: 3000 });
|
|
85
|
+
if (ping.status === 0) { databaseReady = true; break; }
|
|
86
|
+
await sleep(1000);
|
|
87
|
+
}
|
|
88
|
+
if (!databaseReady) throw new Error("Validation database unavailable");
|
|
89
|
+
infrastructureStep = "application_startup";
|
|
90
|
+
execute(["run", "-d", "--name", app, "--network", `container:${database}`, "--user", "1000:1000",
|
|
91
|
+
"--cap-drop=ALL", "--security-opt=no-new-privileges", "--read-only",
|
|
92
|
+
"--memory=512m", "--cpus=1", "--pids-limit=128",
|
|
93
|
+
"--tmpfs", "/tmp:rw,uid=1000,gid=1000,size=67108864",
|
|
94
|
+
"--tmpfs", "/app/backend/uploads:rw,uid=1000,gid=1000,size=16777216",
|
|
95
|
+
"--env", "MONGO_URI=mongodb://127.0.0.1:27017/validation",
|
|
96
|
+
"--env", "NODE_ENV=production", "--env", "APP_ENVIRONMENT=desenvolvimento",
|
|
97
|
+
"--env", "INSTANCE_CREDENTIAL_ENCRYPTION_KEY=validation-only-not-a-real-credential",
|
|
98
|
+
"--env", "CENTRAL_ATIVACAO_URL=http://unavailable.invalid",
|
|
99
|
+
"--env", "CENTRAL_ATIVACAO_API_URL=http://unavailable.invalid", imageId]);
|
|
100
|
+
const deadline = Date.now() + timeoutMs;
|
|
101
|
+
let consecutive = 0;
|
|
102
|
+
while (Date.now() < deadline) {
|
|
103
|
+
const running = execute(["inspect", "--format", "{{.State.Running}}", app]).stdout.trim();
|
|
104
|
+
if (running !== "true") {
|
|
105
|
+
const logs = execute(["logs", "--tail", "80", app], { allowFailure: true });
|
|
106
|
+
return { ...base, imageId, status: "failed", ...classify(`${logs.stdout || ""}\n${logs.stderr || ""}`) };
|
|
107
|
+
}
|
|
108
|
+
const health = execute(["exec", app, "curl", "-fsS", "--max-time", "2", "http://127.0.0.1:8080/api/health/ready"], { allowFailure: true, timeout: 4000 });
|
|
109
|
+
if (health.status === 0) consecutive++; else consecutive = 0;
|
|
110
|
+
if (consecutive >= 3) return { ...base, imageId, status: "passed" };
|
|
111
|
+
await sleep(1000);
|
|
112
|
+
}
|
|
113
|
+
return { ...base, imageId, status: "failed", category: "unknown", code: "IMAGE_VALIDATION_TIMEOUT",
|
|
114
|
+
cause: "A inicialização não terminou no prazo de validação.",
|
|
115
|
+
guidance: "Investigue inicialização e dependências. Timeout isolado não comprova erro de código." };
|
|
116
|
+
} catch {
|
|
117
|
+
return { ...base, imageId, status: "failed", category: "infrastructure", code: "IMAGE_VALIDATION_INFRASTRUCTURE", infrastructureStep,
|
|
118
|
+
cause: "O ambiente de validação não conseguiu executar a prova da imagem.",
|
|
119
|
+
guidance: "Verifique Docker, recursos e disponibilidade da imagem do serviço temporário; tente novamente." };
|
|
120
|
+
} finally {
|
|
121
|
+
execute(["rm", "-f", "-v", app, database], { allowFailure: true });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
module.exports = { validateImage, classify, safePath };
|