@oondemand/oon-core-back 0.6.12 → 0.7.0
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/bin/delivery.js +2 -5
- package/package.json +1 -1
- package/src/api/auth.routes.js +5 -1
- package/src/core/middlewares/auth.js +19 -0
- package/src/core/middlewares/rbac.js +6 -1
- package/src/core/utils/instanceActivation.js +20 -0
- package/src/delivery/mongoProvisioning.js +122 -117
- package/src/delivery/runtime.js +91 -166
- package/src/index.js +1 -7
- package/src/core/licensing.js +0 -71
- package/src/core/technicalIdentity.js +0 -174
- package/src/delivery/mongoCredentialLifecycle.js +0 -33
- package/src/delivery/preparedRuntime.js +0 -230
- package/src/delivery/preparedRuntime.md +0 -107
- package/src/delivery/technicalIdentity.js +0 -140
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const { assertMongoIdentity, recoveryRequired } = require("./mongoProvisioning");
|
|
4
|
-
|
|
5
|
-
// The store must create the intent exclusively, then replace it by resourceVersion.
|
|
6
|
-
// A present record without a credential is an interrupted operation, never absence.
|
|
7
|
-
async function resolveMongoCredential({ identity, read, begin, persist, provision, validate }) {
|
|
8
|
-
const record = await read();
|
|
9
|
-
if (record !== null) {
|
|
10
|
-
if (!record.mongoUri) throw recoveryRequired("MONGO_CREDENTIAL_MISSING");
|
|
11
|
-
assertMongoIdentity(record.mongoUri, identity);
|
|
12
|
-
try { await validate(record.mongoUri); }
|
|
13
|
-
catch { throw recoveryRequired("MONGO_PERSISTED_CREDENTIAL_INVALID"); }
|
|
14
|
-
return { mongoUri: record.mongoUri, created: false };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
await begin();
|
|
18
|
-
let captured;
|
|
19
|
-
try {
|
|
20
|
-
const result = await provision(async (uri) => {
|
|
21
|
-
assertMongoIdentity(uri, identity);
|
|
22
|
-
await persist(uri);
|
|
23
|
-
captured = uri;
|
|
24
|
-
});
|
|
25
|
-
if (!captured || result.mongoUri !== captured) throw recoveryRequired();
|
|
26
|
-
return { mongoUri: captured, created: true };
|
|
27
|
-
} catch {
|
|
28
|
-
// Do not serialize driver/provider errors, URIs, tokens or creation responses.
|
|
29
|
-
throw recoveryRequired("MONGO_PROVISIONING_INCOMPLETE");
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
module.exports = { resolveMongoCredential };
|
|
@@ -1,230 +0,0 @@
|
|
|
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 { isDeepStrictEqual } = require("node:util");
|
|
8
|
-
const { resolveDeliveryTechnicalIdentity } = require("./technicalIdentity");
|
|
9
|
-
const MEMBERS = new Set(["meus-apps", "oon-workspace", "oon-docs"]);
|
|
10
|
-
const AUTHORITY_API = "https://central-ativacao.oonapps.online/api";
|
|
11
|
-
const ENVIRONMENTS = new Set(["desenvolvimento", "homologacao", "producao"]);
|
|
12
|
-
const OBJECT_ID = /^[a-f0-9]{24}$/;
|
|
13
|
-
const UID = /^[23456789abcdefghjkmnopqrstuvwxyz]{16}$/;
|
|
14
|
-
const VERSION = /^[0-9]+$/;
|
|
15
|
-
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
16
|
-
const PROFILES = Object.freeze({
|
|
17
|
-
nano: ["50m", "250m", "96Mi", "256Mi"], small: ["100m", "500m", "128Mi", "512Mi"],
|
|
18
|
-
medium: ["250m", "1", "256Mi", "1Gi"], large: ["500m", "2", "512Mi", "2Gi"],
|
|
19
|
-
});
|
|
20
|
-
function fail(code) { const e = new Error(code); e.code = code; throw e; }
|
|
21
|
-
function demand(value, code) { if (!value) fail(code); }
|
|
22
|
-
function stable(value) {
|
|
23
|
-
if (Array.isArray(value)) return value.map(stable);
|
|
24
|
-
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])]));
|
|
25
|
-
return value;
|
|
26
|
-
}
|
|
27
|
-
const hash = value => crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex");
|
|
28
|
-
const copy = value => JSON.parse(JSON.stringify(value));
|
|
29
|
-
|
|
30
|
-
// authority is trusted executor configuration, separate from the member context.
|
|
31
|
-
// Callers must fetch/authorize the context through Central Prod; no browser input.
|
|
32
|
-
function expectedPreparation({ context: c, imageDigest, authority: a }) {
|
|
33
|
-
demand(c && a && MEMBERS.has(c.appCode) && c.repository === `oondemand/${c.appCode}` &&
|
|
34
|
-
c.lifecycleMode === "global_singleton" && ENVIRONMENTS.has(c.environment) &&
|
|
35
|
-
OBJECT_ID.test(c.appId || "") && OBJECT_ID.test(c.technicalInstanceId || "") &&
|
|
36
|
-
OBJECT_ID.test(c.deploymentId || "") && c.technicalIdentityVersion === 1 &&
|
|
37
|
-
/^[A-Za-z0-9_-]{1,120}$/.test(c.releaseId || "") && UUID.test(c.leaseId || "") &&
|
|
38
|
-
typeof c.deploymentToken === "string" && c.deploymentToken.length >= 32 && c.deploymentToken.length <= 8192 &&
|
|
39
|
-
/^g[a-z0-9-]{1,39}$/.test(a.installationId || "") && UID.test(a.instanceUid || "") &&
|
|
40
|
-
a.apiUrl === AUTHORITY_API && c.activationApiUrl === a.apiUrl && c.instanceUid !== a.instanceUid &&
|
|
41
|
-
/^sha256:[a-f0-9]{64}$/.test(imageDigest || "") && (!c.imageDigest || c.imageDigest === imageDigest) &&
|
|
42
|
-
c.imageRepository === `ghcr.io/oondemand/${c.appCode}`, "PREPARED_RUNTIME_CONTEXT_INVALID");
|
|
43
|
-
let id; try { id = resolveDeliveryTechnicalIdentity(c, { requireCanonical: true }); }
|
|
44
|
-
catch { fail("PREPARED_RUNTIME_IDENTITY_INVALID"); }
|
|
45
|
-
const frontendHostname = `${id.frontendDnsLabel}.oonapps.online`, backendHostname = `${id.backendDnsLabel}.oonapps.online`;
|
|
46
|
-
demand(c.namespace === id.namespace && c.frontendHostname === frontendHostname && c.backendHostname === backendHostname &&
|
|
47
|
-
c.frontendUrl === `https://${frontendHostname}` && c.backendUrl === `https://${backendHostname}/api` &&
|
|
48
|
-
c.publicUrl === c.frontendUrl && c.hostname === frontendHostname, "PREPARED_RUNTIME_URL_INVALID");
|
|
49
|
-
const profile = c.resourceProfile || "small";
|
|
50
|
-
demand(Object.hasOwn(PROFILES, profile), "PREPARED_RUNTIME_PROFILE_INVALID");
|
|
51
|
-
const binding = { schemaVersion: 1, installationId: a.installationId, authorityInstanceUid: a.instanceUid,
|
|
52
|
-
authorityApiUrl: a.apiUrl, appId: c.appId, appCode: c.appCode, technicalInstanceId: c.technicalInstanceId,
|
|
53
|
-
instanceUid: id.instanceUid, identityVersion: 1, environment: c.environment, namespace: id.namespace,
|
|
54
|
-
releaseId: c.releaseId, leaseId: c.leaseId, imageDigest, imageRepository: c.imageRepository,
|
|
55
|
-
deploymentId: c.deploymentId, deploymentTokenSha256: hash(c.deploymentToken),
|
|
56
|
-
frontendUrl: c.frontendUrl, backendUrl: c.backendUrl, resourceProfile: profile,
|
|
57
|
-
capabilitiesSha256: hash({ capabilities: c.capabilities || {}, functionalCapabilities: c.functionalCapabilities || {} }) };
|
|
58
|
-
return { binding, identity: id, receiptName: `oon-preparation-${hash(binding).slice(0, 32)}` };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function pin(doc, name, namespace, kind) {
|
|
62
|
-
demand(doc?.kind === kind && doc.metadata?.name === name && doc.metadata.namespace === namespace &&
|
|
63
|
-
UUID.test(doc.metadata.uid || "") && VERSION.test(doc.metadata.resourceVersion || "") &&
|
|
64
|
-
!doc.metadata.deletionTimestamp, "PREPARED_RUNTIME_RESOURCE_INVALID");
|
|
65
|
-
return { name, uid: doc.metadata.uid, resourceVersion: doc.metadata.resourceVersion };
|
|
66
|
-
}
|
|
67
|
-
function samePin(doc, expected, namespace, kind) {
|
|
68
|
-
demand(isDeepStrictEqual(pin(doc, expected?.name, namespace, kind), expected), "PREPARED_RUNTIME_RESOURCE_CHANGED");
|
|
69
|
-
}
|
|
70
|
-
function decodeRuntime(secret) {
|
|
71
|
-
const values = {};
|
|
72
|
-
for (const [key, value] of Object.entries(secret.data || {})) {
|
|
73
|
-
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");
|
|
74
|
-
values[key] = Buffer.from(value, "base64").toString("utf8");
|
|
75
|
-
}
|
|
76
|
-
return values;
|
|
77
|
-
}
|
|
78
|
-
function assertRuntime(secret, c, id, a) {
|
|
79
|
-
const env = decodeRuntime(secret);
|
|
80
|
-
const expected = { NODE_ENV: "production", APP_ENVIRONMENT: c.environment, APP_CODE: c.appCode,
|
|
81
|
-
PUBLIC_APP_URL: c.frontendUrl, CENTRAL_ATIVACAO_API_URL: a.apiUrl,
|
|
82
|
-
CENTRAL_ATIVACAO_URL: a.apiUrl.replace(/\/api$/, ""), OON_INSTANCE_UID: id.instanceUid,
|
|
83
|
-
OON_TECHNICAL_IDENTITY_VERSION: "1", OON_DEPLOYMENT_ID: c.deploymentId,
|
|
84
|
-
OON_DEPLOYMENT_TOKEN: c.deploymentToken, OON_APP_CODE: c.appCode };
|
|
85
|
-
demand(Object.entries(expected).every(([k, v]) => env[k] === v) &&
|
|
86
|
-
typeof env.INSTANCE_CREDENTIAL_ENCRYPTION_KEY === "string" && env.INSTANCE_CREDENTIAL_ENCRYPTION_KEY.length >= 32 &&
|
|
87
|
-
!["MONGO_ADMIN_URI", "DIGITALOCEAN_TOKEN", "KUBECONFIG", "KUBECONFIG_B64", "PUBLISHER_KUBECONFIG_B64",
|
|
88
|
-
"GITHUB_APP_PRIVATE_KEY", "GITHUB_CONTROL_PLANE_APP_PRIVATE_KEY"].some(k => Object.hasOwn(env, k)), "PREPARED_RUNTIME_SECRET_INVALID");
|
|
89
|
-
let uri, username; try { uri = new URL(env.MONGO_URI); username = decodeURIComponent(uri.username); }
|
|
90
|
-
catch { fail("PREPARED_RUNTIME_MONGO_INVALID"); }
|
|
91
|
-
demand(["mongodb:", "mongodb+srv:"].includes(uri.protocol) && uri.hostname && uri.password &&
|
|
92
|
-
username === id.mongoUsername && uri.pathname === `/${id.database}` &&
|
|
93
|
-
uri.searchParams.get("authSource") === "admin", "PREPARED_RUNTIME_MONGO_INVALID");
|
|
94
|
-
if (c.functionalCapabilities?.["core.transactional-email"]?.enabled) {
|
|
95
|
-
let ring; try { ring = JSON.parse(env.OON_TRANSACTIONAL_EMAIL_KEK_KEYRING); } catch { fail("PREPARED_RUNTIME_CAPABILITY_MISSING"); }
|
|
96
|
-
demand(typeof ring.currentKeyId === "string" && typeof ring.keys?.[ring.currentKeyId] === "string" &&
|
|
97
|
-
Buffer.from(ring.keys[ring.currentKeyId], "base64").length === 32, "PREPARED_RUNTIME_CAPABILITY_MISSING");
|
|
98
|
-
}
|
|
99
|
-
if (c.capabilities?.pdfRendering) {
|
|
100
|
-
demand(env.OON_CAPABILITY_PDF_RENDERING_URL === c.capabilities.pdfRendering.endpoint &&
|
|
101
|
-
env.OON_CAPABILITY_PDF_RENDERING_VERSION === c.capabilities.pdfRendering.implementationVersion &&
|
|
102
|
-
env.OON_CAPABILITY_PDF_RENDERING_USERNAME && env.OON_CAPABILITY_PDF_RENDERING_PASSWORD, "PREPARED_RUNTIME_CAPABILITY_MISSING");
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function kubernetesClient(spawn = spawnSync) {
|
|
107
|
-
const command = (args, input, code) => {
|
|
108
|
-
let r; try { r = spawn("kubectl", [...args, "--request-timeout=60s"], {
|
|
109
|
-
input: input === undefined ? undefined : JSON.stringify(input), encoding: "utf8", timeout: 70000,
|
|
110
|
-
maxBuffer: 4 * 1024 * 1024, stdio: ["pipe", "pipe", "pipe"],
|
|
111
|
-
}); } catch { fail(code); }
|
|
112
|
-
if (r.error || r.status !== 0) fail(code); // Never include provider output or args in diagnostics.
|
|
113
|
-
return String(r.stdout || "");
|
|
114
|
-
};
|
|
115
|
-
const json = (raw, code) => { try { return JSON.parse(raw); } catch { fail(code); } };
|
|
116
|
-
return {
|
|
117
|
-
read: (kind, name, namespace) => {
|
|
118
|
-
const raw = command(["-n", namespace, "get", kind, name, "--ignore-not-found", "-o", "json"], undefined, "PREPARED_RUNTIME_READ_FAILED");
|
|
119
|
-
return raw.trim() ? json(raw, "PREPARED_RUNTIME_READ_FAILED") : null;
|
|
120
|
-
},
|
|
121
|
-
write: (doc, existing) => {
|
|
122
|
-
let next = copy(doc);
|
|
123
|
-
if (existing) {
|
|
124
|
-
next.metadata = { ...existing.metadata, ...next.metadata, resourceVersion: existing.metadata.resourceVersion,
|
|
125
|
-
uid: existing.metadata.uid, labels: { ...existing.metadata.labels, ...next.metadata.labels },
|
|
126
|
-
annotations: { ...next.metadata.annotations } };
|
|
127
|
-
delete next.metadata.managedFields;
|
|
128
|
-
if (next.kind === "Service") {
|
|
129
|
-
for (const field of ["clusterIP", "clusterIPs", "ipFamilies", "ipFamilyPolicy", "internalTrafficPolicy"]) {
|
|
130
|
-
if (existing.spec[field] !== undefined) next.spec[field] = existing.spec[field];
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
// create refuses a concurrent occupant; replace uses resourceVersion CAS.
|
|
135
|
-
return json(command([existing ? "replace" : "create", "-f", "-", "-o", "json"], next,
|
|
136
|
-
"PREPARED_RUNTIME_WRITE_OUTCOME_UNKNOWN"), "PREPARED_RUNTIME_WRITE_OUTCOME_UNKNOWN");
|
|
137
|
-
},
|
|
138
|
-
rollout: (name, namespace) => command(["-n", namespace, "rollout", "status", `deployment/${name}`, "--timeout=60s"], undefined, "PREPARED_RUNTIME_ROLLOUT_PENDING"),
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function renderPreparedRuntime({ context: c, imageDigest, authority }, receipt) {
|
|
143
|
-
const { identity: id, binding } = expectedPreparation({ context: c, imageDigest, authority });
|
|
144
|
-
const [cpuRequest, cpuLimit, memoryRequest, memoryLimit] = PROFILES[binding.resourceProfile];
|
|
145
|
-
const labels = { "app.kubernetes.io/name": c.appCode, "oon.runtime/instance-uid": id.instanceUid, "oon.runtime/identity-version": "1" };
|
|
146
|
-
const ownership = { "oon.delivery/installation": authority.installationId, "oon.delivery/authority-uid": authority.instanceUid,
|
|
147
|
-
"oon.delivery/technical-instance": c.technicalInstanceId, "oon.delivery/environment": c.environment };
|
|
148
|
-
const metadata = name => ({ name, namespace: id.namespace, labels, annotations: ownership });
|
|
149
|
-
const ingress = (name, hostname, tlsSecret, route) => ({ apiVersion: "networking.k8s.io/v1", kind: "Ingress",
|
|
150
|
-
metadata: { ...metadata(name), annotations: { ...ownership, "cert-manager.io/cluster-issuer": receipt.clusterIssuer } },
|
|
151
|
-
spec: { ingressClassName: receipt.ingressClass, tls: [{ hosts: [hostname], secretName: tlsSecret }],
|
|
152
|
-
rules: [{ host: hostname, http: { paths: [{ path: route, pathType: "Prefix", backend: { service: { name: c.appCode, port: { name: "http" } } } }] } }] } });
|
|
153
|
-
return [
|
|
154
|
-
{ apiVersion: "apps/v1", kind: "Deployment", metadata: metadata(c.appCode), spec: {
|
|
155
|
-
replicas: c.environment === "producao" ? 2 : 1, revisionHistoryLimit: 2,
|
|
156
|
-
strategy: { type: "RollingUpdate", rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } },
|
|
157
|
-
selector: { matchLabels: { "app.kubernetes.io/name": c.appCode } },
|
|
158
|
-
template: { metadata: { labels, annotations: { ...ownership, "oon.delivery/release": c.releaseId,
|
|
159
|
-
"oon.delivery/lease": c.leaseId, "oon.delivery/image-digest": imageDigest } }, spec: {
|
|
160
|
-
serviceAccountName: id.serviceAccount, automountServiceAccountToken: false,
|
|
161
|
-
securityContext: { runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000, seccompProfile: { type: "RuntimeDefault" } },
|
|
162
|
-
imagePullSecrets: [{ name: receipt.pullSecret.name }], containers: [{ name: "central", image: `${c.imageRepository}@${imageDigest}`,
|
|
163
|
-
imagePullPolicy: "IfNotPresent", ports: [{ name: "http", containerPort: 8080 }], envFrom: [{ secretRef: { name: id.secret } }],
|
|
164
|
-
securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] } },
|
|
165
|
-
resources: { requests: { cpu: cpuRequest, memory: memoryRequest }, limits: { cpu: cpuLimit, memory: memoryLimit } },
|
|
166
|
-
livenessProbe: { httpGet: { path: "/health/live", port: "http" }, initialDelaySeconds: 20 },
|
|
167
|
-
readinessProbe: { httpGet: { path: "/api/health/ready", port: "http" }, initialDelaySeconds: 10 } }],
|
|
168
|
-
} },
|
|
169
|
-
} },
|
|
170
|
-
{ 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" }] } },
|
|
171
|
-
ingress(c.appCode, c.frontendHostname, id.tlsSecret, "/"),
|
|
172
|
-
ingress(`${c.appCode}-api`, c.backendHostname, `${c.appCode}-api-tls`, "/api"),
|
|
173
|
-
];
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function deployPreparedRuntime(options, dependencies = {}) {
|
|
177
|
-
const frozen = copy(options); // No caller mutation while awaiting I/O.
|
|
178
|
-
const { context: c, authority: a } = frozen;
|
|
179
|
-
const { binding, identity: id, receiptName } = expectedPreparation(frozen);
|
|
180
|
-
const kube = dependencies.kube || kubernetesClient();
|
|
181
|
-
const receiptDoc = await kube.read("configmap", receiptName, id.namespace);
|
|
182
|
-
demand(receiptDoc, "PREPARED_RUNTIME_REQUIRED");
|
|
183
|
-
const receiptPin = pin(receiptDoc, receiptName, id.namespace, "ConfigMap");
|
|
184
|
-
let receipt; try { receipt = JSON.parse(receiptDoc.data?.receipt); } catch { fail("PREPARED_RUNTIME_RECEIPT_INVALID"); }
|
|
185
|
-
demand(receiptDoc.immutable === true && receipt?.phase === "prepared" && isDeepStrictEqual(receipt.binding, binding) &&
|
|
186
|
-
receipt.ingressClass === "nginx" && receipt.clusterIssuer === "letsencrypt-production-http01" &&
|
|
187
|
-
receipt.runtimeSecret?.name === id.secret && receipt.runtimeServiceAccount?.name === id.serviceAccount &&
|
|
188
|
-
receipt.pullSecret?.name === "ghcr-oon", "PREPARED_RUNTIME_RECEIPT_INVALID");
|
|
189
|
-
const checkPreparation = async () => {
|
|
190
|
-
samePin(await kube.read("configmap", receiptName, id.namespace), receiptPin, id.namespace, "ConfigMap");
|
|
191
|
-
const secret = await kube.read("secret", id.secret, id.namespace);
|
|
192
|
-
samePin(secret, receipt.runtimeSecret, id.namespace, "Secret");
|
|
193
|
-
assertRuntime(secret, c, id, a);
|
|
194
|
-
const sa = await kube.read("serviceaccount", id.serviceAccount, id.namespace);
|
|
195
|
-
samePin(sa, receipt.runtimeServiceAccount, id.namespace, "ServiceAccount");
|
|
196
|
-
demand(sa.automountServiceAccountToken === false, "PREPARED_RUNTIME_RESOURCE_INVALID");
|
|
197
|
-
const pull = await kube.read("secret", receipt.pullSecret.name, id.namespace);
|
|
198
|
-
samePin(pull, receipt.pullSecret, id.namespace, "Secret");
|
|
199
|
-
demand(pull.type === "kubernetes.io/dockerconfigjson", "PREPARED_RUNTIME_RESOURCE_INVALID");
|
|
200
|
-
};
|
|
201
|
-
await checkPreparation();
|
|
202
|
-
const docs = renderPreparedRuntime(frozen, receipt), existing = [];
|
|
203
|
-
for (const doc of docs) {
|
|
204
|
-
const current = await kube.read(doc.kind.toLowerCase(), doc.metadata.name, id.namespace);
|
|
205
|
-
if (current) {
|
|
206
|
-
pin(current, doc.metadata.name, id.namespace, doc.kind);
|
|
207
|
-
const ownership = doc.metadata.annotations;
|
|
208
|
-
demand(Object.entries(ownership).every(([k,v]) => current.metadata.annotations?.[k] === v), "PREPARED_RUNTIME_TARGET_OCCUPIED");
|
|
209
|
-
// An owned Ingress may still carry routing/auth/snippet directives that
|
|
210
|
-
// change its behavior. Require explicit reconciliation before any write.
|
|
211
|
-
demand(isDeepStrictEqual(current.metadata.annotations, ownership), "PREPARED_RUNTIME_ANNOTATIONS_UNAPPROVED");
|
|
212
|
-
}
|
|
213
|
-
existing.push(current);
|
|
214
|
-
}
|
|
215
|
-
for (let index = 0; index < docs.length; index++) {
|
|
216
|
-
await checkPreparation();
|
|
217
|
-
const doc = docs[index], before = existing[index], current = await kube.read(doc.kind.toLowerCase(), doc.metadata.name, id.namespace);
|
|
218
|
-
demand(before ? current?.metadata?.uid === before.metadata.uid && current.metadata.resourceVersion === before.metadata.resourceVersion : current === null,
|
|
219
|
-
"PREPARED_RUNTIME_TARGET_CHANGED");
|
|
220
|
-
await kube.write(doc, before);
|
|
221
|
-
}
|
|
222
|
-
await checkPreparation();
|
|
223
|
-
await kube.rollout(c.appCode, id.namespace);
|
|
224
|
-
await checkPreparation();
|
|
225
|
-
return { infraVersion: "prepared-member-v1", imageRef: `${c.imageRepository}@${frozen.imageDigest}`,
|
|
226
|
-
technicalIdentity: id, preparation: { name: receiptName, uid: receiptPin.uid },
|
|
227
|
-
proofLimits: ["no-distributed-lock-with-administrative-preparation", "rollout-is-not-functional-acceptance"] };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
module.exports = { expectedPreparation, renderPreparedRuntime, deployPreparedRuntime, kubernetesClient };
|
|
@@ -1,107 +0,0 @@
|
|
|
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 versão 0.6.12; 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
|
-
|
|
19
|
-
A preparação administrativa ainda será implementada na Infra. Deve ler novamente
|
|
20
|
-
singleton/configuração bloqueada, comandos e concorrência; conferir identidade,
|
|
21
|
-
release Prod e reservas nativas; preparar somente os alvos aprovados. Durante a
|
|
22
|
-
publicação, deve manter a exclusão mútua e a vigência do lease com a Central. Não
|
|
23
|
-
usar `ensureTechnicalInstance` para descoberta, nem adotar recursos por labels.
|
|
24
|
-
|
|
25
|
-
O preparador administra Namespace, quota, LimitRange, políticas de rede/admissão,
|
|
26
|
-
ServiceAccount runtime sem token, usuário Mongo limitado ao banco, runtime Secret,
|
|
27
|
-
pull secret e capacidades. Deve preservar recursos/chaves existentes e parar em
|
|
28
|
-
divergência. Nenhuma credencial administrativa deve ser projetada no membro.
|
|
29
|
-
|
|
30
|
-
## Recibo por publicação
|
|
31
|
-
|
|
32
|
-
`expectedPreparation(options)` retorna `binding`, `identity` e `receiptName`.
|
|
33
|
-
O preparador deverá usar a mesma função/versão fixada. `receiptName` é
|
|
34
|
-
`oon-preparation-` mais os primeiros 32 hex de SHA256 do JSON canônico de `binding`.
|
|
35
|
-
Objetos são ordenados recursivamente; arrays mantêm ordem. O hash do token também
|
|
36
|
-
usa JSON canônico da string, não os bytes crus. Nunca persistir o token no recibo.
|
|
37
|
-
|
|
38
|
-
No namespace canônico, criar ConfigMap `immutable: true` com `data.receipt` contendo
|
|
39
|
-
JSON com estes campos:
|
|
40
|
-
|
|
41
|
-
| Campo | Valor exigido |
|
|
42
|
-
| --- | --- |
|
|
43
|
-
| `phase` | `prepared` |
|
|
44
|
-
| `binding` | Exatamente o retornado pelo SDK: autoridade, identidade, ambiente, release, lease, imagem/digest, deployment, URLs, perfil e hashes das capacidades/token |
|
|
45
|
-
| `ingressClass` | `nginx` |
|
|
46
|
-
| `clusterIssuer` | `letsencrypt-production-http01` |
|
|
47
|
-
| `runtimeSecret` | `{name, uid, resourceVersion}` do Secret canônico final |
|
|
48
|
-
| `runtimeServiceAccount` | `{name, uid, resourceVersion}` da SA canônica final |
|
|
49
|
-
| `pullSecret` | `{name: "ghcr-oon", uid, resourceVersion}` do Secret local final |
|
|
50
|
-
|
|
51
|
-
O recibo é o último recurso da preparação. Não é uma assinatura: sua autoridade
|
|
52
|
-
depende de o executor não poder criar/alterar/apagar ConfigMaps, Secrets ou SAs.
|
|
53
|
-
Não reutilizar recibo em outro lease/digest. UID e resourceVersion dos recursos são
|
|
54
|
-
reconferidos antes de cada escrita e após o rollout. Isso detecta deriva observada,
|
|
55
|
-
mas não substitui um lock distribuído com o preparador.
|
|
56
|
-
|
|
57
|
-
O runtime Secret deve conter identidade/URLs, credencial de deployment e chave de
|
|
58
|
-
criptografia já preparadas. Mongo exige URI de host único `mongodb`/`mongodb+srv`,
|
|
59
|
-
usuário e banco canônicos e `authSource=admin`; o adapter não prova os privilégios
|
|
60
|
-
desse usuário. E-mail habilitado exige keyring existente; PDF exige endpoint,
|
|
61
|
-
versão e credenciais projetadas. O adapter nunca gera chaves ou busca Secrets
|
|
62
|
-
administrativos. A recusa de nomes administrativos conhecidos é defesa adicional,
|
|
63
|
-
não uma prova da origem ou do escopo de todas as credenciais.
|
|
64
|
-
|
|
65
|
-
## Permissões necessárias e limites
|
|
66
|
-
|
|
67
|
-
Leitura `get` somente dos nomes do recibo, runtime Secret, pull Secret e runtime SA
|
|
68
|
-
no alvo; leitura/escrita de Deployment, Service e dois Ingresses canônicos; rollout
|
|
69
|
-
consulta/observa o Deployment nomeado. Não requer namespaces, RBAC, nodes, jobs,
|
|
70
|
-
pods/exec, serviceaccounts/token ou acesso a `oon-system`/outros namespaces.
|
|
71
|
-
|
|
72
|
-
Não aplicar uma Role genérica com base apenas nesta lista. `create` de recursos
|
|
73
|
-
principais não pode ser limitado por `resourceNames` no RBAC; workloads também
|
|
74
|
-
podem montar Secrets e escolher SAs. Antes de conceder escrita, a Infra deve
|
|
75
|
-
implementar e provar admissão restritiva (nomes, imagem, hosts, SAs, referências a
|
|
76
|
-
Secrets, pod security) ou um mecanismo administrativo equivalente. RBAC deve ser
|
|
77
|
-
por alvo/ambiente/instalação, com autenticação dedicada e recusas cruzadas. Ver
|
|
78
|
-
[limites oficiais do RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources).
|
|
79
|
-
|
|
80
|
-
O renderer produz somente Deployment, Service e Ingresses frontend/backend, por
|
|
81
|
-
digest, sem token de SA, sem privilégio, UID/GID 1000 e seccomp RuntimeDefault.
|
|
82
|
-
A compatibilidade das imagens dos três membros com esse usuário ainda precisa
|
|
83
|
-
ser provada. Não oferece manifest arbitrário, initContainers, volumes ou hooks.
|
|
84
|
-
|
|
85
|
-
Um alvo existente exige ownership correspondente e UID/resourceVersion observados;
|
|
86
|
-
annotations adicionais são recusadas antes de qualquer escrita, inclusive regras
|
|
87
|
-
de rewrite, redirect, autenticação ou snippets de Ingress. Exigem reconciliação
|
|
88
|
-
administrativa explícita; não são herdadas silenciosamente na substituição.
|
|
89
|
-
`replace` usa CAS, `create` recusa ocupante concorrente. Essa conferência no cliente
|
|
90
|
-
não é autorização nem prova de ownership canônico no banco. Não adota recursos
|
|
91
|
-
legados. Falha parcial/resultado de escrita incerto interrompe sem retry ou rollback.
|
|
92
|
-
O operador deve reconciliar resultado com o comando antes de tentar novamente.
|
|
93
|
-
Rollout tem limite de 60 segundos; timeout é pendência, não motivo para nova escrita.
|
|
94
|
-
Rollout bem-sucedido não prova HTTP, TLS, callback ou aceite funcional.
|
|
95
|
-
|
|
96
|
-
## Gate de uso
|
|
97
|
-
|
|
98
|
-
Manter delivery bloqueado até implementar preparador e admissão/bindings, provar
|
|
99
|
-
positivos e recusas por alvo, publicar a versão do SDK e ligar ambos os consumidores
|
|
100
|
-
ao novo adapter. O preparador de kubeconfig atual exige `create namespaces` e o
|
|
101
|
-
script atual ainda chama provisionamento administrativo: ambos precisam de caminho
|
|
102
|
-
específico para membros. Não satisfazer esse guard concedendo namespace create.
|
|
103
|
-
Nenhuma chamada deve cair no provisionador antigo se o recibo estiver ausente.
|
|
104
|
-
|
|
105
|
-
Testes locais: `node --test packages/oonCore-back/test/prepared-runtime.test.js
|
|
106
|
-
packages/oonCore-back/test/delivery-technical-identity.test.js`. Os fakes verificam
|
|
107
|
-
comportamento do adapter; não comprovam permissões Kubernetes ou integração real.
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const technical = require("../core/technicalIdentity");
|
|
4
|
-
|
|
5
|
-
const SUPPORTED_DELIVERY_IDENTITY_VERSION = 1;
|
|
6
|
-
const CANONICAL_FIELDS = Object.freeze([
|
|
7
|
-
"version",
|
|
8
|
-
"appCode",
|
|
9
|
-
"instanceUid",
|
|
10
|
-
"environment",
|
|
11
|
-
"resourceBase",
|
|
12
|
-
"namespace",
|
|
13
|
-
"database",
|
|
14
|
-
"mongoUsername",
|
|
15
|
-
"serviceAccount",
|
|
16
|
-
"secret",
|
|
17
|
-
"provisionJob",
|
|
18
|
-
"frontendDnsLabel",
|
|
19
|
-
"backendDnsLabel",
|
|
20
|
-
"tlsSecret",
|
|
21
|
-
]);
|
|
22
|
-
const CANONICAL_SIGNAL_FIELDS = Object.freeze([
|
|
23
|
-
"instanceUid",
|
|
24
|
-
"technicalIdentityVersion",
|
|
25
|
-
"technicalInstanceId",
|
|
26
|
-
"technicalIdentity",
|
|
27
|
-
]);
|
|
28
|
-
|
|
29
|
-
function deliveryIdentityError(code, message, details = {}) {
|
|
30
|
-
const error = new Error(message);
|
|
31
|
-
error.name = "DeliveryTechnicalIdentityError";
|
|
32
|
-
error.code = code;
|
|
33
|
-
error.details = details;
|
|
34
|
-
return error;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function hasOwn(object, field) {
|
|
38
|
-
return Object.prototype.hasOwnProperty.call(object || {}, field);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function hasCanonicalIdentitySignal(context = {}) {
|
|
42
|
-
return CANONICAL_SIGNAL_FIELDS.some((field) => hasOwn(context, field));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function legacyDeliveryIdentity(context = {}) {
|
|
46
|
-
return Object.freeze({
|
|
47
|
-
mode: "legacy",
|
|
48
|
-
version: 0,
|
|
49
|
-
appCode: String(context.appCode || ""),
|
|
50
|
-
environment: String(context.environment || ""),
|
|
51
|
-
namespace: String(context.namespace || ""),
|
|
52
|
-
serviceAccount: "oon-runtime",
|
|
53
|
-
secret: "oon-runtime",
|
|
54
|
-
tlsSecret: `${String(context.appCode || "")}-tls`,
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function assertProjection(canonical, context = {}) {
|
|
59
|
-
const mismatches = [];
|
|
60
|
-
if (context.namespace != null && String(context.namespace) !== canonical.namespace) {
|
|
61
|
-
mismatches.push("namespace");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (hasOwn(context, "technicalIdentity")) {
|
|
65
|
-
const projected = context.technicalIdentity;
|
|
66
|
-
if (!projected || typeof projected !== "object" || Array.isArray(projected)) {
|
|
67
|
-
throw deliveryIdentityError(
|
|
68
|
-
"DELIVERY_TECHNICAL_IDENTITY_PROJECTION_INVALID",
|
|
69
|
-
"A projeção da identidade técnica do delivery é inválida.",
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
for (const field of CANONICAL_FIELDS) {
|
|
73
|
-
if (projected[field] == null) continue;
|
|
74
|
-
if (String(projected[field]) !== String(canonical[field])) mismatches.push(field);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (mismatches.length) {
|
|
79
|
-
throw deliveryIdentityError(
|
|
80
|
-
"DELIVERY_TECHNICAL_IDENTITY_MISMATCH",
|
|
81
|
-
"O contexto de delivery diverge da identidade técnica canônica.",
|
|
82
|
-
{ mismatches: [...new Set(mismatches)] },
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function resolveDeliveryTechnicalIdentity(context = {}, { requireCanonical = false } = {}) {
|
|
88
|
-
if (!hasCanonicalIdentitySignal(context)) {
|
|
89
|
-
if (requireCanonical) {
|
|
90
|
-
throw deliveryIdentityError(
|
|
91
|
-
"DELIVERY_TECHNICAL_IDENTITY_REQUIRED",
|
|
92
|
-
"O contexto de delivery exige identidade técnica canônica.",
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
return legacyDeliveryIdentity(context);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (context.instanceUid == null || context.technicalIdentityVersion == null) {
|
|
99
|
-
throw deliveryIdentityError(
|
|
100
|
-
"DELIVERY_TECHNICAL_IDENTITY_INCOMPLETE",
|
|
101
|
-
"O contexto de delivery possui identidade técnica parcial.",
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
if (Number(context.technicalIdentityVersion) !== SUPPORTED_DELIVERY_IDENTITY_VERSION) {
|
|
105
|
-
throw deliveryIdentityError(
|
|
106
|
-
"DELIVERY_TECHNICAL_IDENTITY_VERSION_UNSUPPORTED",
|
|
107
|
-
"A versão da identidade técnica do delivery não é suportada.",
|
|
108
|
-
{ received: context.technicalIdentityVersion, supported: SUPPORTED_DELIVERY_IDENTITY_VERSION },
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const canonical = technical.technicalIdentity({
|
|
113
|
-
appCode: context.appCode,
|
|
114
|
-
instanceUid: context.instanceUid,
|
|
115
|
-
environment: context.environment,
|
|
116
|
-
});
|
|
117
|
-
assertProjection(canonical, context);
|
|
118
|
-
return Object.freeze({ mode: "canonical", ...canonical });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function canonicalizeDeliveryContext(context = {}, options = {}) {
|
|
122
|
-
const identity = resolveDeliveryTechnicalIdentity(context, options);
|
|
123
|
-
if (identity.mode !== "canonical") {
|
|
124
|
-
return Object.freeze({ ...context, deliveryTechnicalIdentity: identity });
|
|
125
|
-
}
|
|
126
|
-
return Object.freeze({
|
|
127
|
-
...context,
|
|
128
|
-
namespace: identity.namespace,
|
|
129
|
-
deliveryTechnicalIdentity: identity,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
module.exports = Object.freeze({
|
|
134
|
-
CANONICAL_FIELDS,
|
|
135
|
-
CANONICAL_SIGNAL_FIELDS,
|
|
136
|
-
SUPPORTED_DELIVERY_IDENTITY_VERSION,
|
|
137
|
-
canonicalizeDeliveryContext,
|
|
138
|
-
hasCanonicalIdentitySignal,
|
|
139
|
-
resolveDeliveryTechnicalIdentity,
|
|
140
|
-
});
|