@oondemand/oon-core-back 0.6.9 → 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/package.json
CHANGED
|
@@ -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,12 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const defaultMongoClient = () => require("mongoose").mongo.MongoClient;
|
|
4
4
|
const { technicalIdentity } = require("../core/technicalIdentity");
|
|
5
5
|
|
|
6
6
|
const DIGITALOCEAN_API = "https://api.digitalocean.com/v2";
|
|
7
7
|
const DIGITALOCEAN_HOST_SUFFIX = ".mongo.ondigitalocean.com";
|
|
8
8
|
const DIGITALOCEAN_RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
9
|
-
const DIGITALOCEAN_SERVER_STATUS = new Set([500, 502, 503, 504]);
|
|
10
9
|
|
|
11
10
|
function delay(ms) {
|
|
12
11
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -43,46 +42,58 @@ function mongoIdentity(appCode, environment, { instanceUid } = {}) {
|
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
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");
|
|
47
60
|
try {
|
|
48
|
-
return
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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();
|
|
52
69
|
}
|
|
53
70
|
|
|
54
71
|
function resolveMongoProvisioner({ adminUri, provisioner = "auto" }) {
|
|
55
72
|
const normalized = String(provisioner || "auto").trim().toLowerCase();
|
|
56
73
|
if (!["auto", "native", "digitalocean"].includes(normalized)) {
|
|
57
|
-
throw
|
|
58
|
-
`MONGO_PROVISIONER inválido: ${normalized}. Use auto, native ou digitalocean.`,
|
|
59
|
-
);
|
|
74
|
+
throw recoveryRequired("MONGO_PROVISIONER_INVALID");
|
|
60
75
|
}
|
|
61
76
|
if (normalized !== "auto") return normalized;
|
|
62
|
-
return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX)
|
|
63
|
-
? "digitalocean"
|
|
64
|
-
: "native";
|
|
77
|
+
return mongoHost(adminUri).endsWith(DIGITALOCEAN_HOST_SUFFIX) ? "digitalocean" : "native";
|
|
65
78
|
}
|
|
66
79
|
|
|
67
|
-
function buildRuntimeMongoUri({
|
|
68
|
-
adminUri
|
|
69
|
-
|
|
70
|
-
password
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
uri.searchParams.set("authSource", authSource);
|
|
79
|
-
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
|
+
}
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
async function validateMongoUri(
|
|
83
94
|
mongoUri,
|
|
84
95
|
{
|
|
85
|
-
MongoClient =
|
|
96
|
+
MongoClient = defaultMongoClient(),
|
|
86
97
|
timeoutMs = 15000,
|
|
87
98
|
writeProbe = true,
|
|
88
99
|
} = {},
|
|
@@ -93,9 +104,16 @@ async function validateMongoUri(
|
|
|
93
104
|
maxPoolSize: 2,
|
|
94
105
|
});
|
|
95
106
|
|
|
96
|
-
await client.connect();
|
|
97
107
|
try {
|
|
108
|
+
await client.connect();
|
|
98
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
|
+
}
|
|
99
117
|
await db.command({ ping: 1 });
|
|
100
118
|
if (writeProbe) {
|
|
101
119
|
const probe = db.collection("__oon_runtime_probe");
|
|
@@ -133,7 +151,7 @@ async function waitForMongoUri(
|
|
|
133
151
|
}
|
|
134
152
|
}
|
|
135
153
|
}
|
|
136
|
-
const reason = lastError?.
|
|
154
|
+
const reason = [13, 18].includes(lastError?.code) ? lastError.code : undefined;
|
|
137
155
|
throw new Error(
|
|
138
156
|
`A credencial derivada do MongoDB não passou no teste de leitura e escrita${
|
|
139
157
|
reason ? ` (${reason})` : ""
|
|
@@ -141,16 +159,23 @@ async function waitForMongoUri(
|
|
|
141
159
|
);
|
|
142
160
|
}
|
|
143
161
|
|
|
144
|
-
async function provisionNativeMongo({ adminUri, appCode, environment, instanceUid }) {
|
|
162
|
+
async function provisionNativeMongo({ adminUri, appCode, environment, instanceUid }, deps = {}) {
|
|
145
163
|
const identity = mongoIdentity(appCode, environment, { instanceUid });
|
|
146
164
|
const { dbName, username } = identity;
|
|
147
165
|
const password = randomSecret(30);
|
|
148
|
-
const
|
|
149
|
-
|
|
166
|
+
const MongoClient = deps.MongoClient || defaultMongoClient();
|
|
167
|
+
const client = new MongoClient(adminUri, {
|
|
168
|
+
serverSelectionTimeoutMS: 15000, connectTimeoutMS: 15000, maxPoolSize: 2,
|
|
150
169
|
});
|
|
151
|
-
await client.connect();
|
|
152
170
|
try {
|
|
171
|
+
await client.connect();
|
|
153
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);
|
|
154
179
|
try {
|
|
155
180
|
await db.command({
|
|
156
181
|
createUser: username,
|
|
@@ -163,11 +188,7 @@ async function provisionNativeMongo({ adminUri, appCode, environment, instanceUi
|
|
|
163
188
|
error.code === 51003 ||
|
|
164
189
|
/already exists/i.test(error.message);
|
|
165
190
|
if (!alreadyExists) throw error;
|
|
166
|
-
|
|
167
|
-
updateUser: username,
|
|
168
|
-
pwd: password,
|
|
169
|
-
roles: [{ role: "readWrite", db: dbName }],
|
|
170
|
-
});
|
|
191
|
+
throw recoveryRequired();
|
|
171
192
|
}
|
|
172
193
|
} finally {
|
|
173
194
|
await client.close();
|
|
@@ -207,7 +228,7 @@ async function digitalOceanRequest(
|
|
|
207
228
|
} = {},
|
|
208
229
|
) {
|
|
209
230
|
if (!fetchImpl) throw new Error("fetch não disponível para a DigitalOcean API.");
|
|
210
|
-
const attempts = Math.max(1, Number(retryAttempts) || 3);
|
|
231
|
+
const attempts = method === "GET" ? Math.max(1, Number(retryAttempts) || 3) : 1;
|
|
211
232
|
|
|
212
233
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
213
234
|
const controller = new AbortController();
|
|
@@ -225,6 +246,7 @@ async function digitalOceanRequest(
|
|
|
225
246
|
},
|
|
226
247
|
body: body ? JSON.stringify(body) : undefined,
|
|
227
248
|
signal: controller.signal,
|
|
249
|
+
redirect: "error",
|
|
228
250
|
},
|
|
229
251
|
);
|
|
230
252
|
} catch (error) {
|
|
@@ -235,7 +257,7 @@ async function digitalOceanRequest(
|
|
|
235
257
|
if (error.name === "AbortError") {
|
|
236
258
|
throw new Error("Timeout ao acessar a DigitalOcean API.");
|
|
237
259
|
}
|
|
238
|
-
throw
|
|
260
|
+
throw recoveryRequired("MONGO_PROVIDER_REQUEST_FAILED");
|
|
239
261
|
} finally {
|
|
240
262
|
clearTimeout(timeout);
|
|
241
263
|
}
|
|
@@ -257,20 +279,8 @@ async function digitalOceanRequest(
|
|
|
257
279
|
continue;
|
|
258
280
|
}
|
|
259
281
|
|
|
260
|
-
const
|
|
261
|
-
.replace(/[\r\n]+/g, " ")
|
|
262
|
-
.slice(0, 300);
|
|
263
|
-
const providerRequestId = String(payload.request_id || "")
|
|
264
|
-
.replace(/[^A-Za-z0-9_.:-]/g, "")
|
|
265
|
-
.slice(0, 120);
|
|
266
|
-
const error = new Error(
|
|
267
|
-
`DigitalOcean API rejeitou ${method} ${path}: HTTP ${response.status} (${providerMessage})`
|
|
268
|
-
+ (providerRequestId ? ` [requestId: ${providerRequestId}]` : "")
|
|
269
|
-
+ ".",
|
|
270
|
-
);
|
|
282
|
+
const error = recoveryRequired(`MONGO_PROVIDER_HTTP_${response.status}`);
|
|
271
283
|
error.status = response.status;
|
|
272
|
-
error.payload = payload;
|
|
273
|
-
error.providerRequestId = providerRequestId || undefined;
|
|
274
284
|
throw error;
|
|
275
285
|
}
|
|
276
286
|
|
|
@@ -289,19 +299,14 @@ async function ensureDigitalOceanDatabase(options, deps) {
|
|
|
289
299
|
} catch (error) {
|
|
290
300
|
if (error.status === 404) {
|
|
291
301
|
exists = false;
|
|
292
|
-
} else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
|
|
293
|
-
const listed = await digitalOceanRequest(
|
|
294
|
-
{ ...options, path: "/dbs", expected: [200] },
|
|
295
|
-
deps,
|
|
296
|
-
);
|
|
297
|
-
exists = Array.isArray(listed.payload.dbs)
|
|
298
|
-
&& listed.payload.dbs.some((item) => item?.name === options.dbName);
|
|
299
302
|
} else {
|
|
300
303
|
throw error;
|
|
301
304
|
}
|
|
302
305
|
}
|
|
303
306
|
if (exists) return;
|
|
304
307
|
|
|
308
|
+
// Refuse a pre-existing/ambiguous user before creating any database resource.
|
|
309
|
+
await assertDigitalOceanUserAbsent(options, deps);
|
|
305
310
|
await digitalOceanRequest(
|
|
306
311
|
{
|
|
307
312
|
...options,
|
|
@@ -323,70 +328,35 @@ function digitalOceanUserSettings(dbName) {
|
|
|
323
328
|
};
|
|
324
329
|
}
|
|
325
330
|
|
|
326
|
-
async function
|
|
327
|
-
const userPath = `/users/${encodeURIComponent(options.username)}`;
|
|
328
|
-
let exists = false;
|
|
331
|
+
async function assertDigitalOceanUserAbsent(options, deps) {
|
|
329
332
|
try {
|
|
330
333
|
await digitalOceanRequest(
|
|
331
|
-
{ ...options, path:
|
|
334
|
+
{ ...options, path: `/users/${encodeURIComponent(options.username)}`, expected: [200] },
|
|
332
335
|
deps,
|
|
333
336
|
);
|
|
334
|
-
exists = true;
|
|
335
337
|
} catch (error) {
|
|
336
|
-
if (error.status === 404)
|
|
337
|
-
|
|
338
|
-
} else if (DIGITALOCEAN_SERVER_STATUS.has(error.status)) {
|
|
339
|
-
const listed = await digitalOceanRequest(
|
|
340
|
-
{ ...options, path: "/users", expected: [200] },
|
|
341
|
-
deps,
|
|
342
|
-
);
|
|
343
|
-
exists = Array.isArray(listed.payload.users)
|
|
344
|
-
&& listed.payload.users.some(
|
|
345
|
-
(item) => item?.name === options.username,
|
|
346
|
-
);
|
|
347
|
-
} else {
|
|
348
|
-
throw error;
|
|
349
|
-
}
|
|
338
|
+
if (error.status === 404) return;
|
|
339
|
+
throw error;
|
|
350
340
|
}
|
|
341
|
+
throw recoveryRequired();
|
|
342
|
+
}
|
|
351
343
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
} else {
|
|
366
|
-
await digitalOceanRequest(
|
|
367
|
-
{
|
|
368
|
-
...options,
|
|
369
|
-
path: userPath,
|
|
370
|
-
method: "PUT",
|
|
371
|
-
body: { settings },
|
|
372
|
-
expected: [200, 202],
|
|
373
|
-
},
|
|
374
|
-
deps,
|
|
375
|
-
);
|
|
376
|
-
result = await digitalOceanRequest(
|
|
377
|
-
{
|
|
378
|
-
...options,
|
|
379
|
-
path: `${userPath}/reset_auth`,
|
|
380
|
-
method: "POST",
|
|
381
|
-
body: {},
|
|
382
|
-
expected: [200, 201, 202],
|
|
383
|
-
},
|
|
384
|
-
deps,
|
|
385
|
-
);
|
|
386
|
-
}
|
|
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
|
+
);
|
|
387
357
|
|
|
388
358
|
const user = result.payload.user;
|
|
389
|
-
if (
|
|
359
|
+
if (user?.name !== options.username || typeof user?.password !== "string" || !user.password) {
|
|
390
360
|
throw new Error(
|
|
391
361
|
"DigitalOcean API não retornou a senha do usuário. O token precisa do escopo database:view_credentials.",
|
|
392
362
|
);
|
|
@@ -424,16 +394,20 @@ async function provisionDigitalOceanMongo(
|
|
|
424
394
|
dbName,
|
|
425
395
|
username,
|
|
426
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" });
|
|
427
400
|
await ensureDigitalOceanDatabase(apiOptions, deps);
|
|
428
401
|
const user = await provisionDigitalOceanUser(apiOptions, deps);
|
|
429
402
|
const mongoUri = buildRuntimeMongoUri({
|
|
430
403
|
adminUri,
|
|
431
|
-
username
|
|
404
|
+
username,
|
|
432
405
|
password: user.password,
|
|
433
406
|
dbName,
|
|
434
407
|
authSource: "admin",
|
|
435
408
|
});
|
|
436
409
|
|
|
410
|
+
await deps.persistCredential(mongoUri);
|
|
437
411
|
await waitForMongoUri(mongoUri, {
|
|
438
412
|
validate: deps.validateMongoUri || validateMongoUri,
|
|
439
413
|
attempts: deps.validationAttempts,
|
|
@@ -442,7 +416,7 @@ async function provisionDigitalOceanMongo(
|
|
|
442
416
|
|
|
443
417
|
return {
|
|
444
418
|
dbName,
|
|
445
|
-
username
|
|
419
|
+
username,
|
|
446
420
|
...(identity.mode === "canonical"
|
|
447
421
|
? { instanceUid: identity.instanceUid, technicalIdentityVersion: identity.identityVersion }
|
|
448
422
|
: {}),
|
|
@@ -456,7 +430,7 @@ async function provisionMongo(options, deps = {}) {
|
|
|
456
430
|
if (provider === "digitalocean") {
|
|
457
431
|
return provisionDigitalOceanMongo(options, deps);
|
|
458
432
|
}
|
|
459
|
-
const result = await provisionNativeMongo(options);
|
|
433
|
+
const result = await provisionNativeMongo(options, deps);
|
|
460
434
|
await waitForMongoUri(result.mongoUri, {
|
|
461
435
|
validate: deps.validateMongoUri || validateMongoUri,
|
|
462
436
|
attempts: deps.validationAttempts,
|
|
@@ -466,10 +440,10 @@ async function provisionMongo(options, deps = {}) {
|
|
|
466
440
|
}
|
|
467
441
|
|
|
468
442
|
module.exports = {
|
|
443
|
+
recoveryRequired, parseMongoUri, assertMongoIdentity,
|
|
469
444
|
DIGITALOCEAN_API,
|
|
470
445
|
DIGITALOCEAN_HOST_SUFFIX,
|
|
471
446
|
DIGITALOCEAN_RETRYABLE_STATUS,
|
|
472
|
-
DIGITALOCEAN_SERVER_STATUS,
|
|
473
447
|
mongoIdentity,
|
|
474
448
|
mongoHost,
|
|
475
449
|
resolveMongoProvisioner,
|
|
@@ -483,4 +457,4 @@ module.exports = {
|
|
|
483
457
|
provisionDigitalOceanUser,
|
|
484
458
|
provisionDigitalOceanMongo,
|
|
485
459
|
provisionMongo,
|
|
486
|
-
};
|
|
460
|
+
};
|
package/src/delivery/runtime.js
CHANGED
|
@@ -4,7 +4,8 @@ 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");
|
|
8
9
|
const { canonicalizeDeliveryContext } = require("./technicalIdentity");
|
|
9
10
|
|
|
10
11
|
const RESOURCE_PROFILES = {
|
|
@@ -32,9 +33,9 @@ function applyYaml(yaml) {
|
|
|
32
33
|
}
|
|
33
34
|
function readSecret(namespace, name, key) {
|
|
34
35
|
try {
|
|
35
|
-
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 });
|
|
36
37
|
return value ? Buffer.from(value, "base64").toString("utf8") : null;
|
|
37
|
-
} catch {
|
|
38
|
+
} catch { throw recoveryRequired("MONGO_SECRET_READ_FAILED"); }
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function canonicalRuntimeContext(context) {
|
|
@@ -177,10 +178,11 @@ function copyPullSecret(namespace, infra) {
|
|
|
177
178
|
} catch (error) { if (!/not found/i.test(error.message)) throw error; }
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {} }) {
|
|
181
|
+
function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {}, record, writeRecord }) {
|
|
181
182
|
const normalized = canonicalRuntimeContext(context);
|
|
182
183
|
const identity = normalized.deliveryTechnicalIdentity;
|
|
183
184
|
const literals = {
|
|
185
|
+
...extraSecrets,
|
|
184
186
|
MONGO_URI: mongoUri,
|
|
185
187
|
NODE_ENV: "production",
|
|
186
188
|
APP_ENVIRONMENT: normalized.environment,
|
|
@@ -195,12 +197,15 @@ function applyRuntimeSecret({ context, mongoUri, encryptionKey, extraSecrets = {
|
|
|
195
197
|
OON_TECHNICAL_IDENTITY_VERSION: String(identity.version),
|
|
196
198
|
}
|
|
197
199
|
: {}),
|
|
198
|
-
...extraSecrets,
|
|
199
200
|
};
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
});
|
|
204
209
|
}
|
|
205
210
|
|
|
206
211
|
async function provisionRuntime({ context, extraSecrets = {} }) {
|
|
@@ -234,45 +239,56 @@ async function provisionRuntime({ context, extraSecrets = {} }) {
|
|
|
234
239
|
"DIGITALOCEAN_DATABASE_CLUSTER_ID",
|
|
235
240
|
);
|
|
236
241
|
|
|
237
|
-
|
|
238
|
-
let
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (!mongoUri) {
|
|
255
|
-
const provisioned = await provisionMongo({
|
|
256
|
-
adminUri,
|
|
257
|
-
appCode: normalized.appCode,
|
|
258
|
-
environment: normalized.environment,
|
|
259
|
-
...(identity.mode === "canonical" ? { instanceUid: identity.instanceUid } : {}),
|
|
260
|
-
provisioner: mongoProvisioner,
|
|
261
|
-
digitalOceanToken,
|
|
262
|
-
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"],
|
|
263
257
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 });
|
|
276
292
|
return {
|
|
277
293
|
infra,
|
|
278
294
|
databaseName,
|
|
@@ -349,4 +365,4 @@ module.exports = {
|
|
|
349
365
|
deployRuntime,
|
|
350
366
|
waitHttp,
|
|
351
367
|
activateMember,
|
|
352
|
-
};
|
|
368
|
+
};
|