@oondemand/oon-core-back 0.6.11 → 0.6.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oondemand/oon-core-back",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/oondemand/oon-platform.git",
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+
3
+ // Explicit prepared-member path. This module never imports the administrative
4
+ // provisioner, reads platform Secrets, or falls back to provisionRuntime.
5
+ const crypto = require("node:crypto");
6
+ const { spawnSync } = require("node:child_process");
7
+ const { performance } = require("node:perf_hooks");
8
+ const { isDeepStrictEqual } = require("node:util");
9
+ const { resolveDeliveryTechnicalIdentity } = require("./technicalIdentity");
10
+ const MEMBERS = new Set(["meus-apps", "oon-workspace", "oon-docs"]);
11
+ const AUTHORITY_API = "https://central-ativacao.oonapps.online/api";
12
+ const ENVIRONMENTS = new Set(["desenvolvimento", "homologacao", "producao"]);
13
+ const OBJECT_ID = /^[a-f0-9]{24}$/;
14
+ const UID = /^[23456789abcdefghjkmnopqrstuvwxyz]{16}$/;
15
+ const VERSION = /^[0-9]+$/;
16
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
17
+ const PROFILES = Object.freeze({
18
+ nano: ["50m", "250m", "96Mi", "256Mi"], small: ["100m", "500m", "128Mi", "512Mi"],
19
+ medium: ["250m", "1", "256Mi", "1Gi"], large: ["500m", "2", "512Mi", "2Gi"],
20
+ });
21
+ function fail(code) { const e = new Error(code); e.code = code; throw e; }
22
+ function demand(value, code) { if (!value) fail(code); }
23
+ function stable(value) {
24
+ if (Array.isArray(value)) return value.map(stable);
25
+ if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])]));
26
+ return value;
27
+ }
28
+ const hash = value => crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
29
+ const copy = value => JSON.parse(JSON.stringify(value));
30
+
31
+ function leaseDeadline(value) {
32
+ const deadline = typeof value === "string" ? Date.parse(value) : NaN;
33
+ demand(Number.isFinite(deadline) && new Date(deadline).toISOString() === value,
34
+ "PREPARED_RUNTIME_LEASE_INVALID");
35
+ return deadline;
36
+ }
37
+
38
+ // Local freshness only. An in-flight API request may already have committed;
39
+ // expiry never authorizes takeover, rollback or release of an administrative lock.
40
+ function leaseGuard(expiresAt, { now = Date.now, monotonicNow = () => performance.now() } = {}) {
41
+ const deadline = leaseDeadline(expiresAt), started = now(), monotonicStarted = monotonicNow();
42
+ demand(Number.isFinite(started) && Number.isFinite(monotonicStarted), "PREPARED_RUNTIME_CLOCK_INVALID");
43
+ let lastMonotonic = monotonicStarted;
44
+ return () => {
45
+ const current = now(), monotonic = monotonicNow();
46
+ demand(Number.isFinite(current) && Number.isFinite(monotonic) && monotonic >= lastMonotonic,
47
+ "PREPARED_RUNTIME_CLOCK_INVALID");
48
+ lastMonotonic = monotonic;
49
+ // The original remaining budget cannot grow if the wall clock moves back.
50
+ demand(current < deadline && monotonic - monotonicStarted < deadline - started,
51
+ "PREPARED_RUNTIME_LEASE_EXPIRED");
52
+ };
53
+ }
54
+
55
+ // authority is trusted executor configuration, separate from the member context.
56
+ // Callers must fetch/authorize the context through Central Prod; no browser input.
57
+ function expectedPreparation({ context: c, imageDigest, authority: a }) {
58
+ demand(c && a && MEMBERS.has(c.appCode) && c.repository === `oondemand/${c.appCode}` &&
59
+ c.lifecycleMode === "global_singleton" && ENVIRONMENTS.has(c.environment) &&
60
+ OBJECT_ID.test(c.appId || "") && OBJECT_ID.test(c.technicalInstanceId || "") &&
61
+ OBJECT_ID.test(c.deploymentId || "") && c.technicalIdentityVersion === 1 &&
62
+ /^[A-Za-z0-9_-]{1,120}$/.test(c.releaseId || "") && UUID.test(c.leaseId || "") &&
63
+ typeof c.deploymentToken === "string" && c.deploymentToken.length >= 32 && c.deploymentToken.length <= 8192 &&
64
+ /^g[a-z0-9-]{1,39}$/.test(a.installationId || "") && UID.test(a.instanceUid || "") &&
65
+ a.apiUrl === AUTHORITY_API && c.activationApiUrl === a.apiUrl && c.instanceUid !== a.instanceUid &&
66
+ /^sha256:[a-f0-9]{64}$/.test(imageDigest || "") && (!c.imageDigest || c.imageDigest === imageDigest) &&
67
+ c.imageRepository === `ghcr.io/oondemand/${c.appCode}`, "PREPARED_RUNTIME_CONTEXT_INVALID");
68
+ leaseDeadline(c.leaseExpiresAt);
69
+ let id; try { id = resolveDeliveryTechnicalIdentity(c, { requireCanonical: true }); }
70
+ catch { fail("PREPARED_RUNTIME_IDENTITY_INVALID"); }
71
+ const frontendHostname = `${id.frontendDnsLabel}.oonapps.online`, backendHostname = `${id.backendDnsLabel}.oonapps.online`;
72
+ demand(c.namespace === id.namespace && c.frontendHostname === frontendHostname && c.backendHostname === backendHostname &&
73
+ c.frontendUrl === `https://${frontendHostname}` && c.backendUrl === `https://${backendHostname}/api` &&
74
+ c.publicUrl === c.frontendUrl && c.hostname === frontendHostname, "PREPARED_RUNTIME_URL_INVALID");
75
+ const profile = c.resourceProfile || "small";
76
+ demand(Object.hasOwn(PROFILES, profile), "PREPARED_RUNTIME_PROFILE_INVALID");
77
+ const binding = { schemaVersion: 2, installationId: a.installationId, authorityInstanceUid: a.instanceUid,
78
+ authorityApiUrl: a.apiUrl, appId: c.appId, appCode: c.appCode, technicalInstanceId: c.technicalInstanceId,
79
+ instanceUid: id.instanceUid, identityVersion: 1, environment: c.environment, namespace: id.namespace,
80
+ releaseId: c.releaseId, leaseId: c.leaseId, leaseExpiresAt: c.leaseExpiresAt, imageDigest, imageRepository: c.imageRepository,
81
+ deploymentId: c.deploymentId, deploymentTokenSha256: hash(c.deploymentToken),
82
+ frontendUrl: c.frontendUrl, backendUrl: c.backendUrl, resourceProfile: profile,
83
+ capabilitiesSha256: hash({ capabilities: c.capabilities || {}, functionalCapabilities: c.functionalCapabilities || {} }) };
84
+ return { binding, identity: id, receiptName: `oon-preparation-${hash(binding).slice(0, 32)}` };
85
+ }
86
+
87
+ function pin(doc, name, namespace, kind) {
88
+ demand(doc?.kind === kind && doc.metadata?.name === name && doc.metadata.namespace === namespace &&
89
+ UUID.test(doc.metadata.uid || "") && VERSION.test(doc.metadata.resourceVersion || "") &&
90
+ !doc.metadata.deletionTimestamp, "PREPARED_RUNTIME_RESOURCE_INVALID");
91
+ return { name, uid: doc.metadata.uid, resourceVersion: doc.metadata.resourceVersion };
92
+ }
93
+ function samePin(doc, expected, namespace, kind) {
94
+ demand(isDeepStrictEqual(pin(doc, expected?.name, namespace, kind), expected), "PREPARED_RUNTIME_RESOURCE_CHANGED");
95
+ }
96
+ function decodeRuntime(secret) {
97
+ const values = {};
98
+ for (const [key, value] of Object.entries(secret.data || {})) {
99
+ demand(typeof value === "string" && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value), "PREPARED_RUNTIME_SECRET_INVALID");
100
+ values[key] = Buffer.from(value, "base64").toString("utf8");
101
+ }
102
+ return values;
103
+ }
104
+ function assertRuntime(secret, c, id, a) {
105
+ const env = decodeRuntime(secret);
106
+ const expected = { NODE_ENV: "production", APP_ENVIRONMENT: c.environment, APP_CODE: c.appCode,
107
+ PUBLIC_APP_URL: c.frontendUrl, CENTRAL_ATIVACAO_API_URL: a.apiUrl,
108
+ CENTRAL_ATIVACAO_URL: a.apiUrl.replace(/\/api$/, ""), OON_INSTANCE_UID: id.instanceUid,
109
+ OON_TECHNICAL_IDENTITY_VERSION: "1", OON_DEPLOYMENT_ID: c.deploymentId,
110
+ OON_DEPLOYMENT_TOKEN: c.deploymentToken, OON_APP_CODE: c.appCode };
111
+ demand(Object.entries(expected).every(([k, v]) => env[k] === v) &&
112
+ typeof env.INSTANCE_CREDENTIAL_ENCRYPTION_KEY === "string" && env.INSTANCE_CREDENTIAL_ENCRYPTION_KEY.length >= 32 &&
113
+ !["MONGO_ADMIN_URI", "DIGITALOCEAN_TOKEN", "KUBECONFIG", "KUBECONFIG_B64", "PUBLISHER_KUBECONFIG_B64",
114
+ "GITHUB_APP_PRIVATE_KEY", "GITHUB_CONTROL_PLANE_APP_PRIVATE_KEY"].some(k => Object.hasOwn(env, k)), "PREPARED_RUNTIME_SECRET_INVALID");
115
+ let uri, username; try { uri = new URL(env.MONGO_URI); username = decodeURIComponent(uri.username); }
116
+ catch { fail("PREPARED_RUNTIME_MONGO_INVALID"); }
117
+ demand(["mongodb:", "mongodb+srv:"].includes(uri.protocol) && uri.hostname && uri.password &&
118
+ username === id.mongoUsername && uri.pathname === `/${id.database}` &&
119
+ uri.searchParams.get("authSource") === "admin", "PREPARED_RUNTIME_MONGO_INVALID");
120
+ if (c.functionalCapabilities?.["core.transactional-email"]?.enabled) {
121
+ let ring; try { ring = JSON.parse(env.OON_TRANSACTIONAL_EMAIL_KEK_KEYRING); } catch { fail("PREPARED_RUNTIME_CAPABILITY_MISSING"); }
122
+ demand(typeof ring.currentKeyId === "string" && typeof ring.keys?.[ring.currentKeyId] === "string" &&
123
+ Buffer.from(ring.keys[ring.currentKeyId], "base64").length === 32, "PREPARED_RUNTIME_CAPABILITY_MISSING");
124
+ }
125
+ if (c.capabilities?.pdfRendering) {
126
+ demand(env.OON_CAPABILITY_PDF_RENDERING_URL === c.capabilities.pdfRendering.endpoint &&
127
+ env.OON_CAPABILITY_PDF_RENDERING_VERSION === c.capabilities.pdfRendering.implementationVersion &&
128
+ env.OON_CAPABILITY_PDF_RENDERING_USERNAME && env.OON_CAPABILITY_PDF_RENDERING_PASSWORD, "PREPARED_RUNTIME_CAPABILITY_MISSING");
129
+ }
130
+ }
131
+
132
+ function kubernetesClient(spawn = spawnSync) {
133
+ const command = (args, input, code) => {
134
+ let r; try { r = spawn("kubectl", [...args, "--request-timeout=60s"], {
135
+ input: input === undefined ? undefined : JSON.stringify(input), encoding: "utf8", timeout: 70000,
136
+ maxBuffer: 4 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"],
137
+ }); } catch { fail(code); }
138
+ if (r.error || r.status !== 0) fail(code); // Never include provider output or args in diagnostics.
139
+ return String(r.stdout || "");
140
+ };
141
+ const json = (raw, code) => { try { return JSON.parse(raw); } catch { fail(code); } };
142
+ return {
143
+ read: (kind, name, namespace) => {
144
+ const raw = command(["-n", namespace, "get", kind, name, "--ignore-not-found", "-o", "json"], undefined, "PREPARED_RUNTIME_READ_FAILED");
145
+ return raw.trim() ? json(raw, "PREPARED_RUNTIME_READ_FAILED") : null;
146
+ },
147
+ write: (doc, existing) => {
148
+ let next = copy(doc);
149
+ if (existing) {
150
+ next.metadata = { ...existing.metadata, ...next.metadata, resourceVersion: existing.metadata.resourceVersion,
151
+ uid: existing.metadata.uid, labels: { ...existing.metadata.labels, ...next.metadata.labels },
152
+ annotations: { ...next.metadata.annotations } };
153
+ delete next.metadata.managedFields;
154
+ if (next.kind === "Service") {
155
+ for (const field of ["clusterIP", "clusterIPs", "ipFamilies", "ipFamilyPolicy", "internalTrafficPolicy"]) {
156
+ if (existing.spec[field] !== undefined) next.spec[field] = existing.spec[field];
157
+ }
158
+ }
159
+ }
160
+ // create refuses a concurrent occupant; replace uses resourceVersion CAS.
161
+ return json(command([existing ? "replace" : "create", "-f", "-", "-o", "json"], next,
162
+ "PREPARED_RUNTIME_WRITE_OUTCOME_UNKNOWN"), "PREPARED_RUNTIME_WRITE_OUTCOME_UNKNOWN");
163
+ },
164
+ rollout: (name, namespace) => command(["-n", namespace, "rollout", "status", `deployment/${name}`, "--timeout=60s"], undefined, "PREPARED_RUNTIME_ROLLOUT_PENDING"),
165
+ };
166
+ }
167
+
168
+ function renderPreparedRuntime({ context: c, imageDigest, authority }, receipt) {
169
+ const { identity: id, binding } = expectedPreparation({ context: c, imageDigest, authority });
170
+ const [cpuRequest, cpuLimit, memoryRequest, memoryLimit] = PROFILES[binding.resourceProfile];
171
+ const labels = { "app.kubernetes.io/name": c.appCode, "oon.runtime/instance-uid": id.instanceUid, "oon.runtime/identity-version": "1" };
172
+ const ownership = { "oon.delivery/installation": authority.installationId, "oon.delivery/authority-uid": authority.instanceUid,
173
+ "oon.delivery/technical-instance": c.technicalInstanceId, "oon.delivery/environment": c.environment };
174
+ const metadata = name => ({ name, namespace: id.namespace, labels, annotations: ownership });
175
+ const ingress = (name, hostname, tlsSecret, route) => ({ apiVersion: "networking.k8s.io/v1", kind: "Ingress",
176
+ metadata: { ...metadata(name), annotations: { ...ownership, "cert-manager.io/cluster-issuer": receipt.clusterIssuer } },
177
+ spec: { ingressClassName: receipt.ingressClass, tls: [{ hosts: [hostname], secretName: tlsSecret }],
178
+ rules: [{ host: hostname, http: { paths: [{ path: route, pathType: "Prefix", backend: { service: { name: c.appCode, port: { name: "http" } } } }] } }] } });
179
+ return [
180
+ { apiVersion: "apps/v1", kind: "Deployment", metadata: metadata(c.appCode), spec: {
181
+ replicas: c.environment === "producao" ? 2 : 1, revisionHistoryLimit: 2,
182
+ strategy: { type: "RollingUpdate", rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } },
183
+ selector: { matchLabels: { "app.kubernetes.io/name": c.appCode } },
184
+ template: { metadata: { labels, annotations: { ...ownership, "oon.delivery/release": c.releaseId,
185
+ "oon.delivery/lease": c.leaseId, "oon.delivery/image-digest": imageDigest } }, spec: {
186
+ serviceAccountName: id.serviceAccount, automountServiceAccountToken: false,
187
+ securityContext: { runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, seccompProfile: { type: "RuntimeDefault" } },
188
+ imagePullSecrets: [{ name: receipt.pullSecret.name }], containers: [{ name: "central", image: `${c.imageRepository}@${imageDigest}`,
189
+ imagePullPolicy: "IfNotPresent", ports: [{ name: "http", containerPort: 8080 }], envFrom: [{ secretRef: { name: id.secret } }],
190
+ securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
191
+ resources: { requests: { cpu: cpuRequest, memory: memoryRequest }, limits: { cpu: cpuLimit, memory: memoryLimit } },
192
+ livenessProbe: { httpGet: { path: "/health/live", port: "http" }, initialDelaySeconds: 20 },
193
+ readinessProbe: { httpGet: { path: "/api/health/ready", port: "http" }, initialDelaySeconds: 10 } }],
194
+ } },
195
+ } },
196
+ { apiVersion: "v1", kind: "Service", metadata: metadata(c.appCode), spec: { type: "ClusterIP", selector: { "app.kubernetes.io/name": c.appCode }, ports: [{ name: "http", port: 80, targetPort: "http", protocol: "TCP" }] } },
197
+ ingress(c.appCode, c.frontendHostname, id.tlsSecret, "/"),
198
+ ingress(`${c.appCode}-api`, c.backendHostname, `${c.appCode}-api-tls`, "/api"),
199
+ ];
200
+ }
201
+
202
+ async function deployPreparedRuntime(options, dependencies = {}) {
203
+ const frozen = copy(options); // No caller mutation while awaiting I/O.
204
+ const { context: c, authority: a } = frozen;
205
+ const { binding, identity: id, receiptName } = expectedPreparation(frozen);
206
+ const kube = dependencies.kube || kubernetesClient();
207
+ const checkLease = leaseGuard(c.leaseExpiresAt, dependencies);
208
+ const io = async (operation, ...args) => {
209
+ checkLease();
210
+ const result = await kube[operation](...args);
211
+ checkLease();
212
+ return result;
213
+ };
214
+ const receiptDoc = await io("read", "configmap", receiptName, id.namespace);
215
+ demand(receiptDoc, "PREPARED_RUNTIME_REQUIRED");
216
+ const receiptPin = pin(receiptDoc, receiptName, id.namespace, "ConfigMap");
217
+ let receipt; try { receipt = JSON.parse(receiptDoc.data?.receipt); } catch { fail("PREPARED_RUNTIME_RECEIPT_INVALID"); }
218
+ demand(receiptDoc.immutable === true && receipt?.phase === "prepared" && isDeepStrictEqual(receipt.binding, binding) &&
219
+ receipt.ingressClass === "nginx" && receipt.clusterIssuer === "letsencrypt-production-http01" &&
220
+ receipt.runtimeSecret?.name === id.secret && receipt.runtimeServiceAccount?.name === id.serviceAccount &&
221
+ receipt.pullSecret?.name === "ghcr-oon", "PREPARED_RUNTIME_RECEIPT_INVALID");
222
+ const checkPreparation = async () => {
223
+ samePin(await io("read", "configmap", receiptName, id.namespace), receiptPin, id.namespace, "ConfigMap");
224
+ const secret = await io("read", "secret", id.secret, id.namespace);
225
+ samePin(secret, receipt.runtimeSecret, id.namespace, "Secret");
226
+ assertRuntime(secret, c, id, a);
227
+ const sa = await io("read", "serviceaccount", id.serviceAccount, id.namespace);
228
+ samePin(sa, receipt.runtimeServiceAccount, id.namespace, "ServiceAccount");
229
+ demand(sa.automountServiceAccountToken === false, "PREPARED_RUNTIME_RESOURCE_INVALID");
230
+ const pull = await io("read", "secret", receipt.pullSecret.name, id.namespace);
231
+ samePin(pull, receipt.pullSecret, id.namespace, "Secret");
232
+ demand(pull.type === "kubernetes.io/dockerconfigjson", "PREPARED_RUNTIME_RESOURCE_INVALID");
233
+ };
234
+ await checkPreparation();
235
+ const docs = renderPreparedRuntime(frozen, receipt), existing = [];
236
+ for (const doc of docs) {
237
+ const current = await io("read", doc.kind.toLowerCase(), doc.metadata.name, id.namespace);
238
+ if (current) {
239
+ pin(current, doc.metadata.name, id.namespace, doc.kind);
240
+ const ownership = doc.metadata.annotations;
241
+ demand(Object.entries(ownership).every(([k,v]) => current.metadata.annotations?.[k] === v), "PREPARED_RUNTIME_TARGET_OCCUPIED");
242
+ // An owned Ingress may still carry routing/auth/snippet directives that
243
+ // change its behavior. Require explicit reconciliation before any write.
244
+ demand(isDeepStrictEqual(current.metadata.annotations, ownership), "PREPARED_RUNTIME_ANNOTATIONS_UNAPPROVED");
245
+ }
246
+ existing.push(current);
247
+ }
248
+ for (let index = 0; index < docs.length; index++) {
249
+ await checkPreparation();
250
+ const doc = docs[index], before = existing[index], current = await io("read", doc.kind.toLowerCase(), doc.metadata.name, id.namespace);
251
+ demand(before ? current?.metadata?.uid === before.metadata.uid && current.metadata.resourceVersion === before.metadata.resourceVersion : current === null,
252
+ "PREPARED_RUNTIME_TARGET_CHANGED");
253
+ await io("write", doc, before);
254
+ }
255
+ await checkPreparation();
256
+ await io("rollout", c.appCode, id.namespace);
257
+ await checkPreparation();
258
+ checkLease();
259
+ return { infraVersion: "prepared-member-v2", imageRef: `${c.imageRepository}@${frozen.imageDigest}`,
260
+ technicalIdentity: id, preparation: { name: receiptName, uid: receiptPin.uid },
261
+ proofLimits: ["no-distributed-lock-with-administrative-preparation", "rollout-is-not-functional-acceptance"] };
262
+ }
263
+
264
+ module.exports = { expectedPreparation, renderPreparedRuntime, deployPreparedRuntime, kubernetesClient };
@@ -0,0 +1,132 @@
1
+ # Runtime preparado para membros da plataforma — B5
2
+
3
+ API opt-in `deployPreparedRuntime({context, imageDigest, authority})`, exportada por
4
+ `delivery/runtime.js`, para `meus-apps`, `oon-workspace` e `oon-docs`. O executor atual
5
+ carrega delivery pelo caminho físico do pacote; nenhum novo subpath npm é exportado.
6
+ O workflow `version-pr` prepara a próxima versão patch; integrar em main aciona
7
+ `release-npm`, que publica os três pacotes após validação. A publicação só deve ser
8
+ registrada como concluída após o run de release. Isso não liga os consumidores
9
+ existentes ao novo adapter.
10
+
11
+ ## Fronteira de confiança
12
+
13
+ `context` deve vir da Central Prod pelo protocolo autenticado de publicação e lease.
14
+ `authority` é configuração confiável separada, com `installationId`, `instanceUid`
15
+ da raiz e `apiUrl`. Nenhum desses valores pode vir de input livre do workflow.
16
+ O adapter valida consistência; não consulta Mongo, autentica Central nem autoriza
17
+ uma reserva. O consumidor deve validar lease vigente e comando antes de chamá-lo.
18
+ O contexto autenticado precisa incluir `leaseExpiresAt` no formato ISO UTC canônico
19
+ retornado pela Central (`Date.toISOString()`). Ausência, formato inválido ou prazo
20
+ vencido interrompem antes de acessar Kubernetes. Não completar esse campo no
21
+ consumidor com um prazo local nem renovar o lease para reutilizar um recibo.
22
+
23
+ A preparação administrativa ainda será implementada na Infra. Deve ler novamente
24
+ singleton/configuração bloqueada, comandos e concorrência; conferir identidade,
25
+ release Prod e reservas nativas; preparar somente os alvos aprovados. Durante a
26
+ publicação, deve manter a exclusão mútua e a vigência do lease com a Central. Não
27
+ usar `ensureTechnicalInstance` para descoberta, nem adotar recursos por labels.
28
+
29
+ O preparador administra Namespace, quota, LimitRange, políticas de rede/admissão,
30
+ ServiceAccount runtime sem token, usuário Mongo limitado ao banco, runtime Secret,
31
+ pull secret e capacidades. Deve preservar recursos/chaves existentes e parar em
32
+ divergência. Nenhuma credencial administrativa deve ser projetada no membro.
33
+
34
+ ## Recibo por publicação
35
+
36
+ `expectedPreparation(options)` retorna `binding`, `identity` e `receiptName`.
37
+ O binding agora usa `schemaVersion: 2` e inclui `leaseExpiresAt` além de `leaseId`.
38
+ O cálculo é puro e valida o formato, não a vigência no instante de uso; o preparador
39
+ também precisa revalidar a autoridade e o prazo antes de seus próprios efeitos.
40
+ Recibos v1 não são aceitos pelo novo adapter. Não há migração nem adoção automática;
41
+ a futura preparação deve usar a mesma versão do SDK do consumidor. O adapter opt-in
42
+ 0.6.12 ainda não está ligado aos consumidores operacionais, portanto este delta não
43
+ altera o provisionador legado nem publica imagens dos membros.
44
+ O preparador deverá usar a mesma função/versão fixada. `receiptName` é
45
+ `oon-preparation-` mais os primeiros 32 hex de SHA256 do JSON canônico de `binding`.
46
+ Objetos são ordenados recursivamente; arrays mantêm ordem. O hash do token também
47
+ usa JSON canônico da string, não os bytes crus. Nunca persistir o token no recibo.
48
+
49
+ No namespace canônico, criar ConfigMap `immutable: true` com `data.receipt` contendo
50
+ JSON com estes campos:
51
+
52
+ | Campo | Valor exigido |
53
+ | --- | --- |
54
+ | `phase` | `prepared` |
55
+ | `binding` | Exatamente o retornado pelo SDK v2: autoridade, identidade, ambiente, release, lease e vencimento, imagem/digest, deployment, URLs, perfil e hashes das capacidades/token |
56
+ | `ingressClass` | `nginx` |
57
+ | `clusterIssuer` | `letsencrypt-production-http01` |
58
+ | `runtimeSecret` | `{name, uid, resourceVersion}` do Secret canônico final |
59
+ | `runtimeServiceAccount` | `{name, uid, resourceVersion}` da SA canônica final |
60
+ | `pullSecret` | `{name: "ghcr-oon", uid, resourceVersion}` do Secret local final |
61
+
62
+ O recibo é o último recurso da preparação. Não é uma assinatura: sua autoridade
63
+ depende de o executor não poder criar/alterar/apagar ConfigMaps, Secrets ou SAs.
64
+ Não reutilizar recibo em outro lease/digest. UID e resourceVersion dos recursos são
65
+ reconferidos antes de cada escrita e após o rollout. Isso detecta deriva observada,
66
+ mas não substitui um lock distribuído com o preparador.
67
+
68
+ `deployPreparedRuntime` confere o prazo antes e depois de cada leitura, escrita e
69
+ rollout, além de antes do retorno de sucesso (`infraVersion: prepared-member-v2`).
70
+ O orçamento inicial de tempo também é acompanhado por relógio monotônico: um recuo
71
+ do relógio civil durante a chamada não prolonga o prazo. Observação inválida do
72
+ relógio interrompe. O relógio civil inicial do executor precisa estar sincronizado;
73
+ essa checagem local não consulta revogação, comando ou lease na Central.
74
+
75
+ Expiração durante uma requisição não cancela um efeito que já chegou ao servidor.
76
+ O erro `PREPARED_RUNTIME_LEASE_EXPIRED` pode corresponder a escrita parcial; preservar
77
+ e reconciliar os recursos com o comando, sem retry automático, rollback, remoção de
78
+ lock ou takeover. Um novo processo não deve reusar o mesmo lease como autorização
79
+ de concorrência. Exclusão administrativa, fencing/admissão e frescor de callbacks
80
+ permanecem requisitos separados de B5.
81
+
82
+ O runtime Secret deve conter identidade/URLs, credencial de deployment e chave de
83
+ criptografia já preparadas. Mongo exige URI de host único `mongodb`/`mongodb+srv`,
84
+ usuário e banco canônicos e `authSource=admin`; o adapter não prova os privilégios
85
+ desse usuário. E-mail habilitado exige keyring existente; PDF exige endpoint,
86
+ versão e credenciais projetadas. O adapter nunca gera chaves ou busca Secrets
87
+ administrativos. A recusa de nomes administrativos conhecidos é defesa adicional,
88
+ não uma prova da origem ou do escopo de todas as credenciais.
89
+
90
+ ## Permissões necessárias e limites
91
+
92
+ Leitura `get` somente dos nomes do recibo, runtime Secret, pull Secret e runtime SA
93
+ no alvo; leitura/escrita de Deployment, Service e dois Ingresses canônicos; rollout
94
+ consulta/observa o Deployment nomeado. Não requer namespaces, RBAC, nodes, jobs,
95
+ pods/exec, serviceaccounts/token ou acesso a `oon-system`/outros namespaces.
96
+
97
+ Não aplicar uma Role genérica com base apenas nesta lista. `create` de recursos
98
+ principais não pode ser limitado por `resourceNames` no RBAC; workloads também
99
+ podem montar Secrets e escolher SAs. Antes de conceder escrita, a Infra deve
100
+ implementar e provar admissão restritiva (nomes, imagem, hosts, SAs, referências a
101
+ Secrets, pod security) ou um mecanismo administrativo equivalente. RBAC deve ser
102
+ por alvo/ambiente/instalação, com autenticação dedicada e recusas cruzadas. Ver
103
+ [limites oficiais do RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources).
104
+
105
+ O renderer produz somente Deployment, Service e Ingresses frontend/backend, por
106
+ digest, sem token de SA, sem privilégio, UID/GID 1000 e seccomp RuntimeDefault.
107
+ A compatibilidade das imagens dos três membros com esse usuário ainda precisa
108
+ ser provada. Não oferece manifest arbitrário, initContainers, volumes ou hooks.
109
+
110
+ Um alvo existente exige ownership correspondente e UID/resourceVersion observados;
111
+ annotations adicionais são recusadas antes de qualquer escrita, inclusive regras
112
+ de rewrite, redirect, autenticação ou snippets de Ingress. Exigem reconciliação
113
+ administrativa explícita; não são herdadas silenciosamente na substituição.
114
+ `replace` usa CAS, `create` recusa ocupante concorrente. Essa conferência no cliente
115
+ não é autorização nem prova de ownership canônico no banco. Não adota recursos
116
+ legados. Falha parcial/resultado de escrita incerto interrompe sem retry ou rollback.
117
+ O operador deve reconciliar resultado com o comando antes de tentar novamente.
118
+ Rollout tem limite de 60 segundos; timeout é pendência, não motivo para nova escrita.
119
+ Rollout bem-sucedido não prova HTTP, TLS, callback ou aceite funcional.
120
+
121
+ ## Gate de uso
122
+
123
+ Manter delivery bloqueado até implementar preparador e admissão/bindings, provar
124
+ positivos e recusas por alvo, publicar a versão do SDK e ligar ambos os consumidores
125
+ ao novo adapter. O preparador de kubeconfig atual exige `create namespaces` e o
126
+ script atual ainda chama provisionamento administrativo: ambos precisam de caminho
127
+ específico para membros. Não satisfazer esse guard concedendo namespace create.
128
+ Nenhuma chamada deve cair no provisionador antigo se o recibo estiver ausente.
129
+
130
+ Testes locais: `node --test packages/oonCore-back/test/prepared-runtime.test.js
131
+ packages/oonCore-back/test/delivery-technical-identity.test.js`. Os fakes verificam
132
+ comportamento do adapter; não comprovam permissões Kubernetes ou integração real.
@@ -363,6 +363,8 @@ module.exports = {
363
363
  runtimeManifest,
364
364
  provisionRuntime,
365
365
  deployRuntime,
366
+ // Explicit opt-in for a separately prepared target; never a provisioning fallback.
367
+ deployPreparedRuntime: require("./preparedRuntime").deployPreparedRuntime,
366
368
  waitHttp,
367
369
  activateMember,
368
370
  };