@oondemand/oon-core-back 0.3.18 → 0.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const {
5
+ validateInfraFromCluster,
6
+ } = require("../src/deploy/contracts/infra-contract");
7
+ const {
8
+ dnsLabel,
9
+ provisionRuntime,
10
+ deployRuntime,
11
+ waitHttp,
12
+ } = require("../src/delivery/runtime");
13
+ const {
14
+ copyProtectedConfig,
15
+ attachProtectedConfig,
16
+ } = require("../src/delivery/controlPlaneBootstrap");
17
+
18
+ function flag(name, fallback) {
19
+ const index = process.argv.indexOf(name);
20
+ return index < 0 ? fallback : process.argv[index + 1];
21
+ }
22
+ function required(value, name) {
23
+ if (!value) throw new Error(`${name} obrigatório.`);
24
+ return value;
25
+ }
26
+
27
+ async function main() {
28
+ const appCode = dnsLabel(flag("--app-code", "central-ativacao"));
29
+ const imageDigest = required(flag("--image-digest"), "--image-digest");
30
+ const infra = validateInfraFromCluster().config;
31
+ const hostname = flag("--hostname", `${appCode}.${infra.DNS}`);
32
+ const publicUrl = `${infra.DEFAULT_PROTOCOL || "https"}://${hostname}`;
33
+ const apiUrl = `${publicUrl}/api`;
34
+ const namespace = `${infra.RUNTIME_NAMESPACE_PREFIX || "oon"}-${appCode}-dev`
35
+ .slice(0, 63)
36
+ .replace(/-+$/, "");
37
+ const imageRepository = flag(
38
+ "--image-repository",
39
+ `${infra.REGISTRY_HOST || "ghcr.io"}/oondemand/${appCode}`,
40
+ );
41
+ const context = {
42
+ releaseId: `bootstrap-${appCode}`,
43
+ appCode,
44
+ environment: "desenvolvimento",
45
+ namespace,
46
+ hostname,
47
+ publicUrl,
48
+ activationApiUrl: apiUrl,
49
+ imageRepository,
50
+ };
51
+ const extraSecrets = {
52
+ GITHUB_OIDC_AUDIENCE: apiUrl,
53
+ PLATFORM_DNS: infra.DNS,
54
+ PLATFORM_DEFAULT_PROTOCOL: infra.DEFAULT_PROTOCOL || "https",
55
+ PLATFORM_REGISTRY_HOST: infra.REGISTRY_HOST || "ghcr.io",
56
+ PLATFORM_REGISTRY_OWNER: "oondemand",
57
+ PLATFORM_RUNTIME_NAMESPACE_PREFIX:
58
+ infra.RUNTIME_NAMESPACE_PREFIX || "oon",
59
+ PLATFORM_DELIVERY_REPOSITORY: "oondemand/oon-platform",
60
+ PLATFORM_DELIVERY_WORKFLOW: "publish-dev.yml",
61
+ PLATFORM_DELIVERY_REF: "main",
62
+ PLATFORM_API_URL: apiUrl,
63
+ PLATFORM_ADMINISTRATIVE_EMAIL: "plataforma@oondemand.com.br",
64
+ };
65
+
66
+ const provisioned = await provisionRuntime({ context, extraSecrets });
67
+ deployRuntime({ context, infra: provisioned.infra, imageDigest });
68
+
69
+ const protectedName = "oon-control-plane";
70
+ copyProtectedConfig({
71
+ sourceNamespace: infra.PLATFORM_NAMESPACE || "oon-system",
72
+ sourceName: flag("--bootstrap-secret", "oon-control-plane-bootstrap"),
73
+ targetNamespace: namespace,
74
+ targetName: protectedName,
75
+ });
76
+ attachProtectedConfig({
77
+ namespace,
78
+ deployment: appCode,
79
+ secretName: protectedName,
80
+ });
81
+
82
+ await waitHttp(`${publicUrl}/health/live`);
83
+ await waitHttp(`${apiUrl}/ativacao/status`);
84
+ console.log(
85
+ JSON.stringify(
86
+ {
87
+ appCode,
88
+ namespace,
89
+ publicUrl,
90
+ apiUrl,
91
+ activationUrl: `${publicUrl}/ativacao`,
92
+ imageDigest,
93
+ },
94
+ null,
95
+ 2,
96
+ ),
97
+ );
98
+ }
99
+
100
+ main().catch((error) => {
101
+ console.error(error.message);
102
+ process.exit(1);
103
+ });
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const path = require("node:path");
5
+ const { buildImage } = require("../src/delivery/buildImage");
6
+ function flag(name) { const index = process.argv.indexOf(name); return index < 0 ? undefined : process.argv[index + 1]; }
7
+ const workspace = path.resolve(flag("--workspace") || process.cwd());
8
+ const tag = flag("--tag");
9
+ const output = flag("--output");
10
+ if (!tag || !output) { console.error("--tag e --output são obrigatórios"); process.exit(2); }
11
+ buildImage({ workspace, tag, output: path.resolve(output) });
package/bin/cli.js CHANGED
@@ -3,93 +3,135 @@
3
3
 
4
4
  const path = require("node:path");
5
5
  const { spawnSync } = require("node:child_process");
6
+ const { ContractError, EXIT_CODES, sanitizeMessage } = require("../src/deploy/contracts/errors");
6
7
 
7
8
  const cmd = process.argv[2];
8
9
  const cwd = process.cwd();
9
10
 
