@oondemand/oon-core-back 0.7.1 → 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.
@@ -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.1",
3
+ "version": "0.7.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -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 };