@oondemand/oon-core-back 0.7.0 → 0.7.2

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 CHANGED
@@ -23,7 +23,11 @@ Compatibilidade temporária: `CENTRAL_ATIVACAO_BACKEND_URL` e `MEUS_APPS_BACKEND
23
23
 
24
24
  ## Catálogo público de perfis
25
25
 
26
- `GET /core/role-catalog` expõe o contrato mínimo e versionado dos perfis RBAC declarados pelo App. A resposta contém `appCode`, `enabled` e código, nome, descrição e indicador administrativo de cada papel; permissões internas não são publicadas. O endpoint é somente leitura, usa cache público de cinco minutos e não concede autorização.
26
+ Todo App com RBAC declarado expõe `GET /core/role-catalog`. O contrato `schemaVersion: 1` contém `appCode`, `enabled` e `roles[]` com `code`, `name`, `description` e `admin`. Desde o SDK 0.7.1, a extensão aditiva opcional `roles[].commercialPermissions` publica nomes explícitos de permissões funcionais permitidas pelo papel. Wildcards são expandidos somente pelas permissões declaradas em `rbac.permissions`; wildcard literal e namespaces `tenant`, `publications`, `homologation`, `production`, `apps`, `users` e `roles` são excluídos. Políticas internas e permissões técnicas não são publicadas.
27
+
28
+ O campo permanece opcional no schemaVersion 1: catálogos anteriores são conformes para descoberta e fluxos existentes. O novo aceite comercial inicial requer essa capacidade e deve falhar fechado com `APP_ROLE_CATALOG_UNAVAILABLE` quando ausente; não se infere autoridade do nome admin. Consumidores antigos devem tolerar campos adicionais. O runtime continua intersectando cada grant explícito com a política local atual.
29
+
30
+ O endpoint é somente leitura, usa cache público de cinco minutos e não concede autorização.
27
31
 
28
32
  ## Saúde operacional
29
33
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -127,6 +127,15 @@ function canAssignRole(actorPermissions, targetRole) {
127
127
  return target.every((permission) => actor.includes(permission));
128
128
  }
129
129
 
130
+ // Public, bounded capability names only; never publish wildcard or authority
131
+ // over identity, tenants or the Platform's delivery lifecycle.
132
+ function commercialRolePermissions(role, config = ctx.config) {
133
+ const declared = rolePermissions(role);
134
+ const candidates = declared.includes("*") ? policy(config).permissions : declared;
135
+ return unique(candidates).filter(permission => PERMISSION_PATTERN.test(permission)
136
+ && !/^(tenant|publications|homologation|production|apps|users|roles)\./.test(permission)).sort();
137
+ }
138
+
130
139
  function publicRoleCatalog(config = ctx.config) {
131
140
  const currentPolicy = policy(config);
132
141
  return {
@@ -138,6 +147,7 @@ function publicRoleCatalog(config = ctx.config) {
138
147
  name: role.name,
139
148
  description: String(role.description || ""),
140
149
  admin: role.admin === true || role.permissions?.includes("*") === true,
150
+ commercialPermissions: commercialRolePermissions(role, config),
141
151
  })),
142
152
  };
143
153
  }
@@ -180,5 +190,7 @@ module.exports = {
180
190
  matchingRoleForLegacyUser,
181
191
  canAssignRole,
182
192
  publicRoleCatalog,
193
+ commercialRolePermissions,
183
194
  publicPolicy,
184
195
  };
196
+
@@ -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/http.d/default.conf
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 = `server {
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; return 200 'ok'; add_header Content-Type text/plain; }
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
- cd /app/backend
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 };