@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
|
@@ -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.
|
package/src/delivery/runtime.js
CHANGED
|
@@ -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
|
};
|