10
- function loadManifest() {
11
- return require("../src/deploy/render").loadManifest(cwd);
12
- }
13
-
11
+ function loadManifest() { return require("../src/deploy/render").loadManifest(cwd); }
14
12
  function run(bin, args) {
15
- const r = spawnSync(bin, args, { stdio: "inherit", cwd });
16
- if (r.error) {
17
- console.error(`Falha ao executar ${bin}:`, r.error.message);
18
- process.exit(1);
13
+ const result = spawnSync(bin, args, { stdio: "inherit", cwd });
14
+ if (result.error) { console.error(`Falha ao executar ${bin}:`, sanitizeMessage(result.error.message)); process.exit(1); }
15
+ process.exit(result.status || 0);
16
+ }
17
+ function getFlagValue(args, flag) {
18
+ const index = args.indexOf(flag);
19
+ if (index === -1) return undefined;
20
+ const value = args[index + 1];
21
+ if (!value || value.startsWith("--")) throw new ContractError("USAGE_ERROR", `Valor obrigatório ausente para ${flag}.`);
22
+ return value;
23
+ }
24
+ function hasFlag(args, flag) { return args.includes(flag); }
25
+ function printJson(result) {
26
+ const safe = { ok: result.ok, contract: result.contract, version: result.version };
27
+ if (result.errors) safe.errors = result.errors;
28
+ console.log(JSON.stringify(safe, null, 2));
29
+ }
30
+ function errorPayload(err) {
31
+ const errors = err.details?.length ? err.details : [{ path: err.path || "$", message: sanitizeMessage(err.message) }];
32
+ return { ok: false, errors: errors.map((item) => ({ path: item.path || "$", message: sanitizeMessage(item.message) })), category: err.category || "ERROR" };
33
+ }
34
+ function handleExpectedError(err, { json, contract }) {
35
+ const payload = { ...errorPayload(err), contract };
36
+ if (json) printJson(payload);
37
+ else {
38
+ console.error(`${payload.category}: ${sanitizeMessage(err.message)}`);
39
+ for (const issue of payload.errors) console.error(` - ${issue.path}: ${issue.message}`);
40
+ }
41
+ process.exit(err.exitCode || 1);
42
+ }
43
+ function ensureNoUnknownFlags(args, allowed) {
44
+ for (let index = 0; index < args.length; index += 1) {
45
+ const value = args[index];
46
+ if (!value.startsWith("--")) continue;
47
+ if (!allowed.includes(value)) throw new ContractError("USAGE_ERROR", `Flag não suportada: ${value}`);
48
+ if (["--manifest", "--file", "--api"].includes(value)) index += 1;
19
49
  }
20
- process.exit(r.status || 0);
21
50
  }
22
51
 
23
52
  async function main() {
24
53
  switch (cmd) {
25
- case "start": {
26
- const { start } = require("../src");
27
- await start({ cwd });
28
- break;
29
- }
30
-
54
+ case "start": { const { start } = require("../src"); await start({ cwd }); break; }
31
55
  case "dev": {
32
- // Watch + restart usando o próprio Node (>=18.11 tem --watch).
33
56
  process.env.NODE_ENV = process.env.NODE_ENV || "development";
34
- const bootstrap = path.join(__dirname, "dev-runner.js");
35
- run(process.execPath, ["--watch", bootstrap]);
57
+ run(process.execPath, ["--watch", path.join(__dirname, "dev-runner.js")]);
36
58
  break;
37
59
  }
38
-
39
- case "activate": {
40
- const { activate } = require("../src");
41
- await activate({ cwd });
60
+ case "activate": { const { activate } = require("../src"); await activate({ cwd }); process.exit(0); break; }
61
+ case "deploy:validate": {
62
+ const args = process.argv.slice(3);
63
+ try {
64
+ ensureNoUnknownFlags(args, ["--manifest", "--json"]);
65
+ const json = hasFlag(args, "--json");
66
+ const manifest = getFlagValue(args, "--manifest") || path.join(cwd, "oon.deploy.json");
67
+ const result = require("../src/deploy/contracts/deploy-contract").validateDeployFile(path.resolve(cwd, manifest));
68
+ if (json) printJson(result); else console.log(`OK: ${result.contract} ${result.version} válido.`);
69
+ process.exit(0);
70
+ } catch (err) {
71
+ if (err instanceof ContractError) handleExpectedError(err, { json: hasFlag(args, "--json"), contract: "oon.deploy" });
72
+ throw err;
73
+ }
74
+ break;
75
+ }
76
+ case "infra:validate": {
77
+ const args = process.argv.slice(3);
78
+ try {
79
+ ensureNoUnknownFlags(args, ["--file", "--json"]);
80
+ const json = hasFlag(args, "--json");
81
+ const file = getFlagValue(args, "--file");
82
+ const infra = require("../src/deploy/contracts/infra-contract");
83
+ const result = file ? infra.validateInfraFile(path.resolve(cwd, file)) : infra.validateInfraFromCluster();
84
+ if (json) printJson(result); else console.log(`OK: ${result.contract} ${result.version} válido.`);
85
+ process.exit(0);
86
+ } catch (err) {
87
+ if (err instanceof ContractError) handleExpectedError(err, { json: hasFlag(args, "--json"), contract: "oon-platform-config" });
88
+ throw err;
89
+ }
90
+ break;
91
+ }
92
+ case "publish:request": {
93
+ const args = process.argv.slice(3);
94
+ ensureNoUnknownFlags(args, ["--api"]);
95
+ const result = await require("../src/delivery/publicationRequest").requestDevPublication({ cwd, apiUrl: getFlagValue(args, "--api") });
96
+ console.log(JSON.stringify(result, null, 2));
42
97
  process.exit(0);
43
98
  break;
44
99
  }
45
-
46
100
  case "build:image": {
47
- const m = loadManifest();
101
+ const manifest = loadManifest();
48
102
  require("../src/deploy/render").renderDockerfile({ cwd });
49
- const tag = `${m.IMAGE}:${m.IMAGE_TAG}`;
50
- console.log(`Construindo imagem ${tag}...`);
51
- run("docker", [
52
- "build",
53
- "--build-arg",
54
- `SERVICE_VERSION=${m.IMAGE_TAG}`,
55
- "-t",
56
- tag,
57
- ".",
58
- ]);
103
+ run("docker", ["build", "--build-arg", `SERVICE_VERSION=${manifest.IMAGE_TAG}`, "-t", `${manifest.IMAGE}:${manifest.IMAGE_TAG}`, "."]);
59
104
  break;
60
105
  }
61
-
62
106
  case "k8s:render": {
63
107
  const files = require("../src/deploy/render").renderK8s({ cwd });
64
- console.log("Manifests gerados:");
65
- files.forEach((f) => console.log(" -", path.relative(cwd, f)));
108
+ files.forEach((file) => console.log(" -", path.relative(cwd, file)));
66
109
  process.exit(0);
67
110
  break;
68
111
  }
69
-
70
112
  case "deploy": {
71
113
  const files = require("../src/deploy/render").renderK8s({ cwd });
72
- console.log("Aplicando manifests no cluster...");
73
- run("kubectl", ["apply", ...files.flatMap((f) => ["-f", f])]);
114
+ run("kubectl", ["apply", ...files.flatMap((file) => ["-f", file])]);
74
115
  break;
75
116
  }
76
-
77
117
  default:
78
118
  console.log(`oonCore-back <comando>
79
119
 
80
120
  Comandos:
81
- start Sobe a Central (produção).
82
- dev Sobe com watch/reload.
83
- activate Executa seed/ativação (singleton Sistema + hook).
84
- build:image Renderiza o Dockerfile e roda 'docker build'.
85
- k8s:render Renderiza os manifests k8s a partir de central.manifest.json.
86
- deploy Renderiza e aplica os manifests com 'kubectl apply'.
121
+ start Sobe a Central.
122
+ dev Sobe com watch/reload.
123
+ activate Executa seed/ativação.
124
+ deploy:validate Valida o contrato neutro oon.deploy.json.
125
+ infra:validate Valida o contrato oon-platform-config.
126
+ publish:request Solicita publicação dev usando GitHub OIDC.
127
+ build:image Legado: constrói imagem por central.manifest.json.
128
+ k8s:render Legado: renderiza manifests.
129
+ deploy Legado: executa kubectl apply.
130
+
131
+ Códigos de saída: ${Object.entries(EXIT_CODES).map(([key, value]) => `${value}=${key}`).join(", ")}.
87
132
  `);
88
133
  process.exit(cmd ? 1 : 0);
89
134
  }
90
135
  }
91
136
 
92
- main().catch((err) => {
93
- console.error(err);
94
- process.exit(1);
95
- });
137
+ main().catch((err) => { console.error(sanitizeMessage(err?.message || err)); process.exit(1); });
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { getContextWithPhase, setState, addEvidence } = require("../src/delivery/api");
7
+ const { provisionRuntime, deployRuntime, waitHttp, activateMember } = require("../src/delivery/runtime");
8
+
9
+ function flag(name, fallback) {
10
+ const args = process.argv.slice(3);
11
+ const index = args.indexOf(name);
12
+ if (index < 0) return fallback;
13
+ const value = args[index + 1];
14
+ if (!value || value.startsWith("--")) throw new Error(`Valor ausente para ${name}.`);
15
+ return value;
16
+ }
17
+ function required(value, name) { if (!value) throw new Error(`${name} obrigatório.`); return value; }
18
+ function secret() { return required(process.env.OON_DELIVERY_SHARED_SECRET, "OON_DELIVERY_SHARED_SECRET"); }
19
+
20
+ async function main() {
21
+ const command = process.argv[2];
22
+ const releaseId = flag("--release", process.env.RELEASE_ID);
23
+ const apiUrl = flag("--api", process.env.ACTIVATION_API_URL);
24
+
25
+ if (command === "context") {
26
+ const phase = flag("--phase", "build");
27
+ const output = required(flag("--output"), "--output");
28
+ const context = await getContextWithPhase({ apiUrl: required(apiUrl, "--api"), releaseId: required(releaseId, "--release"), token: secret(), phase });
29
+ fs.writeFileSync(path.resolve(output), JSON.stringify(context, null, 2), { mode: 0o600 });
30
+ return;
31
+ }
32
+ if (command === "state") {
33
+ await setState({
34
+ apiUrl: required(apiUrl, "--api"), releaseId: required(releaseId, "--release"), token: secret(),
35
+ etapa: required(flag("--state"), "--state"), imageDigest: flag("--image-digest"),
36
+ erroCodigo: flag("--error-code"), erroMensagem: flag("--error-message"),
37
+ });
38
+ return;
39
+ }
40
+ if (command === "deploy") {
41
+ const contextFile = required(flag("--context"), "--context");
42
+ const imageDigest = required(flag("--image-digest"), "--image-digest");
43
+ const context = JSON.parse(fs.readFileSync(path.resolve(contextFile), "utf8"));
44
+ let failureState = "falha_infra";
45
+ try {
46
+ await setState({ apiUrl, releaseId, token: secret(), etapa: "provisionando", imageDigest });
47
+ const provisioned = await provisionRuntime({ context });
48
+ failureState = "falha_deploy";
49
+ await setState({ apiUrl, releaseId, token: secret(), etapa: "implantando", imageDigest });
50
+ const deployed = deployRuntime({ context, infra: provisioned.infra, imageDigest, resourceProfile: context.resourceProfile });
51
+ await waitHttp(`${context.publicUrl}/health/live`);
52
+ failureState = "falha_ativacao";
53
+ await setState({ apiUrl, releaseId, token: secret(), etapa: "ativando", imageDigest });
54
+ const activation = await activateMember(context);
55
+ failureState = "falha_saude";
56
+ await setState({ apiUrl, releaseId, token: secret(), etapa: "validando_saude", imageDigest, instanciaId: activation.instanceId });
57
+ await waitHttp(`${context.publicUrl}/api/ativacao/status`);
58
+ await addEvidence({ apiUrl, releaseId, token: secret(), evidence: { kind: "deployment", imageDigest, infraVersion: deployed.infraVersion, namespace: context.namespace, publicUrl: context.publicUrl, health: "ok" } });
59
+ await setState({ apiUrl, releaseId, token: secret(), etapa: "publicada", imageDigest, instanciaId: activation.instanceId, urlPublica: context.publicUrl });
60
+ console.log(`Publicação concluída: ${context.publicUrl}`);
61
+ } catch (error) {
62
+ try { await setState({ apiUrl, releaseId, token: secret(), etapa: failureState, erroCodigo: failureState.toUpperCase(), erroMensagem: error.message, imageDigest }); }
63
+ catch (reportError) { console.error(`Falha ao reportar erro: ${reportError.message}`); }
64
+ throw error;
65
+ } finally {
66
+ delete context.activationCode;
67
+ fs.writeFileSync(path.resolve(contextFile), JSON.stringify(context, null, 2), { mode: 0o600 });
68
+ }
69
+ return;
70
+ }
71
+ throw new Error(`Comando desconhecido: ${command}`);
72
+ }
73
+
74
+ main().catch((error) => { console.error(error.message); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+
3
+ function endpoint(apiUrl, releaseId, suffix) {
4
+ return `${String(apiUrl).replace(/\/$/, "")}/integracao/publicacoes/${encodeURIComponent(releaseId)}${suffix}`;
5
+ }
6
+
7
+ async function request(url, { token, method = "GET", body } = {}) {
8
+ const response = await fetch(url, {
9
+ method,
10
+ headers: {
11
+ "x-oon-delivery-token": token,
12
+ "Content-Type": "application/json",
13
+ },
14
+ body: body === undefined ? undefined : JSON.stringify(body),
15
+ });
16
+ const data = await response.json().catch(() => ({}));
17
+ if (!response.ok) {
18
+ const error = new Error(data?.error?.message || data?.message || `HTTP ${response.status}`);
19
+ error.statusCode = response.status;
20
+ error.code = data?.error?.code;
21
+ throw error;
22
+ }
23
+ return data;
24
+ }
25
+
26
+ async function getContextWithPhase({ apiUrl, releaseId, token, phase }) {
27
+ const response = await fetch(endpoint(apiUrl, releaseId, "/contexto"), {
28
+ headers: { "x-oon-delivery-token": token, "x-oon-delivery-phase": phase },
29
+ });
30
+ const data = await response.json().catch(() => ({}));
31
+ if (!response.ok) throw new Error(data?.error?.message || data?.message || `HTTP ${response.status}`);
32
+ return data;
33
+ }
34
+
35
+ function setState({ apiUrl, releaseId, token, etapa, ...details }) {
36
+ return request(endpoint(apiUrl, releaseId, "/estado"), { token, method: "POST", body: { etapa, ...details } });
37
+ }
38
+ function addEvidence({ apiUrl, releaseId, token, evidence }) {
39
+ return request(endpoint(apiUrl, releaseId, "/evidencias"), { token, method: "POST", body: evidence });
40
+ }
41
+
42
+ module.exports = { endpoint, request, getContextWithPhase, setState, addEvidence };
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { run } = require("./command");
6
+
7
+ const DOCKERFILE = `# syntax=docker/dockerfile:1.7
8
+ FROM node:20-alpine AS frontend
9
+ WORKDIR /src/frontend
10
+ COPY frontend/package*.json ./
11
+ RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
12
+ COPY frontend/ ./
13
+ ARG VITE_API_URL=/api
14
+ ENV VITE_API_URL=$VITE_API_URL
15
+ RUN npm run build
16
+
17
+ FROM node:20-alpine AS backend
18
+ WORKDIR /app/backend
19
+ COPY backend/package*.json ./
20
+ RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
21
+ COPY backend/ ./
22
+
23
+ FROM node:20-alpine
24
+ RUN apk add --no-cache nginx curl
25
+ WORKDIR /app
26
+ ENV NODE_ENV=production
27
+ COPY --from=backend /app/backend /app/backend
28
+ COPY --from=frontend /src/frontend/dist /usr/share/nginx/html
29
+ COPY .oon-delivery/nginx.conf /etc/nginx/http.d/default.conf
30
+ COPY .oon-delivery/entrypoint.sh /entrypoint.sh
31
+ RUN chmod +x /entrypoint.sh && mkdir -p /app/backend/uploads /run/nginx
32
+ EXPOSE 8080
33
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD curl -fsS http://127.0.0.1:8080/health/live || exit 1
34
+ ENTRYPOINT ["/entrypoint.sh"]
35
+ `;
36
+
37
+ const NGINX = `server {
38
+ listen 8080;
39
+ server_name _;
40
+ root /usr/share/nginx/html;
41
+ location = /health/live { access_log off; return 200 'ok'; add_header Content-Type text/plain; }
42
+ location /api/ {
43
+ proxy_pass http://127.0.0.1:4000/;
44
+ proxy_http_version 1.1;
45
+ proxy_set_header Host $host;
46
+ proxy_set_header X-Forwarded-Proto $scheme;
47
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
48
+ }
49
+ location / { try_files $uri $uri/ /index.html; }
50
+ }
51
+ `;
52
+
53
+ const ENTRYPOINT = `#!/bin/sh
54
+ set -eu
55
+ cd /app/backend
56
+ export PORT=4000
57
+ ./node_modules/.bin/oonCore-back start &
58
+ backend_pid=$!
59
+ trap 'kill $backend_pid 2>/dev/null || true' TERM INT EXIT
60
+ exec nginx -g 'daemon off;'
61
+ `;
62
+
63
+ function prepareDeliveryFiles(workspace) {
64
+ const target = path.join(workspace, ".oon-delivery");
65
+ fs.mkdirSync(target, { recursive: true });
66
+ fs.writeFileSync(path.join(target, "Dockerfile"), DOCKERFILE);
67
+ fs.writeFileSync(path.join(target, "nginx.conf"), NGINX);
68
+ fs.writeFileSync(path.join(target, "entrypoint.sh"), ENTRYPOINT, {
69
+ mode: 0o755,
70
+ });
71
+ return path.join(target, "Dockerfile");
72
+ }
73
+
74
+ function buildImage({ workspace, tag, output }) {
75
+ const dockerfile = prepareDeliveryFiles(workspace);
76
+ run("docker", ["build", "-f", dockerfile, "-t", tag, workspace]);
77
+ run("docker", ["save", "-o", output, tag]);
78
+ return { dockerfile, output, tag };
79
+ }
80
+
81
+ module.exports = {
82
+ DOCKERFILE,
83
+ NGINX,
84
+ ENTRYPOINT,
85
+ prepareDeliveryFiles,
86
+ buildImage,
87
+ };
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ const { spawnSync } = require("node:child_process");
4
+
5
+ function run(command, args, options = {}) {
6
+ const result = spawnSync(command, args, {
7
+ cwd: options.cwd || process.cwd(),
8
+ encoding: "utf8",
9
+ stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
10
+ env: { ...process.env, ...(options.env || {}) },
11
+ });
12
+ if (result.error) throw result.error;
13
+ if (result.status !== 0) {
14
+ const message = options.capture ? String(result.stderr || result.stdout || "").slice(0, 2000) : `${command} falhou`;
15
+ throw new Error(message);
16
+ }
17
+ return options.capture ? String(result.stdout || "").trim() : result;
18
+ }
19
+
20
+ function kubectl(args, options = {}) { return run("kubectl", args, options); }
21
+
22
+ module.exports = { run, kubectl };
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+
3
+ const { spawnSync } = require("node:child_process");
4
+ const { kubectl } = require("./command");
5
+
6
+ function applyJson(document) {
7
+ const result = spawnSync("kubectl", ["apply", "-f", "-"], {
8
+ input: JSON.stringify(document),
9
+ encoding: "utf8",
10
+ stdio: ["pipe", "inherit", "inherit"],
11
+ });
12
+ if (result.status !== 0) {
13
+ throw new Error("Falha ao copiar configuração protegida.");
14
+ }
15
+ }
16
+
17
+ function copyProtectedConfig({
18
+ sourceNamespace,
19
+ sourceName,
20
+ targetNamespace,
21
+ targetName,
22
+ }) {
23
+ const source = JSON.parse(
24
+ kubectl(
25
+ ["-n", sourceNamespace, "get", "secret", sourceName, "-o", "json"],
26
+ { capture: true },
27
+ ),
28
+ );
29
+ for (const key of [
30
+ "namespace",
31
+ "resourceVersion",
32
+ "uid",
33
+ "creationTimestamp",
34
+ "managedFields",
35
+ ]) {
36
+ delete source.metadata[key];
37
+ }
38
+ source.metadata.namespace = targetNamespace;
39
+ source.metadata.name = targetName;
40
+ applyJson(source);
41
+ }
42
+
43
+ function attachProtectedConfig({ namespace, deployment, secretName }) {
44
+ const patch = {
45
+ spec: {
46
+ template: {
47
+ spec: {
48
+ containers: [
49
+ {
50
+ name: "central",
51
+ envFrom: [
52
+ { secretRef: { name: "oon-runtime" } },
53
+ { secretRef: { name: secretName } },
54
+ ],
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ },
60
+ };
61
+ kubectl([
62
+ "-n",
63
+ namespace,
64
+ "patch",
65
+ "deployment",
66
+ deployment,
67
+ "--type=strategic",
68
+ "-p",
69
+ JSON.stringify(patch),
70
+ ]);
71
+ kubectl([
72
+ "-n",
73
+ namespace,
74
+ "rollout",
75
+ "status",
76
+ `deployment/${deployment}`,
77
+ "--timeout=10m",
78
+ ]);
79
+ }
80
+
81
+ module.exports = { copyProtectedConfig, attachProtectedConfig };
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+ const { validateDeployFile } = require("../deploy/contracts/deploy-contract");
5
+
6
+ const DEFAULT_ACTIVATION_API = "https://central-ativacao.central.oondemand.online/api";
7
+
8
+ async function githubOidcToken(audience) {
9
+ const base = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
10
+ const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
11
+ if (!base || !requestToken) {
12
+ const error = new Error("GitHub Actions OIDC indisponível. O workflow precisa de permissions.id-token=write.");
13
+ error.code = "GITHUB_OIDC_UNAVAILABLE";
14
+ throw error;
15
+ }
16
+ const separator = base.includes("?") ? "&" : "?";
17
+ const response = await fetch(`${base}${separator}audience=${encodeURIComponent(audience)}`, {
18
+ headers: { Authorization: `Bearer ${requestToken}` },
19
+ });
20
+ if (!response.ok) throw new Error(`Falha ao obter OIDC do GitHub: HTTP ${response.status}.`);
21
+ const data = await response.json();
22
+ if (!data.value) throw new Error("Resposta OIDC do GitHub não contém token.");
23
+ return data.value;
24
+ }
25
+
26
+ async function requestDevPublication({ cwd = process.cwd(), apiUrl } = {}) {
27
+ const result = validateDeployFile(path.join(cwd, "oon.deploy.json"));
28
+ const activationApi = String(apiUrl || process.env.CENTRAL_ATIVACAO_API_URL || DEFAULT_ACTIVATION_API).replace(/\/$/, "");
29
+ const token = await githubOidcToken(activationApi);
30
+ const response = await fetch(`${activationApi}/integracao/publicacoes/solicitar`, {
31
+ method: "POST",
32
+ headers: {
33
+ Authorization: `Bearer ${token}`,
34
+ "Content-Type": "application/json",
35
+ "Idempotency-Key": `${process.env.GITHUB_REPOSITORY || "local"}:${process.env.GITHUB_SHA || "unknown"}:dev`,
36
+ },
37
+ body: JSON.stringify({ appCode: result.manifest.appCode }),
38
+ });
39
+ const data = await response.json().catch(() => ({}));
40
+ if (!response.ok) {
41
+ const error = new Error(`Publicação dev não autorizada: ${data?.error?.message || data?.message || `HTTP ${response.status}`}`);
42
+ error.code = data?.error?.code || "PUBLICATION_REQUEST_FAILED";
43
+ error.statusCode = response.status;
44
+ throw error;
45
+ }
46
+ return { activationApi, ...data };
47
+ }
48
+
49
+ module.exports = { DEFAULT_ACTIVATION_API, githubOidcToken, requestDevPublication };
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const { mongo } = require("mongoose");
5
+ const { spawnSync } = require("node:child_process");
6
+ const { kubectl } = require("./command");
7
+ const { validateInfraFromCluster } = require("../deploy/contracts/infra-contract");
8
+
9
+ const RESOURCE_PROFILES = {
10
+ nano: { cpuRequest: "50m", cpuLimit: "250m", memoryRequest: "96Mi", memoryLimit: "256Mi" },
11
+ small: { cpuRequest: "100m", cpuLimit: "500m", memoryRequest: "128Mi", memoryLimit: "512Mi" },
12
+ medium: { cpuRequest: "250m", cpuLimit: "1", memoryRequest: "256Mi", memoryLimit: "1Gi" },
13
+ large: { cpuRequest: "500m", cpuLimit: "2", memoryRequest: "512Mi", memoryLimit: "2Gi" },
14
+ };
15
+
16
+ function randomSecret(bytes = 32) { return crypto.randomBytes(bytes).toString("base64url"); }
17
+ function dnsLabel(value, max = 63) {
18
+ return String(value || "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, max).replace(/-+$/, "");
19
+ }
20
+ function applyYaml(yaml) {
21
+ const result = spawnSync("kubectl", ["apply", "-f", "-"], { input: yaml, encoding: "utf8", stdio: ["pipe", "inherit", "inherit"] });
22
+ if (result.status !== 0) throw new Error("kubectl apply falhou.");
23
+ }
24
+ function readSecret(namespace, name, key) {
25
+ try {
26
+ const value = kubectl(["-n", namespace, "get", "secret", name, "-o", `jsonpath={.data.${key}}`], { capture: true });
27
+ return value ? Buffer.from(value, "base64").toString("utf8") : null;
28
+ } catch { return null; }
29
+ }
30
+
31
+ async function provisionMongo({ adminUri, appCode, environment }) {
32
+ const dbName = dnsLabel(`${appCode}_${environment}`, 48).replace(/-/g, "_");
33
+ const username = dnsLabel(`${appCode}-${environment}`, 48);
34
+ const password = randomSecret(30);
35
+ const client = new mongo.MongoClient(adminUri, { serverSelectionTimeoutMS: 15000 });
36
+ await client.connect();
37
+ try {
38
+ const db = client.db(dbName);
39
+ try { await db.command({ createUser: username, pwd: password, roles: [{ role: "readWrite", db: dbName }] }); }
40
+ catch (error) {
41
+ if (error.codeName !== "DuplicateKey" && error.code !== 51003 && !/already exists/i.test(error.message)) throw error;
42
+ await db.command({ updateUser: username, pwd: password, roles: [{ role: "readWrite", db: dbName }] });
43
+ }
44
+ } finally { await client.close(); }
45
+ const uri = new URL(adminUri);
46
+ uri.username = username;
47
+ uri.password = password;
48
+ uri.pathname = `/${dbName}`;
49
+ uri.searchParams.set("authSource", dbName);
50
+ return { dbName, mongoUri: uri.toString() };
51
+ }
52
+
53
+ function baselineManifest({ context }) {
54
+ return `apiVersion: v1
55
+ kind: Namespace
56
+ metadata:
57
+ name: ${context.namespace}
58
+ labels:
59
+ oon.dev/app-code: ${context.appCode}
60
+ oon.dev/environment: ${context.environment}
61
+ app.kubernetes.io/managed-by: oon-platform
62
+ ---
63
+ apiVersion: v1
64
+ kind: ServiceAccount
65
+ metadata: { name: oon-runtime, namespace: ${context.namespace} }
66
+ automountServiceAccountToken: false
67
+ ---
68
+ apiVersion: v1
69
+ kind: LimitRange
70
+ metadata: { name: oon-runtime-defaults, namespace: ${context.namespace} }
71
+ spec:
72
+ limits:
73
+ - type: Container
74
+ defaultRequest: { cpu: 100m, memory: 128Mi }
75
+ default: { cpu: 500m, memory: 512Mi }
76
+ ---
77
+ apiVersion: v1
78
+ kind: ResourceQuota
79
+ metadata: { name: oon-runtime-quota, namespace: ${context.namespace} }
80
+ spec:
81
+ hard: { requests.cpu: "2", requests.memory: 2Gi, limits.cpu: "4", limits.memory: 4Gi, pods: "20", services: "10" }
82
+ ---
83
+ apiVersion: networking.k8s.io/v1
84
+ kind: NetworkPolicy
85
+ metadata: { name: oon-runtime-baseline, namespace: ${context.namespace} }
86
+ spec:
87
+ podSelector: {}
88
+ policyTypes: [Ingress, Egress]
89
+ ingress:
90
+ - from:
91
+ - namespaceSelector:
92
+ matchLabels: { kubernetes.io/metadata.name: ingress-nginx }
93
+ egress:
94
+ - {}
95
+ `;
96
+ }
97
+
98
+ function runtimeManifest({ context, infra, imageRef, profile = "small" }) {
99
+ const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
100
+ return `apiVersion: apps/v1
101
+ kind: Deployment
102
+ metadata:
103
+ name: ${context.appCode}
104
+ namespace: ${context.namespace}
105
+ spec:
106
+ replicas: 1
107
+ selector:
108
+ matchLabels: { app.kubernetes.io/name: ${context.appCode} }
109
+ template:
110
+ metadata:
111
+ labels:
112
+ app.kubernetes.io/name: ${context.appCode}
113
+ app.kubernetes.io/part-of: oon-central
114
+ oon.dev/release-id: ${context.releaseId}
115
+ spec:
116
+ serviceAccountName: oon-runtime
117
+ imagePullSecrets: [{ name: ${infra.REGISTRY_PULL_SECRET_NAME || "ghcr-oon"} }]
118
+ containers:
119
+ - name: central
120
+ image: ${imageRef}
121
+ imagePullPolicy: IfNotPresent
122
+ ports: [{ name: http, containerPort: 8080 }]
123
+ envFrom: [{ secretRef: { name: oon-runtime } }]
124
+ resources:
125
+ requests: { cpu: ${resources.cpuRequest}, memory: ${resources.memoryRequest} }
126
+ limits: { cpu: ${resources.cpuLimit}, memory: ${resources.memoryLimit} }
127
+ livenessProbe:
128
+ httpGet: { path: /health/live, port: http }
129
+ initialDelaySeconds: 20
130
+ readinessProbe:
131
+ httpGet: { path: /api/ativacao/status, port: http }
132
+ initialDelaySeconds: 10
133
+ ---
134
+ apiVersion: v1
135
+ kind: Service
136
+ metadata: { name: ${context.appCode}, namespace: ${context.namespace} }
137
+ spec:
138
+ selector: { app.kubernetes.io/name: ${context.appCode} }
139
+ ports: [{ name: http, port: 80, targetPort: http }]
140
+ ---
141
+ apiVersion: networking.k8s.io/v1
142
+ kind: Ingress
143
+ metadata:
144
+ name: ${context.appCode}
145
+ namespace: ${context.namespace}
146
+ annotations: { cert-manager.io/cluster-issuer: ${infra.CLUSTER_ISSUER} }
147
+ spec:
148
+ ingressClassName: ${infra.INGRESS_CLASS}
149
+ tls: [{ hosts: [${context.hostname}], secretName: ${context.appCode}-tls }]
150
+ rules:
151
+ - host: ${context.hostname}
152
+ http:
153
+ paths:
154
+ - path: /
155
+ pathType: Prefix
156
+ backend:
157
+ service: { name: ${context.appCode}, port: { name: http } }
158
+ `;
159
+ }
160
+
161
+ function copyPullSecret(namespace, infra) {
162
+ const source = infra.REGISTRY_PULL_SECRET_NAME || "ghcr-oon";
163
+ try {
164
+ const secret = JSON.parse(kubectl(["-n", infra.PLATFORM_NAMESPACE || "oon-system", "get", "secret", source, "-o", "json"], { capture: true }));
165
+ for (const key of ["namespace", "resourceVersion", "uid", "creationTimestamp", "managedFields"]) delete secret.metadata[key];
166
+ secret.metadata.namespace = namespace;
167
+ applyYaml(JSON.stringify(secret));
168
+ } catch (error) { if (!/not found/i.test(error.message)) throw error; }
169
+ }
170
+
171
+ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {} }) {
172
+ const literals = {
173
+ MONGO_URI: mongoUri,
174
+ NODE_ENV: "production",
175
+ APP_ENVIRONMENT: context.environment,
176
+ APP_CODE: context.appCode,
177
+ PUBLIC_APP_URL: context.publicUrl,
178
+ CENTRAL_ATIVACAO_URL: context.activationApiUrl,
179
+ CENTRAL_ATIVACAO_API_URL: context.activationApiUrl,
180
+ INSTANCE_CREDENTIAL_ENCRYPTION_KEY: encryptionKey,
181
+ ...extraSecrets,
182
+ };
183
+ const args = ["-n", context.namespace, "create", "secret", "generic", "oon-runtime"];
184
+ for (const [key, value] of Object.entries(literals)) args.push(`--from-literal=${key}=${value}`);
185
+ args.push("--dry-run=client", "-o", "yaml");
186
+ applyYaml(kubectl(args, { capture: true }));
187
+ }
188
+
189
+ async function provisionRuntime({ context, extraSecrets = {} }) {
190
+ const infra = validateInfraFromCluster().config;
191
+ applyYaml(baselineManifest({ context, infra }));
192
+ let mongoUri = readSecret(context.namespace, "oon-runtime", "MONGO_URI");
193
+ let databaseName = "";
194
+ if (!mongoUri) {
195
+ const adminUri = readSecret(infra.PLATFORM_NAMESPACE || "oon-system", infra.MONGO_ADMIN_SECRET_NAME || "oon-mongo-admin", "MONGO_ADMIN_URI");
196
+ if (!adminUri) throw new Error("MongoDB administrativo não configurado na plataforma.");
197
+ const provisioned = await provisionMongo({ adminUri, appCode: context.appCode, environment: context.environment });
198
+ mongoUri = provisioned.mongoUri;
199
+ databaseName = provisioned.dbName;
200
+ }
201
+ const encryptionKey = readSecret(context.namespace, "oon-runtime", "INSTANCE_CREDENTIAL_ENCRYPTION_KEY") || randomSecret(48);
202
+ applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets });
203
+ return { infra, databaseName };
204
+ }
205
+
206
+ function deployRuntime({ context, infra, imageDigest, resourceProfile = "small" }) {
207
+ copyPullSecret(context.namespace, infra);
208
+ const imageRef = `${context.imageRepository}@${imageDigest}`;
209
+ applyYaml(runtimeManifest({ context, infra, imageRef, profile: resourceProfile }));
210
+ kubectl(["-n", context.namespace, "rollout", "status", `deployment/${context.appCode}`, "--timeout=10m"]);
211
+ return { infraVersion: infra.INFRA_SCHEMA_VERSION, imageRef };
212
+ }
213
+
214
+ async function waitHttp(url, { timeoutMs = 300000, expectedStatus = 200 } = {}) {
215
+ const deadline = Date.now() + timeoutMs;
216
+ let last;
217
+ while (Date.now() < deadline) {
218
+ try {
219
+ const response = await fetch(url, { redirect: "manual" });
220
+ last = `HTTP ${response.status}`;
221
+ if (response.status === expectedStatus) return response;
222
+ } catch (error) { last = error.message; }
223
+ await new Promise((resolve) => setTimeout(resolve, 5000));
224
+ }
225
+ throw new Error(`Timeout aguardando ${url}: ${last || "sem resposta"}`);
226
+ }
227
+
228
+ async function activateMember(context) {
229
+ const statusResponse = await waitHttp(`${context.publicUrl}/api/ativacao/status`);
230
+ const status = await statusResponse.json();
231
+ if (status.status === "ativa") return { alreadyActive: true, instanceId: status.instanceId };
232
+ if (!context.activationCode) throw new Error("Instância não está ativa e não há código de ativação autorizado.");
233
+ const response = await fetch(`${context.publicUrl}/api/ativacao/concluir`, {
234
+ method: "POST",
235
+ headers: { "Content-Type": "application/json", "Idempotency-Key": context.releaseId },
236
+ body: JSON.stringify({
237
+ codigoAtivacao: context.activationCode,
238
+ nomeInstancia: `${context.appCode} dev`,
239
+ parametrosGlobais: {
240
+ nomeInstancia: `${context.appCode} dev`, ambiente: context.environment,
241
+ urlPublica: context.publicUrl, emailAdministrativo: context.administrativeEmail,
242
+ idioma: "pt-BR", timezone: "America/Sao_Paulo", moeda: "BRL", pais: "BR", formatoData: "DD/MM/YYYY",
243
+ },
244
+ parametrosAplicacao: {},
245
+ }),
246
+ });
247
+ const data = await response.json().catch(() => ({}));
248
+ if (!response.ok) throw new Error(data?.error?.message || data?.message || `Ativação falhou: HTTP ${response.status}`);
249
+ return data;
250
+ }
251
+
252
+ module.exports = { RESOURCE_PROFILES, randomSecret, dnsLabel, provisionMongo, baselineManifest, runtimeManifest, provisionRuntime, deployRuntime, waitHttp, activateMember };
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { ContractError, toIssue, isSensitiveKey } = require("./errors");
6
+
7
+ const CONTRACT = "oon.deploy";
8
+ const SUPPORTED_MAJOR = 1;
9
+ const RUNTIMES = ["ooncore-node-react"];
10
+ const RESOURCE_PROFILES = ["nano", "small", "medium", "large"];
11
+ const REQUIRED = ["schemaVersion", "appCode", "runtime", "components", "resourceProfile", "health"];
12
+ const COMPONENTS = ["backend", "frontend"];
13
+ const HEALTH = ["livePath", "readyPath", "versionPath"];
14
+
15
+ function readJsonFile(file) {
16
+ let raw;
17
+ try { raw = fs.readFileSync(file, "utf8"); }
18
+ catch (err) {
19
+ if (err.code === "ENOENT") throw new ContractError("FILE_NOT_FOUND", `Arquivo não encontrado: ${file}`);
20
+ throw new ContractError("FILE_NOT_FOUND", `Não foi possível ler o arquivo: ${file}`);
21
+ }
22
+ try { return JSON.parse(raw); }
23
+ catch { throw new ContractError("INVALID_JSON", `JSON inválido em ${file}`); }
24
+ }
25
+
26
+ function majorOf(version) {
27
+ const match = String(version || "").match(/^(\d+)\.\d+\.\d+$/);
28
+ return match ? Number(match[1]) : null;
29
+ }
30
+
31
+ function validRelativePath(value) {
32
+ if (typeof value !== "string" || !value.trim() || path.isAbsolute(value)) return false;
33
+ const normalized = path.posix.normalize(value.replace(/\\/g, "/"));
34
+ return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
35
+ }
36
+
37
+ function walkSecrets(value, currentPath, issues) {
38
+ if (!value || typeof value !== "object") return;
39
+ for (const [key, child] of Object.entries(value)) {
40
+ const childPath = `${currentPath}.${key}`;
41
+ if (isSensitiveKey(key)) issues.push(toIssue(childPath, "Campo com semântica de segredo/credencial não é permitido."));
42
+ walkSecrets(child, childPath, issues);
43
+ }
44
+ }
45
+
46
+ function noExtra(object, allowed, base, issues) {
47
+ for (const key of Object.keys(object || {})) {
48
+ if (!allowed.includes(key)) issues.push(toIssue(`${base}.${key}`, "Propriedade não permitida neste contrato."));
49
+ }
50
+ }
51
+
52
+ function validateDeployContract(manifest) {
53
+ const issues = [];
54
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
55
+ issues.push(toIssue("$", "O manifesto deve ser um objeto JSON."));
56
+ } else {
57
+ const major = majorOf(manifest.schemaVersion);
58
+ if (major === null) issues.push(toIssue("$.schemaVersion", "schemaVersion deve seguir semver."));
59
+ else if (major !== SUPPORTED_MAJOR) throw new ContractError("UNSUPPORTED_CONTRACT_VERSION", `Major não suportado: ${manifest.schemaVersion}. Migre para 1.x.`, { path: "$.schemaVersion" });
60
+
61
+ REQUIRED.forEach((key) => { if (manifest[key] === undefined) issues.push(toIssue(`$.${key}`, "Campo obrigatório ausente.")); });
62
+ noExtra(manifest, REQUIRED, "$", issues);
63
+ walkSecrets(manifest, "$", issues);
64
+
65
+ if (manifest.appCode !== undefined && !/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(manifest.appCode)) issues.push(toIssue("$.appCode", "appCode deve ser compatível com DNS/Kubernetes."));
66
+ if (manifest.runtime !== undefined && !RUNTIMES.includes(manifest.runtime)) issues.push(toIssue("$.runtime", "Runtime não suportado."));
67
+ if (manifest.resourceProfile !== undefined && !RESOURCE_PROFILES.includes(manifest.resourceProfile)) issues.push(toIssue("$.resourceProfile", "Perfil de recursos não suportado."));
68
+
69
+ if (manifest.components && typeof manifest.components === "object" && !Array.isArray(manifest.components)) {
70
+ noExtra(manifest.components, COMPONENTS, "$.components", issues);
71
+ COMPONENTS.forEach((name) => {
72
+ const component = manifest.components[name];
73
+ if (!component || typeof component !== "object" || Array.isArray(component)) issues.push(toIssue(`$.components.${name}`, "Componente obrigatório ausente ou inválido."));
74
+ else {
75
+ noExtra(component, ["path"], `$.components.${name}`, issues);
76
+ if (!validRelativePath(component.path)) issues.push(toIssue(`$.components.${name}.path`, "path deve ser relativo e não pode escapar do workspace."));
77
+ }
78
+ });
79
+ } else if (manifest.components !== undefined) issues.push(toIssue("$.components", "components deve ser um objeto."));
80
+
81
+ if (manifest.health && typeof manifest.health === "object" && !Array.isArray(manifest.health)) {
82
+ noExtra(manifest.health, HEALTH, "$.health", issues);
83
+ HEALTH.forEach((key) => {
84
+ if (typeof manifest.health[key] !== "string" || !manifest.health[key].startsWith("/")) issues.push(toIssue(`$.health.${key}`, "Health path deve iniciar por /."));
85
+ });
86
+ } else if (manifest.health !== undefined) issues.push(toIssue("$.health", "health deve ser um objeto."));
87
+ }
88
+ if (issues.length) throw new ContractError("SCHEMA_VALIDATION_ERROR", "oon.deploy.json inválido.", { details: issues });
89
+ return { ok: true, contract: CONTRACT, version: manifest.schemaVersion, manifest };
90
+ }
91
+
92
+ function validateDeployFile(file = path.join(process.cwd(), "oon.deploy.json")) {
93
+ return validateDeployContract(readJsonFile(file));
94
+ }
95
+
96
+ module.exports = { validateDeployContract, validateDeployFile, readJsonFile, RUNTIMES, RESOURCE_PROFILES };
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+
3
+ const EXIT_CODES = {
4
+ USAGE_ERROR: 2,
5
+ FILE_NOT_FOUND: 3,
6
+ INVALID_JSON: 4,
7
+ SCHEMA_VALIDATION_ERROR: 5,
8
+ UNSUPPORTED_CONTRACT_VERSION: 6,
9
+ KUBECTL_UNAVAILABLE: 7,
10
+ KUBERNETES_ACCESS_ERROR: 8,
11
+ CONFIGMAP_NOT_FOUND: 9,
12
+ };
13
+
14
+ class ContractError extends Error {
15
+ constructor(category, message, { path = "$", details = [] } = {}) {
16
+ super(message);
17
+ this.name = "ContractError";
18
+ this.category = category;
19
+ this.path = path;
20
+ this.details = details;
21
+ this.exitCode = EXIT_CODES[category] || 1;
22
+ }
23
+ }
24
+
25
+ const SENSITIVE_RE = /(secret|token|password|passwd|pwd|kubeconfig|credential|connection\s*string|connectionstring|db[_-]?uri|database[_-]?uri|mongo[_-]?uri|mongodb|private[_-]?key|access[_-]?key)/i;
26
+
27
+ function sanitizeMessage(message) {
28
+ return String(message || "")
29
+ .replace(/(mongodb(?:\+srv)?:\/\/)[^\s"']+/gi, "$1[REDACTED]")
30
+ .replace(/(postgres(?:ql)?:\/\/)[^\s"']+/gi, "$1[REDACTED]")
31
+ .replace(/(mysql:\/\/)[^\s"']+/gi, "$1[REDACTED]")
32
+ .replace(/(Bearer\s+)[A-Za-z0-9._~+\-/]+=*/gi, "$1[REDACTED]")
33
+ .replace(/([A-Za-z0-9_ -]*(?:secret|token|password|kubeconfig|credential|connectionstring|connection string)[A-Za-z0-9_ -]*\s*[=:]\s*)[^\s,;]+/gi, "$1[REDACTED]");
34
+ }
35
+
36
+ function toIssue(path, message) {
37
+ return { path, message: sanitizeMessage(message) };
38
+ }
39
+
40
+ function isSensitiveKey(key) {
41
+ return SENSITIVE_RE.test(String(key));
42
+ }
43
+
44
+ module.exports = { ContractError, EXIT_CODES, sanitizeMessage, toIssue, isSensitiveKey };
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+
3
+ const { spawnSync } = require("node:child_process");
4
+ const { ContractError, toIssue } = require("./errors");
5
+ const { readJsonFile } = require("./deploy-contract");
6
+
7
+ const CONTRACT = "oon-platform-config";
8
+ const SUPPORTED_MAJORS = new Set([1, 2]);
9
+ const REQUIRED_COMMON = ["DNS", "DEFAULT_PROTOCOL", "INGRESS_CLASS", "CLUSTER_ISSUER", "INFRA_SCHEMA_VERSION"];
10
+ const REQUIRED_V2 = ["REGISTRY_HOST", "PLATFORM_NAMESPACE", "DELIVERY_NAMESPACE", "RUNTIME_NAMESPACE_PREFIX", "REGISTRY_PULL_SECRET_NAME", "MONGO_ADMIN_SECRET_NAME"];
11
+ const DEFAULTS = {
12
+ REGISTRY_HOST: "ghcr.io",
13
+ PLATFORM_NAMESPACE: "oon-system",
14
+ DELIVERY_NAMESPACE: "oon-delivery",
15
+ RUNTIME_NAMESPACE_PREFIX: "oon",
16
+ REGISTRY_PULL_SECRET_NAME: "ghcr-oon",
17
+ MONGO_ADMIN_SECRET_NAME: "oon-mongo-admin",
18
+ OBSERVABILITY_MODE: "contract-only",
19
+ NETWORK_POLICY_MODE: "baseline",
20
+ };
21
+
22
+ function majorOf(value) {
23
+ const match = String(value || "").match(/^(\d+)(?:\.|$)/);
24
+ return match ? Number(match[1]) : null;
25
+ }
26
+ function extractData(input) {
27
+ if (input && input.kind === "ConfigMap" && input.data && typeof input.data === "object") return input.data;
28
+ return input;
29
+ }
30
+
31
+ function validateInfraConfig(input) {
32
+ const data = extractData(input);
33
+ const issues = [];
34
+ if (!data || typeof data !== "object" || Array.isArray(data)) issues.push(toIssue("$", "Contrato de infraestrutura deve ser um objeto."));
35
+ else {
36
+ const major = majorOf(data.INFRA_SCHEMA_VERSION);
37
+ if (major !== null && !SUPPORTED_MAJORS.has(major)) throw new ContractError("UNSUPPORTED_CONTRACT_VERSION", `Major de INFRA_SCHEMA_VERSION não suportado: ${data.INFRA_SCHEMA_VERSION}.`, { path: "$.INFRA_SCHEMA_VERSION" });
38
+ REQUIRED_COMMON.forEach((key) => { if (data[key] === undefined || data[key] === "") issues.push(toIssue(`$.${key}`, "Chave obrigatória ausente.")); });
39
+ if (major === 2) {
40
+ REQUIRED_V2.forEach((key) => { if (data[key] === undefined || data[key] === "") issues.push(toIssue(`$.${key}`, "Chave obrigatória ausente.")); });
41
+ if (Object.prototype.hasOwnProperty.call(data, "OON_ENVIRONMENT")) issues.push(toIssue("$.OON_ENVIRONMENT", "A infraestrutura 2.x é neutra e não aceita ambiente."));
42
+ }
43
+ }
44
+ if (issues.length) throw new ContractError("SCHEMA_VALIDATION_ERROR", "oon-platform-config inválido.", { details: issues });
45
+ const normalized = { ...DEFAULTS, ...data };
46
+ delete normalized.OON_ENVIRONMENT;
47
+ return { ok: true, contract: CONTRACT, version: normalized.INFRA_SCHEMA_VERSION, config: normalized };
48
+ }
49
+
50
+ function validateInfraFile(file) { return validateInfraConfig(readJsonFile(file)); }
51
+ function readConfigMapWithKubectl() {
52
+ const result = spawnSync("kubectl", ["-n", "oon-system", "get", "configmap", "oon-platform-config", "-o", "json"], { encoding: "utf8" });
53
+ if (result.error?.code === "ENOENT") throw new ContractError("KUBECTL_UNAVAILABLE", "kubectl não encontrado no PATH.");
54
+ if (result.error) throw new ContractError("KUBERNETES_ACCESS_ERROR", "Falha ao executar kubectl.");
55
+ if (result.status !== 0) {
56
+ const stderr = String(result.stderr || "");
57
+ if (/notfound|not found/i.test(stderr)) throw new ContractError("CONFIGMAP_NOT_FOUND", "ConfigMap oon-system/oon-platform-config não encontrado.");
58
+ throw new ContractError("KUBERNETES_ACCESS_ERROR", "Não foi possível ler o ConfigMap oon-system/oon-platform-config.");
59
+ }
60
+ try { return JSON.parse(result.stdout); }
61
+ catch { throw new ContractError("INVALID_JSON", "kubectl retornou JSON inválido para o ConfigMap."); }
62
+ }
63
+ function validateInfraFromCluster() { return validateInfraConfig(readConfigMapWithKubectl()); }
64
+
65
+ module.exports = { validateInfraConfig, validateInfraFile, validateInfraFromCluster, DEFAULTS, REQUIRED_COMMON, REQUIRED_V2 };