@oondemand/oon-core-back 0.6.8 → 0.6.10
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 +5 -2
- package/package.json +1 -1
- package/src/core/technicalIdentity.js +13 -1
- package/src/delivery/mongoCredentialLifecycle.js +33 -0
- package/src/delivery/mongoProvisioning.js +117 -122
- package/src/delivery/runtime.js +164 -91
- package/src/delivery/technicalIdentity.js +140 -0
package/bin/delivery.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs = require("node:fs");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { getContextWithPhase, setState, addEvidence } = require("../src/delivery/api");
|
|
7
7
|
const { provisionRuntime, deployRuntime, waitHttp, activateMember } = require("../src/delivery/runtime");
|
|
8
|
+
const { canonicalizeDeliveryContext } = require("../src/delivery/technicalIdentity");
|
|
8
9
|
|
|
9
10
|
function flag(name, fallback) {
|
|
10
11
|
const args = process.argv.slice(3);
|
|
@@ -16,6 +17,7 @@ function flag(name, fallback) {
|
|
|
16
17
|
}
|
|
17
18
|
function required(value, name) { if (!value) throw new Error(`${name} obrigatório.`); return value; }
|
|
18
19
|
function secret() { return required(process.env.OON_DELIVERY_SHARED_SECRET, "OON_DELIVERY_SHARED_SECRET"); }
|
|
20
|
+
function normalizedContext(value) { return { ...canonicalizeDeliveryContext(value) }; }
|
|
19
21
|
|
|
20
22
|
async function main() {
|
|
21
23
|
const command = process.argv[2];
|
|
@@ -25,7 +27,8 @@ async function main() {
|
|
|
25
27
|
if (command === "context") {
|
|
26
28
|
const phase = flag("--phase", "build");
|
|
27
29
|
const output = required(flag("--output"), "--output");
|
|
28
|
-
const
|
|
30
|
+
const fetched = await getContextWithPhase({ apiUrl: required(apiUrl, "--api"), releaseId: required(releaseId, "--release"), token: secret(), phase });
|
|
31
|
+
const context = normalizedContext(fetched);
|
|
29
32
|
fs.writeFileSync(path.resolve(output), JSON.stringify(context, null, 2), { mode: 0o600 });
|
|
30
33
|
return;
|
|
31
34
|
}
|
|
@@ -40,7 +43,7 @@ async function main() {
|
|
|
40
43
|
if (command === "deploy") {
|
|
41
44
|
const contextFile = required(flag("--context"), "--context");
|
|
42
45
|
const imageDigest = required(flag("--image-digest"), "--image-digest");
|
|
43
|
-
const context = JSON.parse(fs.readFileSync(path.resolve(contextFile), "utf8"));
|
|
46
|
+
const context = normalizedContext(JSON.parse(fs.readFileSync(path.resolve(contextFile), "utf8")));
|
|
44
47
|
let failureState = "falha_infra";
|
|
45
48
|
try {
|
|
46
49
|
await setState({ apiUrl, releaseId, token: secret(), etapa: "provisionando", imageDigest });
|
package/package.json
CHANGED
|
@@ -113,6 +113,10 @@ function technicalIdentity({ appCode, instanceUid, environment }) {
|
|
|
113
113
|
const mongoUsername = `${base}-rw`;
|
|
114
114
|
const serviceAccount = `${base}-runtime`;
|
|
115
115
|
const secret = `${base}-runtime`;
|
|
116
|
+
const provisionJob = `${base}-provision`;
|
|
117
|
+
const frontendDnsLabel = base;
|
|
118
|
+
const backendDnsLabel = `${base}-api`;
|
|
119
|
+
const tlsSecret = `${base}-tls`;
|
|
116
120
|
|
|
117
121
|
const lengths = {
|
|
118
122
|
base: base.length,
|
|
@@ -120,6 +124,10 @@ function technicalIdentity({ appCode, instanceUid, environment }) {
|
|
|
120
124
|
mongoUsername: mongoUsername.length,
|
|
121
125
|
serviceAccount: serviceAccount.length,
|
|
122
126
|
secret: secret.length,
|
|
127
|
+
provisionJob: provisionJob.length,
|
|
128
|
+
frontendDnsLabel: frontendDnsLabel.length,
|
|
129
|
+
backendDnsLabel: backendDnsLabel.length,
|
|
130
|
+
tlsSecret: tlsSecret.length,
|
|
123
131
|
};
|
|
124
132
|
const invalidLength = Object.entries(lengths).find(([, length]) => length > 63);
|
|
125
133
|
if (invalidLength) {
|
|
@@ -141,6 +149,10 @@ function technicalIdentity({ appCode, instanceUid, environment }) {
|
|
|
141
149
|
mongoUsername,
|
|
142
150
|
serviceAccount,
|
|
143
151
|
secret,
|
|
152
|
+
provisionJob,
|
|
153
|
+
frontendDnsLabel,
|
|
154
|
+
backendDnsLabel,
|
|
155
|
+
tlsSecret,
|
|
144
156
|
});
|
|
145
157
|
}
|
|
146
158
|
|
|
@@ -159,4 +171,4 @@ module.exports = Object.freeze({
|
|
|
159
171
|
encodeInstanceUid,
|
|
160
172
|
generateInstanceUid,
|
|
161
173
|
technicalIdentity,
|
|
162
|
-
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
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,11 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const defaultMongoClient = () => require("mongoose").mongo.MongoClient;
|
|
4
|
+
const { technicalIdentity } = require("../core/technicalIdentity");
|
|
4
5
|
|
|
5
6
|
const DIGITALOCEAN_API = "https://api.digitalocean.com/v2";
|
|
6
7
|
const DIGITALOCEAN_HOST_SUFFIX = ".mongo.ondigitalocean.com";
|
|
7
8
|
const DIGITALOCEAN_RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
8
|
-
const DIGITALOCEAN_SERVER_STATUS = new Set([500, 502, 503, 504]);
|
|
9
9
|
|
|
10
10
|
function delay(ms) {
|
|
11
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -24,53 +24,76 @@ function dnsLabel(value, max = 63) {
|
|
|
24
24
|
.replace(/-+$/, "");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function mongoIdentity(appCode, environment) {
|
|
27
|
+
function mongoIdentity(appCode, environment, { instanceUid } = {}) {
|
|
28
|
+
if (instanceUid != null) {
|
|
29
|
+
const identity = technicalIdentity({ appCode, instanceUid, environment });
|
|
30
|
+
return {
|
|
31
|
+
mode: "canonical",
|
|
32
|
+
identityVersion: identity.version,
|
|
33
|
+
instanceUid: identity.instanceUid,
|
|
34
|
+
dbName: identity.database,
|
|
35
|
+
username: identity.mongoUsername,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
28
38
|
return {
|
|
39
|
+
mode: "legacy",
|
|
29
40
|
dbName: dnsLabel(`${appCode}_${environment}`, 48).replace(/-/g, "_"),
|
|
30
41
|
username: dnsLabel(`${appCode}-${environment}`, 48),
|
|
31
42
|
};
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
function
|
|
45
|
+
function recoveryRequired(code = "MONGO_RECOVERY_REQUIRED") {
|
|
46
|
+
const error = new Error(`${code}: recuperação explícita necessária; credenciais preservadas.`);
|
|
47
|
+
error.code = code;
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// WHATWG URL rejects MongoDB seed lists with multiple host:port pairs.
|
|
52
|
+
function parseMongoUri(uri) {
|
|
53
|
+
const match = typeof uri === "string" && uri.match(/^(mongodb(?:\+srv)?):\/\/([^/?#]+)(?:\/([^?#]*))?(?:\?([^#]*))?$/);
|
|
54
|
+
if (!match) throw recoveryRequired("MONGO_URI_INVALID");
|
|
55
|
+
const [, scheme, authority, database = "", query = ""] = match;
|
|
56
|
+
const at = authority.lastIndexOf("@");
|
|
57
|
+
const credentials = at < 0 ? "" : authority.slice(0, at);
|
|
58
|
+
const hosts = authority.slice(at + 1);
|
|
59
|
+
if (!hosts) throw recoveryRequired("MONGO_URI_INVALID");
|
|
35
60
|
try {
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
61
|
+
return { scheme, hosts, database: decodeURIComponent(database),
|
|
62
|
+
username: decodeURIComponent(credentials.split(":")[0]), query: new URLSearchParams(query) };
|
|
63
|
+
} catch { throw recoveryRequired("MONGO_URI_INVALID"); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function mongoHost(uri) {
|
|
67
|
+
const parsed = parseMongoUri(uri);
|
|
68
|
+
return parsed.hosts.split(",")[0].replace(/:\d+$/, "").toLowerCase();
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
function resolveMongoProvisioner({ adminUri, provisioner = "auto" }) {
|
|
43
72
|
const normalized = String(provisioner || "auto").trim().toLowerCase();
|
|
44
73
|
if (!["auto", "native", "digitalocean"].includes(normalized)) {
|
|
45
|
-
throw
|
|
46
|
-
`MONGO_PROVISIONER inválido: ${normalized}. Use auto, native ou digitalocean.`,
|
|
47
|
-
);
|
|
74
|
+
throw recoveryRequired("MONGO_PROVISIONER_INVALID");
|
|
48
75
|
}
|
|
49
76
|
if (normalized !== "auto") return normalized;
|
|
50
|
-
return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX)
|
|
51
|
-
? "digitalocean"
|
|
52
|
-
: "native";
|
|
77
|
+
return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX) ? "digitalocean" : "native";
|
|
53
78
|
}
|
|
54
79
|
|
|
55
|
-
function buildRuntimeMongoUri({
|
|
56
|
-
adminUri
|
|
57
|
-
|
|
58
|
-
password
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
uri.searchParams.set("authSource", authSource);
|
|
67
|
-
return uri.toString();
|
|
80
|
+
function buildRuntimeMongoUri({ adminUri, username, password, dbName, authSource }) {
|
|
81
|
+
const parsed = parseMongoUri(adminUri);
|
|
82
|
+
parsed.query.set("authSource", authSource);
|
|
83
|
+
return `${parsed.scheme}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${parsed.hosts}/${encodeURIComponent(dbName)}?${parsed.query}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertMongoIdentity(uri, identity) {
|
|
87
|
+
const parsed = parseMongoUri(uri);
|
|
88
|
+
if (parsed.username !== identity.username || parsed.database !== identity.dbName) {
|
|
89
|
+
throw recoveryRequired("MONGO_IDENTITY_MISMATCH");
|
|
90
|
+
}
|
|
68
91
|
}
|
|
69
92
|
|
|
70
93
|
async function validateMongoUri(
|
|
71
94
|
mongoUri,
|
|
72
95
|
{
|
|
73
|
-
MongoClient =
|
|
96
|
+
MongoClient = defaultMongoClient(),
|
|
74
97
|
timeoutMs = 15000,
|
|
75
98
|
writeProbe = true,
|
|
76
99
|
} = {},
|
|
@@ -81,9 +104,16 @@ async function validateMongoUri(
|
|
|
81
104
|
maxPoolSize: 2,
|
|
82
105
|
});
|
|
83
106
|
|
|
84
|
-
await client.connect();
|
|
85
107
|
try {
|
|
108
|
+
await client.connect();
|
|
86
109
|
const db = client.db();
|
|
110
|
+
const target = parseMongoUri(mongoUri);
|
|
111
|
+
const status = await db.command({ connectionStatus: 1 });
|
|
112
|
+
const roles = status?.authInfo?.authenticatedUserRoles;
|
|
113
|
+
if (!target.database || !Array.isArray(roles) || !roles.length ||
|
|
114
|
+
roles.some(role => role.role !== "readWrite" || role.db !== target.database)) {
|
|
115
|
+
throw recoveryRequired("MONGO_RUNTIME_SCOPE_UNSAFE");
|
|
116
|
+
}
|
|
87
117
|
await db.command({ ping: 1 });
|
|
88
118
|
if (writeProbe) {
|
|
89
119
|
const probe = db.collection("__oon_runtime_probe");
|
|
@@ -121,7 +151,7 @@ async function waitForMongoUri(
|
|
|
121
151
|
}
|
|
122
152
|
}
|
|
123
153
|
}
|
|
124
|
-
const reason = lastError?.
|
|
154
|
+
const reason = [13, 18].includes(lastError?.code) ? lastError.code : undefined;
|
|
125
155
|
throw new Error(
|
|
126
156
|
`A credencial derivada do MongoDB não passou no teste de leitura e escrita${
|
|
127
157
|
reason ? ` (${reason})` : ""
|
|
@@ -129,15 +159,23 @@ async function waitForMongoUri(
|
|
|
129
159
|
);
|
|
130
160
|
}
|
|
131
161
|
|
|
132
|
-
async function provisionNativeMongo({ adminUri, appCode, environment }) {
|
|
133
|
-
const
|
|
162
|
+
async function provisionNativeMongo({ adminUri, appCode, environment, instanceUid }, deps = {}) {
|
|
163
|
+
const identity = mongoIdentity(appCode, environment, { instanceUid });
|
|
164
|
+
const { dbName, username } = identity;
|
|
134
165
|
const password = randomSecret(30);
|
|
135
|
-
const
|
|
136
|
-
|
|
166
|
+
const MongoClient = deps.MongoClient || defaultMongoClient();
|
|
167
|
+
const client = new MongoClient(adminUri, {
|
|
168
|
+
serverSelectionTimeoutMS: 15000, connectTimeoutMS: 15000, maxPoolSize: 2,
|
|
137
169
|
});
|
|
138
|
-
await client.connect();
|
|
139
170
|
try {
|
|
171
|
+
await client.connect();
|
|
140
172
|
const db = client.db(dbName);
|
|
173
|
+
const existing = await db.command({ usersInfo: username });
|
|
174
|
+
if (!Array.isArray(existing.users) || existing.users.length) throw recoveryRequired();
|
|
175
|
+
const mongoUri = buildRuntimeMongoUri({ adminUri, username, password, dbName, authSource: dbName });
|
|
176
|
+
if (typeof deps.persistCredential !== "function") throw recoveryRequired("MONGO_CREDENTIAL_PERSISTENCE_REQUIRED");
|
|
177
|
+
// Capture before createUser: loss of its response never requires password rotation.
|
|
178
|
+
await deps.persistCredential(mongoUri);
|
|
141
179
|
try {
|
|
142
180
|
await db.command({
|
|
143
181
|
createUser: username,
|
|
@@ -150,11 +188,7 @@ async function provisionNativeMongo({ adminUri, appCode, environment }) {
|
|
|
150
188
|
error.code === 51003 ||
|
|
151
189
|
/already exists/i.test(error.message);
|
|
152
190
|
if (!alreadyExists) throw error;
|
|
153
|
-
|
|
154
|
-
updateUser: username,
|
|
155
|
-
pwd: password,
|
|
156
|
-
roles: [{ role: "readWrite", db: dbName }],
|
|
157
|
-
});
|
|
191
|
+
throw recoveryRequired();
|
|
158
192
|
}
|
|
159
193
|
} finally {
|
|
160
194
|
await client.close();
|
|
@@ -163,6 +197,9 @@ async function provisionNativeMongo({ adminUri, appCode, environment }) {
|
|
|
163
197
|
return {
|
|
164
198
|
dbName,
|
|
165
199
|
username,
|
|
200
|
+
...(identity.mode === "canonical"
|
|
201
|
+
? { instanceUid: identity.instanceUid, technicalIdentityVersion: identity.identityVersion }
|
|
202
|
+
: {}),
|
|
166
203
|
mongoUri: buildRuntimeMongoUri({
|
|
167
204
|
adminUri,
|
|
168
205
|
username,
|
|
@@ -191,7 +228,7 @@ async function digitalOceanRequest(
|
|
|
191
228
|
} = {},
|
|
192
229
|
) {
|
|
193
230
|
if (!fetchImpl) throw new Error("fetch não disponível para a DigitalOcean API.");
|
|
194
|
-
const attempts = Math.max(1, Number(retryAttempts) || 3);
|
|
231
|
+
const attempts = method === "GET" ? Math.max(1, Number(retryAttempts) || 3) : 1;
|
|
195
232
|
|
|
196
233
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
197
234
|
const controller = new AbortController();
|
|
@@ -209,6 +246,7 @@ async function digitalOceanRequest(
|
|
|
209
246
|
},
|
|
210
247
|
body: body ? JSON.stringify(body) : undefined,
|
|
211
248
|
signal: controller.signal,
|
|
249
|
+
redirect: "error",
|
|
212
250
|
},
|
|
213
251
|
);
|
|
214
252
|
} catch (error) {
|
|
@@ -219,7 +257,7 @@ async function digitalOceanRequest(
|
|
|
219
257
|
if (error.name === "AbortError") {
|
|
220
258
|
throw new Error("Timeout ao acessar a DigitalOcean API.");
|
|
221
259
|
}
|
|
222
|
-
throw
|
|
260
|
+
throw recoveryRequired("MONGO_PROVIDER_REQUEST_FAILED");
|
|
223
261
|
} finally {
|
|
224
262
|
clearTimeout(timeout);
|
|
225
263
|
}
|
|
@@ -241,20 +279,8 @@ async function digitalOceanRequest(
|
|
|
241
279
|
continue;
|
|
242
280
|
}
|
|
243
281
|
|
|
244
|
-
const
|
|
245
|
-
.replace(/[\r\n]+/g, " ")
|
|
246
|
-
.slice(0, 300);
|
|
247
|
-
const providerRequestId = String(payload.request_id || "")
|
|
248
|
-
.replace(/[^A-Za-z0-9_.:-]/g, "")
|
|
249
|
-
.slice(0, 120);
|
|
250
|
-
const error = new Error(
|
|
251
|
-
`DigitalOcean API rejeitou ${method} ${path}: HTTP ${response.status} (${providerMessage})`
|
|
252
|
-
+ (providerRequestId ? ` [requestId: ${providerRequestId}]` : "")
|
|
253
|
-
+ ".",
|
|
254
|
-
);
|
|
282
|
+
const error = recoveryRequired(`MONGO_PROVIDER_HTTP_${response.status}`);
|
|
255
283
|
error.status = response.status;
|
|
256
|
-
error.payload = payload;
|
|
257
|
-
error.providerRequestId = providerRequestId || undefined;
|
|
258
284
|
throw error;
|
|
259
285
|
}
|
|
260
286
|
|
|
@@ -273,19 +299,14 @@ async function ensureDigitalOceanDatabase(options, deps) {
|
|
|
273
299
|
} catch (error) {
|
|
274
300
|
if (error.status === 404) {
|
|
275
301
|
exists = false;
|
|
276
|
-
} else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
|
|
277
|
-
const listed = await digitalOceanRequest(
|
|
278
|
-
{ ...options, path: "/dbs", expected: [200] },
|
|
279
|
-
deps,
|
|
280
|
-
);
|
|
281
|
-
exists = Array.isArray(listed.payload.dbs)
|
|
282
|
-
&& listed.payload.dbs.some((item) => item?.name === options.dbName);
|
|
283
302
|
} else {
|
|
284
303
|
throw error;
|
|
285
304
|
}
|
|
286
305
|
}
|
|
287
306
|
if (exists) return;
|
|
288
307
|
|
|
308
|
+
// Refuse a pre-existing/ambiguous user before creating any database resource.
|
|
309
|
+
await assertDigitalOceanUserAbsent(options, deps);
|
|
289
310
|
await digitalOceanRequest(
|
|
290
311
|
{
|
|
291
312
|
...options,
|
|
@@ -307,70 +328,35 @@ function digitalOceanUserSettings(dbName) {
|
|
|
307
328
|
};
|
|
308
329
|
}
|
|
309
330
|
|
|
310
|
-
async function
|
|
311
|
-
const userPath = `/users/${encodeURIComponent(options.username)}`;
|
|
312
|
-
let exists = false;
|
|
331
|
+
async function assertDigitalOceanUserAbsent(options, deps) {
|
|
313
332
|
try {
|
|
314
333
|
await digitalOceanRequest(
|
|
315
|
-
{ ...options, path:
|
|
334
|
+
{ ...options, path: `/users/${encodeURIComponent(options.username)}`, expected: [200] },
|
|
316
335
|
deps,
|
|
317
336
|
);
|
|
318
|
-
exists = true;
|
|
319
337
|
} catch (error) {
|
|
320
|
-
if (error.status === 404)
|
|
321
|
-
|
|
322
|
-
} else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
|
|
323
|
-
const listed = await digitalOceanRequest(
|
|
324
|
-
{ ...options, path: "/users", expected: [200] },
|
|
325
|
-
deps,
|
|
326
|
-
);
|
|
327
|
-
exists = Array.isArray(listed.payload.users)
|
|
328
|
-
&& listed.payload.users.some(
|
|
329
|
-
(item) => item?.name === options.username,
|
|
330
|
-
);
|
|
331
|
-
} else {
|
|
332
|
-
throw error;
|
|
333
|
-
}
|
|
338
|
+
if (error.status === 404) return;
|
|
339
|
+
throw error;
|
|
334
340
|
}
|
|
341
|
+
throw recoveryRequired();
|
|
342
|
+
}
|
|
335
343
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
} else {
|
|
350
|
-
await digitalOceanRequest(
|
|
351
|
-
{
|
|
352
|
-
...options,
|
|
353
|
-
path: userPath,
|
|
354
|
-
method: "PUT",
|
|
355
|
-
body: { settings },
|
|
356
|
-
expected: [200, 202],
|
|
357
|
-
},
|
|
358
|
-
deps,
|
|
359
|
-
);
|
|
360
|
-
result = await digitalOceanRequest(
|
|
361
|
-
{
|
|
362
|
-
...options,
|
|
363
|
-
path: `${userPath}/reset_auth`,
|
|
364
|
-
method: "POST",
|
|
365
|
-
body: {},
|
|
366
|
-
expected: [200, 201, 202],
|
|
367
|
-
},
|
|
368
|
-
deps,
|
|
369
|
-
);
|
|
370
|
-
}
|
|
344
|
+
async function provisionDigitalOceanUser(options, deps) {
|
|
345
|
+
// Recheck after database creation: another publisher may have created the user.
|
|
346
|
+
await assertDigitalOceanUserAbsent(options, deps);
|
|
347
|
+
const result = await digitalOceanRequest(
|
|
348
|
+
{
|
|
349
|
+
...options,
|
|
350
|
+
path: "/users",
|
|
351
|
+
method: "POST",
|
|
352
|
+
body: { name: options.username, settings: digitalOceanUserSettings(options.dbName) },
|
|
353
|
+
expected: [201, 202],
|
|
354
|
+
},
|
|
355
|
+
deps,
|
|
356
|
+
);
|
|
371
357
|
|
|
372
358
|
const user = result.payload.user;
|
|
373
|
-
if (
|
|
359
|
+
if (user?.name !== options.username || typeof user?.password !== "string" || !user.password) {
|
|
374
360
|
throw new Error(
|
|
375
361
|
"DigitalOcean API não retornou a senha do usuário. O token precisa do escopo database:view_credentials.",
|
|
376
362
|
);
|
|
@@ -383,6 +369,7 @@ async function provisionDigitalOceanMongo(
|
|
|
383
369
|
adminUri,
|
|
384
370
|
appCode,
|
|
385
371
|
environment,
|
|
372
|
+
instanceUid,
|
|
386
373
|
digitalOceanToken,
|
|
387
374
|
digitalOceanClusterId,
|
|
388
375
|
},
|
|
@@ -399,23 +386,28 @@ async function provisionDigitalOceanMongo(
|
|
|
399
386
|
);
|
|
400
387
|
}
|
|
401
388
|
|
|
402
|
-
const
|
|
389
|
+
const identity = mongoIdentity(appCode, environment, { instanceUid });
|
|
390
|
+
const { dbName, username } = identity;
|
|
403
391
|
const apiOptions = {
|
|
404
392
|
token: digitalOceanToken,
|
|
405
393
|
clusterId: digitalOceanClusterId,
|
|
406
394
|
dbName,
|
|
407
395
|
username,
|
|
408
396
|
};
|
|
397
|
+
if (typeof deps.persistCredential !== "function") throw recoveryRequired("MONGO_CREDENTIAL_PERSISTENCE_REQUIRED");
|
|
398
|
+
// Validate URI construction before any provider mutation.
|
|
399
|
+
buildRuntimeMongoUri({ adminUri, username, password: "preflight", dbName, authSource: "admin" });
|
|
409
400
|
await ensureDigitalOceanDatabase(apiOptions, deps);
|
|
410
401
|
const user = await provisionDigitalOceanUser(apiOptions, deps);
|
|
411
402
|
const mongoUri = buildRuntimeMongoUri({
|
|
412
403
|
adminUri,
|
|
413
|
-
username
|
|
404
|
+
username,
|
|
414
405
|
password: user.password,
|
|
415
406
|
dbName,
|
|
416
407
|
authSource: "admin",
|
|
417
408
|
});
|
|
418
409
|
|
|
410
|
+
await deps.persistCredential(mongoUri);
|
|
419
411
|
await waitForMongoUri(mongoUri, {
|
|
420
412
|
validate: deps.validateMongoUri || validateMongoUri,
|
|
421
413
|
attempts: deps.validationAttempts,
|
|
@@ -424,7 +416,10 @@ async function provisionDigitalOceanMongo(
|
|
|
424
416
|
|
|
425
417
|
return {
|
|
426
418
|
dbName,
|
|
427
|
-
username
|
|
419
|
+
username,
|
|
420
|
+
...(identity.mode === "canonical"
|
|
421
|
+
? { instanceUid: identity.instanceUid, technicalIdentityVersion: identity.identityVersion }
|
|
422
|
+
: {}),
|
|
428
423
|
mongoUri,
|
|
429
424
|
provider: "digitalocean",
|
|
430
425
|
};
|
|
@@ -435,7 +430,7 @@ async function provisionMongo(options, deps = {}) {
|
|
|
435
430
|
if (provider === "digitalocean") {
|
|
436
431
|
return provisionDigitalOceanMongo(options, deps);
|
|
437
432
|
}
|
|
438
|
-
const result = await provisionNativeMongo(options);
|
|
433
|
+
const result = await provisionNativeMongo(options, deps);
|
|
439
434
|
await waitForMongoUri(result.mongoUri, {
|
|
440
435
|
validate: deps.validateMongoUri || validateMongoUri,
|
|
441
436
|
attempts: deps.validationAttempts,
|
|
@@ -445,10 +440,10 @@ async function provisionMongo(options, deps = {}) {
|
|
|
445
440
|
}
|
|
446
441
|
|
|
447
442
|
module.exports = {
|
|
443
|
+
recoveryRequired, parseMongoUri, assertMongoIdentity,
|
|
448
444
|
DIGITALOCEAN_API,
|
|
449
445
|
DIGITALOCEAN_HOST_SUFFIX,
|
|
450
446
|
DIGITALOCEAN_RETRYABLE_STATUS,
|
|
451
|
-
DIGITALOCEAN_SERVER_STATUS,
|
|
452
447
|
mongoIdentity,
|
|
453
448
|
mongoHost,
|
|
454
449
|
resolveMongoProvisioner,
|
package/src/delivery/runtime.js
CHANGED
|
@@ -4,7 +4,9 @@ const crypto = require("node:crypto");
|
|
|
4
4
|
const { spawnSync } = require("node:child_process");
|
|
5
5
|
const { kubectl } = require("./command");
|
|
6
6
|
const { validateInfraFromCluster } = require("../deploy/contracts/infra-contract");
|
|
7
|
-
const {
|
|
7
|
+
const { resolveMongoCredential } = require("./mongoCredentialLifecycle");
|
|
8
|
+
const { provisionMongo, validateMongoUri, mongoIdentity, recoveryRequired } = require("./mongoProvisioning");
|
|
9
|
+
const { canonicalizeDeliveryContext } = require("./technicalIdentity");
|
|
8
10
|
|
|
9
11
|
const RESOURCE_PROFILES = {
|
|
10
12
|
nano: { cpuRequest: "50m", cpuLimit: "250m", memoryRequest: "96Mi", memoryLimit: "256Mi" },
|
|
@@ -31,29 +33,45 @@ function applyYaml(yaml) {
|
|
|
31
33
|
}
|
|
32
34
|
function readSecret(namespace, name, key) {
|
|
33
35
|
try {
|
|
34
|
-
const value = kubectl(["-n", namespace, "get", "secret", name, "-o", `jsonpath={.data.${key}}`], { capture: true });
|
|
36
|
+
const value = kubectl(["-n", namespace, "get", "secret", name, "--ignore-not-found", "-o", `jsonpath={.data.${key}}`], { capture: true });
|
|
35
37
|
return value ? Buffer.from(value, "base64").toString("utf8") : null;
|
|
36
|
-
} catch {
|
|
38
|
+
} catch { throw recoveryRequired("MONGO_SECRET_READ_FAILED"); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function canonicalRuntimeContext(context) {
|
|
42
|
+
return canonicalizeDeliveryContext(context);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function identityLabels(identity) {
|
|
46
|
+
if (identity.mode !== "canonical") return "";
|
|
47
|
+
return `\n oon.runtime/instance-uid: ${identity.instanceUid}\n oon.runtime/identity-version: \"${identity.version}\"`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function podIdentityLabels(identity) {
|
|
51
|
+
if (identity.mode !== "canonical") return "";
|
|
52
|
+
return `\n oon.runtime/instance-uid: ${identity.instanceUid}\n oon.runtime/identity-version: \"${identity.version}\"`;
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
function baselineManifest({ context }) {
|
|
56
|
+
const normalized = canonicalRuntimeContext(context);
|
|
57
|
+
const identity = normalized.deliveryTechnicalIdentity;
|
|
40
58
|
return `apiVersion: v1
|
|
41
59
|
kind: Namespace
|
|
42
60
|
metadata:
|
|
43
|
-
name: ${
|
|
61
|
+
name: ${normalized.namespace}
|
|
44
62
|
labels:
|
|
45
|
-
oon.dev/app-code: ${
|
|
46
|
-
oon.dev/environment: ${
|
|
47
|
-
app.kubernetes.io/managed-by: oon-platform
|
|
63
|
+
oon.dev/app-code: ${normalized.appCode}
|
|
64
|
+
oon.dev/environment: ${normalized.environment}
|
|
65
|
+
app.kubernetes.io/managed-by: oon-platform${identityLabels(identity)}
|
|
48
66
|
---
|
|
49
67
|
apiVersion: v1
|
|
50
68
|
kind: ServiceAccount
|
|
51
|
-
metadata: { name:
|
|
69
|
+
metadata: { name: ${identity.serviceAccount}, namespace: ${normalized.namespace} }
|
|
52
70
|
automountServiceAccountToken: false
|
|
53
71
|
---
|
|
54
72
|
apiVersion: v1
|
|
55
73
|
kind: LimitRange
|
|
56
|
-
metadata: { name: oon-runtime-defaults, namespace: ${
|
|
74
|
+
metadata: { name: oon-runtime-defaults, namespace: ${normalized.namespace} }
|
|
57
75
|
spec:
|
|
58
76
|
limits:
|
|
59
77
|
- type: Container
|
|
@@ -62,13 +80,13 @@ spec:
|
|
|
62
80
|
---
|
|
63
81
|
apiVersion: v1
|
|
64
82
|
kind: ResourceQuota
|
|
65
|
-
metadata: { name: oon-runtime-quota, namespace: ${
|
|
83
|
+
metadata: { name: oon-runtime-quota, namespace: ${normalized.namespace} }
|
|
66
84
|
spec:
|
|
67
85
|
hard: { requests.cpu: "2", requests.memory: 2Gi, limits.cpu: "4", limits.memory: 4Gi, pods: "20", services: "10" }
|
|
68
86
|
---
|
|
69
87
|
apiVersion: networking.k8s.io/v1
|
|
70
88
|
kind: NetworkPolicy
|
|
71
|
-
metadata: { name: oon-runtime-baseline, namespace: ${
|
|
89
|
+
metadata: { name: oon-runtime-baseline, namespace: ${normalized.namespace} }
|
|
72
90
|
spec:
|
|
73
91
|
podSelector: {}
|
|
74
92
|
policyTypes: [Ingress, Egress]
|
|
@@ -82,35 +100,37 @@ spec:
|
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
function runtimeManifest({ context, infra, imageRef, profile = "small" }) {
|
|
103
|
+
const normalized = canonicalRuntimeContext(context);
|
|
104
|
+
const identity = normalized.deliveryTechnicalIdentity;
|
|
85
105
|
const resources = RESOURCE_PROFILES[profile] || RESOURCE_PROFILES.small;
|
|
86
|
-
const releaseLabel = kubernetesLabelValue(
|
|
87
|
-
const releaseAnnotation = JSON.stringify(String(
|
|
106
|
+
const releaseLabel = kubernetesLabelValue(normalized.releaseId);
|
|
107
|
+
const releaseAnnotation = JSON.stringify(String(normalized.releaseId));
|
|
88
108
|
return `apiVersion: apps/v1
|
|
89
109
|
kind: Deployment
|
|
90
110
|
metadata:
|
|
91
|
-
name: ${
|
|
92
|
-
namespace: ${
|
|
111
|
+
name: ${normalized.appCode}
|
|
112
|
+
namespace: ${normalized.namespace}
|
|
93
113
|
spec:
|
|
94
114
|
replicas: 1
|
|
95
115
|
selector:
|
|
96
|
-
matchLabels: { app.kubernetes.io/name: ${
|
|
116
|
+
matchLabels: { app.kubernetes.io/name: ${normalized.appCode} }
|
|
97
117
|
template:
|
|
98
118
|
metadata:
|
|
99
119
|
labels:
|
|
100
|
-
app.kubernetes.io/name: ${
|
|
120
|
+
app.kubernetes.io/name: ${normalized.appCode}
|
|
101
121
|
app.kubernetes.io/part-of: oon-central
|
|
102
|
-
oon.dev/release-id: ${releaseLabel}
|
|
122
|
+
oon.dev/release-id: ${releaseLabel}${podIdentityLabels(identity)}
|
|
103
123
|
annotations:
|
|
104
124
|
oon.dev/release-id: ${releaseAnnotation}
|
|
105
125
|
spec:
|
|
106
|
-
serviceAccountName:
|
|
126
|
+
serviceAccountName: ${identity.serviceAccount}
|
|
107
127
|
imagePullSecrets: [{ name: ${infra.REGISTRY_PULL_SECRET_NAME || "ghcr-oon"} }]
|
|
108
128
|
containers:
|
|
109
129
|
- name: central
|
|
110
130
|
image: ${imageRef}
|
|
111
131
|
imagePullPolicy: IfNotPresent
|
|
112
132
|
ports: [{ name: http, containerPort: 8080 }]
|
|
113
|
-
envFrom: [{ secretRef: { name:
|
|
133
|
+
envFrom: [{ secretRef: { name: ${identity.secret} } }]
|
|
114
134
|
resources:
|
|
115
135
|
requests: { cpu: ${resources.cpuRequest}, memory: ${resources.memoryRequest} }
|
|
116
136
|
limits: { cpu: ${resources.cpuLimit}, memory: ${resources.memoryLimit} }
|
|
@@ -123,28 +143,28 @@ spec:
|
|
|
123
143
|
---
|
|
124
144
|
apiVersion: v1
|
|
125
145
|
kind: Service
|
|
126
|
-
metadata: { name: ${
|
|
146
|
+
metadata: { name: ${normalized.appCode}, namespace: ${normalized.namespace} }
|
|
127
147
|
spec:
|
|
128
|
-
selector: { app.kubernetes.io/name: ${
|
|
148
|
+
selector: { app.kubernetes.io/name: ${normalized.appCode} }
|
|
129
149
|
ports: [{ name: http, port: 80, targetPort: http }]
|
|
130
150
|
---
|
|
131
151
|
apiVersion: networking.k8s.io/v1
|
|
132
152
|
kind: Ingress
|
|
133
153
|
metadata:
|
|
134
|
-
name: ${
|
|
135
|
-
namespace: ${
|
|
154
|
+
name: ${normalized.appCode}
|
|
155
|
+
namespace: ${normalized.namespace}
|
|
136
156
|
annotations: { cert-manager.io/cluster-issuer: ${infra.CLUSTER_ISSUER} }
|
|
137
157
|
spec:
|
|
138
158
|
ingressClassName: ${infra.INGRESS_CLASS}
|
|
139
|
-
tls: [{ hosts: [${
|
|
159
|
+
tls: [{ hosts: [${normalized.hostname}], secretName: ${identity.tlsSecret} }]
|
|
140
160
|
rules:
|
|
141
|
-
- host: ${
|
|
161
|
+
- host: ${normalized.hostname}
|
|
142
162
|
http:
|
|
143
163
|
paths:
|
|
144
164
|
- path: /
|
|
145
165
|
pathType: Prefix
|
|
146
166
|
backend:
|
|
147
|
-
service: { name: ${
|
|
167
|
+
service: { name: ${normalized.appCode}, port: { name: http } }
|
|
148
168
|
`;
|
|
149
169
|
}
|
|
150
170
|
|
|
@@ -158,27 +178,41 @@ function copyPullSecret(namespace, infra) {
|
|
|
158
178
|
} catch (error) { if (!/not found/i.test(error.message)) throw error; }
|
|
159
179
|
}
|
|
160
180
|
|
|
161
|
-
function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {} }) {
|
|
181
|
+
function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {}, record, writeRecord }) {
|
|
182
|
+
const normalized = canonicalRuntimeContext(context);
|
|
183
|
+
const identity = normalized.deliveryTechnicalIdentity;
|
|
162
184
|
const literals = {
|
|
185
|
+
...extraSecrets,
|
|
163
186
|
MONGO_URI: mongoUri,
|
|
164
187
|
NODE_ENV: "production",
|
|
165
|
-
APP_ENVIRONMENT:
|
|
166
|
-
APP_CODE:
|
|
167
|
-
PUBLIC_APP_URL:
|
|
168
|
-
CENTRAL_ATIVACAO_URL:
|
|
169
|
-
CENTRAL_ATIVACAO_API_URL:
|
|
188
|
+
APP_ENVIRONMENT: normalized.environment,
|
|
189
|
+
APP_CODE: normalized.appCode,
|
|
190
|
+
PUBLIC_APP_URL: normalized.publicUrl,
|
|
191
|
+
CENTRAL_ATIVACAO_URL: normalized.activationApiUrl,
|
|
192
|
+
CENTRAL_ATIVACAO_API_URL: normalized.activationApiUrl,
|
|
170
193
|
INSTANCE_CREDENTIAL_ENCRYPTION_KEY: encryptionKey,
|
|
171
|
-
...
|
|
194
|
+
...(identity.mode === "canonical"
|
|
195
|
+
? {
|
|
196
|
+
OON_INSTANCE_UID: identity.instanceUid,
|
|
197
|
+
OON_TECHNICAL_IDENTITY_VERSION: String(identity.version),
|
|
198
|
+
}
|
|
199
|
+
: {}),
|
|
172
200
|
};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
201
|
+
writeRecord("replace", {
|
|
202
|
+
...record,
|
|
203
|
+
metadata: { ...record.metadata, annotations: {
|
|
204
|
+
...record.metadata.annotations, "oon.runtime/mongo-state": "ready",
|
|
205
|
+
} },
|
|
206
|
+
data: { ...record.data, ...Object.fromEntries(Object.entries(literals)
|
|
207
|
+
.map(([key, value]) => [key, Buffer.from(String(value)).toString("base64")])) },
|
|
208
|
+
});
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
async function provisionRuntime({ context, extraSecrets = {} }) {
|
|
212
|
+
const normalized = canonicalRuntimeContext(context);
|
|
213
|
+
const identity = normalized.deliveryTechnicalIdentity;
|
|
180
214
|
const infra = validateInfraFromCluster().config;
|
|
181
|
-
applyYaml(baselineManifest({ context, infra }));
|
|
215
|
+
applyYaml(baselineManifest({ context: normalized, infra }));
|
|
182
216
|
|
|
183
217
|
const platformNamespace = infra.PLATFORM_NAMESPACE || "oon-system";
|
|
184
218
|
const adminSecretName = infra.MONGO_ADMIN_SECRET_NAME || "oon-mongo-admin";
|
|
@@ -205,53 +239,77 @@ async function provisionRuntime({ context, extraSecrets = {} }) {
|
|
|
205
239
|
"DIGITALOCEAN_DATABASE_CLUSTER_ID",
|
|
206
240
|
);
|
|
207
241
|
|
|
208
|
-
|
|
209
|
-
let
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (!mongoUri) {
|
|
226
|
-
const provisioned = await provisionMongo({
|
|
227
|
-
adminUri,
|
|
228
|
-
appCode: context.appCode,
|
|
229
|
-
environment: context.environment,
|
|
230
|
-
provisioner: mongoProvisioner,
|
|
231
|
-
digitalOceanToken,
|
|
232
|
-
digitalOceanClusterId,
|
|
242
|
+
// Read errors are distinct from confirmed absence. Never provision on a failed GET.
|
|
243
|
+
let record;
|
|
244
|
+
try {
|
|
245
|
+
const raw = kubectl(["-n", normalized.namespace, "get", "secret", identity.secret,
|
|
246
|
+
"--ignore-not-found", "-o", "json"], { capture: true });
|
|
247
|
+
record = raw ? JSON.parse(raw) : null;
|
|
248
|
+
} catch { throw recoveryRequired("MONGO_SECRET_READ_FAILED"); }
|
|
249
|
+
const decode = (key) => record?.data?.[key]
|
|
250
|
+
? Buffer.from(record.data[key], "base64").toString("utf8") : null;
|
|
251
|
+
const encryptionKey = record === null ? randomSecret(48) : decode("INSTANCE_CREDENTIAL_ENCRYPTION_KEY");
|
|
252
|
+
if (!encryptionKey) throw recoveryRequired("MONGO_ENCRYPTION_KEY_MISSING");
|
|
253
|
+
const writeRecord = (operation, value) => {
|
|
254
|
+
// Secrets travel through stdin and captured output, never inherited stderr/argv.
|
|
255
|
+
const result = spawnSync("kubectl", [operation, "-f", "-", "-o", "json"], {
|
|
256
|
+
input: JSON.stringify(value), encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
|
|
233
257
|
});
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
258
|
+
if (result.status !== 0) throw recoveryRequired("MONGO_SECRET_WRITE_FAILED");
|
|
259
|
+
try { record = JSON.parse(result.stdout); }
|
|
260
|
+
catch { throw recoveryRequired("MONGO_SECRET_RESPONSE_INVALID"); }
|
|
261
|
+
if (!record?.metadata?.resourceVersion) throw recoveryRequired("MONGO_SECRET_RESPONSE_INVALID");
|
|
262
|
+
};
|
|
263
|
+
const mongo = await resolveMongoCredential({
|
|
264
|
+
identity: mongoIdentity(normalized.appCode, normalized.environment,
|
|
265
|
+
identity.mode === "canonical" ? { instanceUid: identity.instanceUid } : {}),
|
|
266
|
+
read: async () => record === null ? null : { mongoUri: decode("MONGO_URI") },
|
|
267
|
+
begin: async () => writeRecord("create", {
|
|
268
|
+
apiVersion: "v1", kind: "Secret", type: "Opaque",
|
|
269
|
+
metadata: { name: identity.secret, namespace: normalized.namespace,
|
|
270
|
+
annotations: { "oon.runtime/mongo-state": "provisioning" } },
|
|
271
|
+
data: { INSTANCE_CREDENTIAL_ENCRYPTION_KEY: Buffer.from(encryptionKey).toString("base64") },
|
|
272
|
+
}),
|
|
273
|
+
persist: async (uri) => writeRecord("replace", {
|
|
274
|
+
...record,
|
|
275
|
+
metadata: { ...record.metadata, annotations: {
|
|
276
|
+
...record.metadata.annotations, "oon.runtime/mongo-state": "credential-captured",
|
|
277
|
+
} },
|
|
278
|
+
data: { ...record.data, MONGO_URI: Buffer.from(uri).toString("base64") },
|
|
279
|
+
}),
|
|
280
|
+
provision: (persistCredential) => provisionMongo({
|
|
281
|
+
adminUri, appCode: normalized.appCode, environment: normalized.environment,
|
|
282
|
+
...(identity.mode === "canonical" ? { instanceUid: identity.instanceUid } : {}),
|
|
283
|
+
provisioner: mongoProvisioner, digitalOceanToken, digitalOceanClusterId,
|
|
284
|
+
}, { persistCredential }),
|
|
285
|
+
validate: validateMongoUri,
|
|
286
|
+
});
|
|
287
|
+
const mongoUri = mongo.mongoUri;
|
|
288
|
+
const databaseName = mongoIdentity(normalized.appCode, normalized.environment,
|
|
289
|
+
identity.mode === "canonical" ? { instanceUid: identity.instanceUid } : {}).dbName;
|
|
290
|
+
const credentialRotated = false;
|
|
291
|
+
applyRuntimeSecret({ context: normalized, mongoUri, encryptionKey, extraSecrets, record, writeRecord });
|
|
292
|
+
return {
|
|
293
|
+
infra,
|
|
294
|
+
databaseName,
|
|
295
|
+
credentialRotated,
|
|
296
|
+
...(identity.mode === "canonical" ? { technicalIdentity: identity } : {}),
|
|
297
|
+
};
|
|
247
298
|
}
|
|
248
299
|
|
|
249
300
|
function deployRuntime({ context, infra, imageDigest, resourceProfile = "small" }) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
301
|
+
const normalized = canonicalRuntimeContext(context);
|
|
302
|
+
copyPullSecret(normalized.namespace, infra);
|
|
303
|
+
const imageRef = `${normalized.imageRepository}@${imageDigest}`;
|
|
304
|
+
applyYaml(runtimeManifest({ context: normalized, infra, imageRef, profile: resourceProfile }));
|
|
305
|
+
kubectl(["-n", normalized.namespace, "rollout", "status", `deployment/${normalized.appCode}`, "--timeout=10m"]);
|
|
306
|
+
return {
|
|
307
|
+
infraVersion: infra.INFRA_SCHEMA_VERSION,
|
|
308
|
+
imageRef,
|
|
309
|
+
...(normalized.deliveryTechnicalIdentity.mode === "canonical"
|
|
310
|
+
? { technicalIdentity: normalized.deliveryTechnicalIdentity }
|
|
311
|
+
: {}),
|
|
312
|
+
};
|
|
255
313
|
}
|
|
256
314
|
|
|
257
315
|
async function waitHttp(url, { timeoutMs = 300000, expectedStatus = 200 } = {}) {
|
|
@@ -269,19 +327,20 @@ async function waitHttp(url, { timeoutMs = 300000, expectedStatus = 200 } = {})
|
|
|
269
327
|
}
|
|
270
328
|
|
|
271
329
|
async function activateMember(context) {
|
|
272
|
-
const
|
|
330
|
+
const normalized = canonicalRuntimeContext(context);
|
|
331
|
+
const statusResponse = await waitHttp(`${normalized.publicUrl}/api/ativacao/status`);
|
|
273
332
|
const status = await statusResponse.json();
|
|
274
333
|
if (status.status === "ativa") return { alreadyActive: true, instanceId: status.instanceId };
|
|
275
|
-
if (!
|
|
276
|
-
const response = await fetch(`${
|
|
334
|
+
if (!normalized.activationCode) throw new Error("Instância não está ativa e não há código de ativação autorizado.");
|
|
335
|
+
const response = await fetch(`${normalized.publicUrl}/api/ativacao/concluir`, {
|
|
277
336
|
method: "POST",
|
|
278
|
-
headers: { "Content-Type": "application/json", "Idempotency-Key":
|
|
337
|
+
headers: { "Content-Type": "application/json", "Idempotency-Key": normalized.releaseId },
|
|
279
338
|
body: JSON.stringify({
|
|
280
|
-
codigoAtivacao:
|
|
281
|
-
nomeInstancia: `${
|
|
339
|
+
codigoAtivacao: normalized.activationCode,
|
|
340
|
+
nomeInstancia: `${normalized.appCode} dev`,
|
|
282
341
|
parametrosGlobais: {
|
|
283
|
-
nomeInstancia: `${
|
|
284
|
-
urlPublica:
|
|
342
|
+
nomeInstancia: `${normalized.appCode} dev`, ambiente: normalized.environment,
|
|
343
|
+
urlPublica: normalized.publicUrl, emailAdministrativo: normalized.administrativeEmail,
|
|
285
344
|
idioma: "pt-BR", timezone: "America/Sao_Paulo", moeda: "BRL", pais: "BR", formatoData: "DD/MM/YYYY",
|
|
286
345
|
},
|
|
287
346
|
parametrosAplicacao: {},
|
|
@@ -292,4 +351,18 @@ async function activateMember(context) {
|
|
|
292
351
|
return data;
|
|
293
352
|
}
|
|
294
353
|
|
|
295
|
-
module.exports = {
|
|
354
|
+
module.exports = {
|
|
355
|
+
RESOURCE_PROFILES,
|
|
356
|
+
randomSecret,
|
|
357
|
+
dnsLabel,
|
|
358
|
+
kubernetesLabelValue,
|
|
359
|
+
canonicalRuntimeContext,
|
|
360
|
+
provisionMongo,
|
|
361
|
+
validateMongoUri,
|
|
362
|
+
baselineManifest,
|
|
363
|
+
runtimeManifest,
|
|
364
|
+
provisionRuntime,
|
|
365
|
+
deployRuntime,
|
|
366
|
+
waitHttp,
|
|
367
|
+
activateMember,
|
|
368
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
});
|