@bongos/core 1.19.1079 → 1.19.1081
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/.bongos-core.json +86 -31
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md +2 -0
- package/docs/adr/0347-every-store-module-ships-a-how-to.md +135 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +126 -3
- package/docs/api-reference.md +3 -2
- package/docs/architecture.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +7 -1
- package/modules/provisioning/migrations/provisioning_028_render_standup.sql +46 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +5 -5
- package/modules/provisioning/render-standup.js +91 -0
- package/modules/provisioning/routes/render-standup.js +90 -0
- package/package-lock.json +2 -2
- package/package.json +2 -2
- package/release-notes.json +16 -0
- package/scripts/gds/module.js +176 -46
- package/scripts/gds/provision-render.js +158 -0
- package/scripts/gds/provision-teardown.js +2 -0
- package/scripts/gds/provision.js +2 -2
- package/scripts/gds/render-api.js +178 -0
- package/scripts/gds/render-payload.js +110 -0
- package/src/module-api.js +1 -1
- package/tests/module_update.mjs +232 -0
- package/tests/provision_render.mjs +269 -0
- package/tests/provisioning_render_route.mjs +216 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/render_api.mjs +153 -0
package/scripts/gds/module.js
CHANGED
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
// bongos upgrade pre-check enabled modules vs the core version
|
|
62
62
|
// bongos module check <key> [--sign-off "Name <email>"] AGPL/publish pre-check + DCO sign-off
|
|
63
63
|
// bongos module install <key> [--version X.Y.Z] acquire from the store, verify, place (not enabled)
|
|
64
|
+
// bongos module update <key> [--version X.Y.Z] move an installed store module forward
|
|
64
65
|
|
|
65
66
|
const fs = require('node:fs');
|
|
66
67
|
const path = require('node:path');
|
|
@@ -925,30 +926,104 @@ function placeModule(files, target) {
|
|
|
925
926
|
}
|
|
926
927
|
}
|
|
927
928
|
|
|
929
|
+
// Every discovery root the loader scans (core + instance, ADR 0108 §1). A key on
|
|
930
|
+
// disk in ANY of them is already taken: the loader treats a repeated key as a hard
|
|
931
|
+
// error, so placing a second copy in the instance root would break boot.
|
|
932
|
+
function discoveryRoots() {
|
|
933
|
+
return require('../../src/module-loader/loader').DEFAULT_ROOTS;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Parse `<key> [--version X.Y.Z]` for install and update. Returns { key, wanted }
|
|
937
|
+
// or { code } (2 = usage) after printing why.
|
|
938
|
+
function parseStoreArgs(args, verb, errlog) {
|
|
939
|
+
const usage = `usage: bongos module ${verb} <key> [--version X.Y.Z]`;
|
|
940
|
+
const vIdx = args.indexOf('--version');
|
|
941
|
+
const wanted = vIdx !== -1 ? args[vIdx + 1] : null;
|
|
942
|
+
const key = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--version');
|
|
943
|
+
if (!key || (vIdx !== -1 && (!wanted || wanted.startsWith('--')))) { errlog(usage); return { code: 2 }; }
|
|
944
|
+
if (!KEY_RE.test(key)) { errlog(`REFUSING: "${key}" is not a module key (lowercase kebab-case).`); return { code: 2 }; }
|
|
945
|
+
const { parseVersion } = require('../../src/module-loader/semver');
|
|
946
|
+
if (wanted && !parseVersion(wanted)) { errlog(`REFUSING: --version must be exact X.Y.Z, not "${wanted}".`); return { code: 2 }; }
|
|
947
|
+
return { key, wanted };
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// The one acquisition path install AND update share (task 1003786): acquire, check
|
|
951
|
+
// the version can mount on this core, download, verify with verifyModuleArtifact and
|
|
952
|
+
// hold the bytes to what the store described. Nothing here reads a price or branches
|
|
953
|
+
// on how the module was obtained — the store decides entitlement, and a holder it has
|
|
954
|
+
// already granted (for any reason) is never asked again. tests/module_update.mjs
|
|
955
|
+
// fails if a price-conditional branch appears in this path.
|
|
956
|
+
//
|
|
957
|
+
// `shouldDownload(described)` lets update stop before the download when the store's
|
|
958
|
+
// version is not newer than what is installed. Resolves { ok, described, verified }
|
|
959
|
+
// or { ok: false, code, upToDate? } after printing why.
|
|
960
|
+
async function fetchVerified(key, { wanted, call, core, log, errlog, verb, shouldDownload = () => true }) {
|
|
961
|
+
const fail = (code, msg) => { if (msg) errlog(msg); return { ok: false, code }; };
|
|
962
|
+
const enc = encodeURIComponent(key);
|
|
963
|
+
let acq;
|
|
964
|
+
try { acq = await call('POST', `/store/modules/${enc}/acquire`, { json: wanted ? { version: wanted } : {} }); }
|
|
965
|
+
catch (e) { return fail(1, `bongos module ${verb}: the store could not be reached — ${e.message}`); }
|
|
966
|
+
if (!acq.ok) return fail(1, `REFUSING: the store would not hand over "${key}" (HTTP ${acq.status}): ${storeError(acq)}`);
|
|
967
|
+
const described = acq.data;
|
|
968
|
+
log(`bongos module ${verb} — "${key}" ${described.version} (needs core ${described.core_version})`);
|
|
969
|
+
|
|
970
|
+
const stop = shouldDownload(described);
|
|
971
|
+
if (stop !== true) return { ok: false, code: stop.code, upToDate: stop.upToDate };
|
|
972
|
+
|
|
973
|
+
const { satisfies } = require('../../src/module-loader/semver');
|
|
974
|
+
const coreV = core || require('../../src/module-api').CORE_VERSION;
|
|
975
|
+
if (!satisfies(coreV, described.core_version)) {
|
|
976
|
+
return fail(1, `REFUSING: "${key}" ${described.version} needs core ${described.core_version}; this core is ${coreV}, so it could not mount.`);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
let dl;
|
|
980
|
+
try { dl = await call('GET', `/store/modules/${enc}/versions/${encodeURIComponent(described.version)}/tarball`, { binary: true }); }
|
|
981
|
+
catch (e) { return fail(1, `bongos module ${verb}: download failed — ${e.message}`); }
|
|
982
|
+
if (!dl.ok) return fail(1, `REFUSING: the download was refused (HTTP ${dl.status}): ${storeError(dl)}`);
|
|
983
|
+
|
|
984
|
+
const artifact = require('./module-artifact');
|
|
985
|
+
const verified = await artifact.verifyModuleArtifact(dl.buf, { key, includeFiles: true });
|
|
986
|
+
if (!verified.ok) return fail(1, `REFUSING: the downloaded tarball failed verification (${verified.code}): ${verified.message}`);
|
|
987
|
+
if (verified.version !== described.version || verified.tarballSha256 !== described.tarball_sha256 || verified.treeSha256 !== described.tree_sha256) {
|
|
988
|
+
return fail(1, `REFUSING: the download is not the ${described.version} the store described (version or hash differs).`);
|
|
989
|
+
}
|
|
990
|
+
log(` ✓ verified ${verified.files.length} file(s), ${verified.bytes} bytes — tree ${verified.treeSha256.slice(0, 12)}…`);
|
|
991
|
+
return { ok: true, described, verified };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Tell the store which exact version was placed. A failure here never undoes the
|
|
995
|
+
// placement: the files are good, and the next install or update records it.
|
|
996
|
+
async function recordPlaced(key, version, { call, errlog, verb }) {
|
|
997
|
+
let rec;
|
|
998
|
+
try { rec = await call('POST', `/store/modules/${encodeURIComponent(key)}/acquired`, { json: { version } }); }
|
|
999
|
+
catch (e) { rec = { ok: false, status: 0, data: { error: { message: e.message } } }; }
|
|
1000
|
+
if (!rec.ok) {
|
|
1001
|
+
errlog(`WARNING: "${key}" is ${verb === 'update' ? 'updated' : 'installed'}, but the store did not record the version (${storeError(rec)}). Nothing is lost; the next install or update records it.`);
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
return true;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
928
1007
|
// `bongos module install <key> [--version X.Y.Z]` — task 1003785. Acquires the
|
|
929
1008
|
// module (a free one is granted on the spot; a priced one waits on the buy action,
|
|
930
1009
|
// area 8), downloads its tarball, verifies it with verifyModuleArtifact — the one
|
|
931
1010
|
// checker the store itself uses on publish — and places it under the instance's
|
|
932
1011
|
// modules/. It never enables: the loader discovers what is on disk and the hall's
|
|
933
|
-
// Modules tab decides what mounts. A
|
|
934
|
-
// installed module to a newer version is `update` (task 1003786).
|
|
1012
|
+
// Modules tab decides what mounts. A key already on disk in ANY discovery root is
|
|
1013
|
+
// refused; moving an installed module to a newer version is `update` (task 1003786).
|
|
935
1014
|
async function cmdInstall(args, {
|
|
936
1015
|
log = console.log, errlog = console.error,
|
|
937
|
-
modulesDir, request, core,
|
|
1016
|
+
modulesDir, roots, request, core,
|
|
938
1017
|
} = {}) {
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
const
|
|
942
|
-
const key = args.find((a, i) => !a.startsWith('-') && args[i - 1] !== '--version');
|
|
943
|
-
if (!key || (vIdx !== -1 && (!wanted || wanted.startsWith('--')))) { errlog(usage); return 2; }
|
|
944
|
-
if (!KEY_RE.test(key)) { errlog(`REFUSING: "${key}" is not a module key (lowercase kebab-case).`); return 2; }
|
|
945
|
-
const { parseVersion, satisfies } = require('../../src/module-loader/semver');
|
|
946
|
-
if (wanted && !parseVersion(wanted)) { errlog(`REFUSING: --version must be exact X.Y.Z, not "${wanted}".`); return 2; }
|
|
1018
|
+
const parsed = parseStoreArgs(args, 'install', errlog);
|
|
1019
|
+
if (parsed.code) return parsed.code;
|
|
1020
|
+
const { key, wanted } = parsed;
|
|
947
1021
|
|
|
948
1022
|
const dir = modulesDir || instanceModulesDir();
|
|
949
1023
|
const target = path.join(dir, key);
|
|
950
|
-
|
|
951
|
-
|
|
1024
|
+
const taken = [dir, ...(roots || discoveryRoots())].map((r) => path.join(r, key)).find((p) => fs.existsSync(p));
|
|
1025
|
+
if (taken) {
|
|
1026
|
+
errlog(`REFUSING: "${key}" is already on disk at ${taken}. Install only adds a new module; to move an installed one forward, run \`bongos module update ${key}\`.`);
|
|
952
1027
|
return 1;
|
|
953
1028
|
}
|
|
954
1029
|
|
|
@@ -956,47 +1031,99 @@ async function cmdInstall(args, {
|
|
|
956
1031
|
try { call = request || storeRequest(); }
|
|
957
1032
|
catch (e) { errlog(`bongos module install: ${e.message}`); return 1; }
|
|
958
1033
|
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
try { acq = await call('POST', `/store/modules/${enc}/acquire`, { json: wanted ? { version: wanted } : {} }); }
|
|
962
|
-
catch (e) { errlog(`bongos module install: the store could not be reached — ${e.message}`); return 1; }
|
|
963
|
-
if (!acq.ok) { errlog(`REFUSING: the store would not hand over "${key}" (HTTP ${acq.status}): ${storeError(acq)}`); return 1; }
|
|
964
|
-
const a = acq.data;
|
|
965
|
-
log(`bongos module install — "${key}" ${a.version} (needs core ${a.core_version})`);
|
|
1034
|
+
const got = await fetchVerified(key, { wanted, call, core, log, errlog, verb: 'install' });
|
|
1035
|
+
if (!got.ok) return got.code;
|
|
966
1036
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
return 1;
|
|
971
|
-
}
|
|
1037
|
+
try { placeModule(got.verified.files, target); }
|
|
1038
|
+
catch (e) { errlog(`bongos module install: could not place the module — ${e.message}`); return 1; }
|
|
1039
|
+
log(` ✓ placed at ${target}`);
|
|
972
1040
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1041
|
+
if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'install' }))) return 1;
|
|
1042
|
+
log(`\n✓ Installed "${key}" ${got.described.version}. It is not switched on: enable it from the hall's Modules tab when you want it.`);
|
|
1043
|
+
return 0;
|
|
1044
|
+
}
|
|
977
1045
|
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1046
|
+
// Replace an installed module's directory with a verified new one. The new files
|
|
1047
|
+
// are written to a staging dir first; the old dir is moved aside, the new one moved
|
|
1048
|
+
// in, and only then is the old one removed — so a failure at any step leaves the
|
|
1049
|
+
// previous version in place, never a half-written or missing module.
|
|
1050
|
+
function replaceModule(files, target) {
|
|
1051
|
+
const aside = `${target}.previous-${process.pid}-${Date.now()}`;
|
|
1052
|
+
const staging = `${target}.installing-${process.pid}-${Date.now()}`;
|
|
1053
|
+
placeModule(files, staging);
|
|
1054
|
+
try {
|
|
1055
|
+
fs.renameSync(target, aside);
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
1058
|
+
throw e;
|
|
1059
|
+
}
|
|
1060
|
+
try {
|
|
1061
|
+
fs.renameSync(staging, target);
|
|
1062
|
+
} catch (e) {
|
|
1063
|
+
fs.renameSync(aside, target);
|
|
1064
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
1065
|
+
throw e;
|
|
984
1066
|
}
|
|
985
|
-
|
|
1067
|
+
fs.rmSync(aside, { recursive: true, force: true });
|
|
1068
|
+
}
|
|
986
1069
|
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1070
|
+
// `bongos module update <key> [--version X.Y.Z]` — task 1003786. Moves an installed
|
|
1071
|
+
// store module forward, through exactly the acquisition path install uses
|
|
1072
|
+
// (fetchVerified): the same calls whether the module was free or paid, and a module
|
|
1073
|
+
// the caller already holds is never charged again (the store's acquire route only
|
|
1074
|
+
// looks at price for a caller with no entitlement). Only forward: with no --version
|
|
1075
|
+
// it takes the newest, and says so without downloading when that is not newer; an
|
|
1076
|
+
// older --version is refused, since rolling back is a different act. Like install it
|
|
1077
|
+
// never enables or disables — the module keeps whatever switch it had.
|
|
1078
|
+
async function cmdUpdate(args, {
|
|
1079
|
+
log = console.log, errlog = console.error,
|
|
1080
|
+
modulesDir, roots, request, core,
|
|
1081
|
+
} = {}) {
|
|
1082
|
+
const parsed = parseStoreArgs(args, 'update', errlog);
|
|
1083
|
+
if (parsed.code) return parsed.code;
|
|
1084
|
+
const { key, wanted } = parsed;
|
|
990
1085
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
errlog(
|
|
1086
|
+
const dir = modulesDir || instanceModulesDir();
|
|
1087
|
+
const target = path.join(dir, key);
|
|
1088
|
+
if (!fs.existsSync(path.join(target, 'module.json'))) {
|
|
1089
|
+
const bundled = (roots || discoveryRoots()).map((r) => path.join(r, key)).find((p) => p !== target && fs.existsSync(p));
|
|
1090
|
+
errlog(bundled
|
|
1091
|
+
? `REFUSING: "${key}" at ${bundled} comes with the core, not the store. It updates when the core does (\`bongos upgrade\`).`
|
|
1092
|
+
: `REFUSING: "${key}" is not installed here. Run \`bongos module install ${key}\` first.`);
|
|
996
1093
|
return 1;
|
|
997
1094
|
}
|
|
1095
|
+
const { parseVersion, cmp } = require('../../src/module-loader/semver');
|
|
1096
|
+
let installed;
|
|
1097
|
+
try { installed = JSON.parse(fs.readFileSync(path.join(target, 'module.json'), 'utf8')).version; }
|
|
1098
|
+
catch (e) { errlog(`REFUSING: could not read the installed ${key}/module.json — ${e.message}`); return 1; }
|
|
1099
|
+
const have = parseVersion(String(installed));
|
|
1100
|
+
if (!have) { errlog(`REFUSING: the installed "${key}" has no exact version in module.json, so there is nothing to compare against.`); return 1; }
|
|
1101
|
+
if (wanted && cmp(parseVersion(wanted), have) < 0) {
|
|
1102
|
+
errlog(`REFUSING: ${wanted} is older than the installed ${installed}. Update only moves forward.`);
|
|
1103
|
+
return 1;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
let call;
|
|
1107
|
+
try { call = request || storeRequest(); }
|
|
1108
|
+
catch (e) { errlog(`bongos module update: ${e.message}`); return 1; }
|
|
1109
|
+
|
|
1110
|
+
const got = await fetchVerified(key, {
|
|
1111
|
+
wanted, call, core, log, errlog, verb: 'update',
|
|
1112
|
+
shouldDownload: (d) => {
|
|
1113
|
+
const next = parseVersion(String(d.version));
|
|
1114
|
+
if (next && cmp(next, have) > 0) return true;
|
|
1115
|
+
log(`\n✓ "${key}" is up to date at ${installed}.`);
|
|
1116
|
+
return { code: 0, upToDate: true };
|
|
1117
|
+
},
|
|
1118
|
+
});
|
|
1119
|
+
if (!got.ok) return got.code;
|
|
1120
|
+
|
|
1121
|
+
try { replaceModule(got.verified.files, target); }
|
|
1122
|
+
catch (e) { errlog(`bongos module update: could not replace the module, ${installed} is still in place — ${e.message}`); return 1; }
|
|
1123
|
+
log(` ✓ replaced ${installed} with ${got.described.version} at ${target}`);
|
|
998
1124
|
|
|
999
|
-
|
|
1125
|
+
if (!(await recordPlaced(key, got.described.version, { call, errlog, verb: 'update' }))) return 1;
|
|
1126
|
+
log(`\n✓ Updated "${key}" ${installed} → ${got.described.version}. Its on/off switch is unchanged.`);
|
|
1000
1127
|
return 0;
|
|
1001
1128
|
}
|
|
1002
1129
|
|
|
@@ -1015,6 +1142,8 @@ function printHelp(out = console.log) {
|
|
|
1015
1142
|
out(' Pack a module version (tarball + hash manifest) and upload it to the module store');
|
|
1016
1143
|
out(' bongos module install <key> [--version X.Y.Z]');
|
|
1017
1144
|
out(' Acquire a module from the store, verify it and place it under modules/ (not enabled)');
|
|
1145
|
+
out(' bongos module update <key> [--version X.Y.Z]');
|
|
1146
|
+
out(' Move an installed store module to a newer version (same path, paid or free)');
|
|
1018
1147
|
}
|
|
1019
1148
|
|
|
1020
1149
|
async function main(argv) {
|
|
@@ -1027,6 +1156,7 @@ async function main(argv) {
|
|
|
1027
1156
|
if (sub === 'submit') return cmdSubmit(args.slice(1));
|
|
1028
1157
|
if (sub === 'publish') return cmdPublish(args.slice(1));
|
|
1029
1158
|
if (sub === 'install') return cmdInstall(args.slice(1));
|
|
1159
|
+
if (sub === 'update') return cmdUpdate(args.slice(1));
|
|
1030
1160
|
if (!sub || sub === 'help' || sub === '--help' || sub === '-h') {
|
|
1031
1161
|
printHelp();
|
|
1032
1162
|
return sub ? 0 : 2;
|
|
@@ -1037,7 +1167,7 @@ async function main(argv) {
|
|
|
1037
1167
|
}
|
|
1038
1168
|
|
|
1039
1169
|
module.exports = {
|
|
1040
|
-
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, printHelp,
|
|
1170
|
+
main, cmdNew, cmdList, cmdUpgrade, cmdCheck, cmdSubmit, cmdPublish, cmdInstall, cmdUpdate, printHelp,
|
|
1041
1171
|
gatherCatalog, formatCatalog, ownProjectName,
|
|
1042
1172
|
scaffoldModule, gatherUpgrade, evaluateUpgrade, buildManifest,
|
|
1043
1173
|
formatMaintenanceHint, isPastRemoveAfter,
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-render.js — the Render standup leg: the runner half of the `render-standup`
|
|
3
|
+
// intent (task 1004183; ADR 0327, ADR 0345). Its own file because provision.js sits at
|
|
4
|
+
// the size ratchet; the dispatch there requires it inline.
|
|
5
|
+
//
|
|
6
|
+
// WHAT IT DOES. Stands a project up on its OWNER's Render account, with a key the owner
|
|
7
|
+
// lent for this one run:
|
|
8
|
+
// - ADOPT links a Render service the owner already runs. It reads that service through
|
|
9
|
+
// the workspace guard and records it. It makes NO write call to their account.
|
|
10
|
+
// - GREENFIELD creates the Postgres and the web service (render-payload.js decides what
|
|
11
|
+
// they are), sets the env vars that need the service's URL, and asks for a deploy.
|
|
12
|
+
// Ids are written to the row as each resource is created, so an owner who re-enters their
|
|
13
|
+
// key after a failure resumes onto what already exists instead of creating a second one.
|
|
14
|
+
//
|
|
15
|
+
// THE KEY IS BORROWED, NEVER STORED (ADR 0327 §2). It reaches this leg encrypted on the
|
|
16
|
+
// intent (provisioning_intents.borrowed_key_enc). It is decrypted here, used for this run
|
|
17
|
+
// only, and the column is cleared in a `finally` — on success, on a refusal and on a
|
|
18
|
+
// throw. resolveIntent clears it again, and the table's CHECK refuses a resolved intent
|
|
19
|
+
// that still holds one. What the encryption buys, said plainly: it protects the key from
|
|
20
|
+
// a database dump or a nightly backup while the intent waits. It does NOT defend against
|
|
21
|
+
// a compromised web tier — the key arrives there in plaintext before anything can
|
|
22
|
+
// encrypt it.
|
|
23
|
+
//
|
|
24
|
+
// A FAILURE IS TERMINAL. Every failure returns `terminal: true`, so the runner retires the
|
|
25
|
+
// intent instead of re-queuing it. There is no key left to retry with, and an unattended
|
|
26
|
+
// retry holding someone else's cloud credential is not a thing this platform does: the
|
|
27
|
+
// owner retries by entering their key again.
|
|
28
|
+
//
|
|
29
|
+
// Every Render call goes through scripts/gds/render-api.js, which pins the workspace and
|
|
30
|
+
// asserts it on every response, and which cannot delete.
|
|
31
|
+
|
|
32
|
+
const { RenderApi } = require('./render-api.js');
|
|
33
|
+
const payload = require('./render-payload.js');
|
|
34
|
+
const { parseTargetRef } = require('./provision-repo-auth.js');
|
|
35
|
+
const { provisionedEnvPrefix } = require('./provision-units.js');
|
|
36
|
+
|
|
37
|
+
// A key waiting longer than this was lent for a run that never came. It is refused and
|
|
38
|
+
// cleared rather than spent late.
|
|
39
|
+
const KEY_TTL_MS = 30 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
function renderApiFor(deps, token, ownerId) {
|
|
42
|
+
if (deps.renderApiFactory) return deps.renderApiFactory({ token, ownerId });
|
|
43
|
+
return new RenderApi({ token, ownerId, dryRun: !deps.apply, fetchImpl: deps.fetchImpl, log: deps.log });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Merge `patch` into the row's app_host. The runner is the only writer while an intent
|
|
47
|
+
// runs, and the web tier only writes it when enqueuing, which the open-intent index
|
|
48
|
+
// serialises — so a merge needs no read-modify-write.
|
|
49
|
+
async function recordAppHost(db, instId, patch) {
|
|
50
|
+
await db.query(
|
|
51
|
+
`UPDATE provisioning_instances SET app_host = COALESCE(app_host, '{}'::jsonb) || $2::jsonb WHERE id = $1`,
|
|
52
|
+
[instId, JSON.stringify({ ...patch, updatedAt: new Date().toISOString() })]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function clearBorrowedKey(db, intentId) {
|
|
56
|
+
await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function ownerLoginOf(db, inst) {
|
|
60
|
+
if (!inst.owner_builder_id) return null;
|
|
61
|
+
const { rows } = await db.query(`SELECT github_login FROM builders WHERE id = $1`, [inst.owner_builder_id]);
|
|
62
|
+
return (rows[0] && rows[0].github_login) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function adopt(api, inst, host, { db, log }) {
|
|
66
|
+
if (!host.serviceId) throw new Error('adopting needs the id of the Render service to link');
|
|
67
|
+
const svc = await api.getService(host.serviceId);
|
|
68
|
+
const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
|
|
69
|
+
await recordAppHost(db, inst.id, { state: 'linked', serviceUrl: url, error: null });
|
|
70
|
+
log(` [render] linked ${inst.slug} to service ${svc.id}${url ? ` (${url})` : ''} — nothing created or changed on Render`);
|
|
71
|
+
return { ok: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function greenfield(api, inst, host, deps) {
|
|
75
|
+
const { db, log } = deps;
|
|
76
|
+
const plans = payload.plansFor(host);
|
|
77
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
78
|
+
if (!repo) throw new Error('the project has no GitHub repository for Render to build from');
|
|
79
|
+
const envPrefix = deps.envPrefix || provisionedEnvPrefix();
|
|
80
|
+
|
|
81
|
+
// 1. The database — reused if a previous run got this far.
|
|
82
|
+
let pg = host.postgresId ? await api.getPostgres(host.postgresId) : null;
|
|
83
|
+
if (!pg) {
|
|
84
|
+
pg = await api.createPostgres(payload.renderPostgresPayload(inst, { ownerId: api.ownerId, dbPlan: plans.dbPlan, region: plans.region }));
|
|
85
|
+
await recordAppHost(db, inst.id, { postgresId: pg.id, dbPlan: plans.dbPlan, state: 'creating' });
|
|
86
|
+
log(` [render] created database ${pg.id} (${plans.dbPlan})`);
|
|
87
|
+
}
|
|
88
|
+
await api.settlePostgres(pg.id, { sleepFn: deps.sleepFn });
|
|
89
|
+
const conn = await api.postgresConnectionInfo(pg.id);
|
|
90
|
+
const dbUrl = conn && conn.internalConnectionString;
|
|
91
|
+
if (!dbUrl) throw new Error(`Render gave database ${pg.id} no internal connection string`);
|
|
92
|
+
|
|
93
|
+
// 2. The web service — reused likewise.
|
|
94
|
+
let svc = host.serviceId ? await api.getService(host.serviceId) : null;
|
|
95
|
+
if (!svc) {
|
|
96
|
+
const firstAdmin = (await ownerLoginOf(db, inst)) || repo.owner;
|
|
97
|
+
const created = await api.createService(payload.renderServicePayload(inst, {
|
|
98
|
+
ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, branch: host.branch,
|
|
99
|
+
webPlan: plans.webPlan, region: plans.region, dbUrl, secretKey: payload.freshSecretKey(), envPrefix, firstAdmin,
|
|
100
|
+
}));
|
|
101
|
+
svc = created.service;
|
|
102
|
+
await recordAppHost(db, inst.id, { serviceId: svc.id, webPlan: plans.webPlan, region: plans.region, state: 'creating' });
|
|
103
|
+
log(` [render] created web service ${svc.id} (${plans.webPlan})`);
|
|
104
|
+
}
|
|
105
|
+
const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
|
|
106
|
+
|
|
107
|
+
// 3. What needs the URL, then one deploy that picks it all up.
|
|
108
|
+
for (const { key, value } of payload.renderAfterCreateEnv(url, { envPrefix })) await api.putEnvVar(svc.id, key, value);
|
|
109
|
+
await api.triggerDeploy(svc.id);
|
|
110
|
+
await recordAppHost(db, inst.id, { state: 'deploying', serviceUrl: url, error: null });
|
|
111
|
+
log(` [render] ${inst.slug} is deploying at ${url}`);
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function renderStandupInstance(inst, deps, intent) {
|
|
116
|
+
const { db, log } = deps;
|
|
117
|
+
const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
|
|
118
|
+
const host = inst.app_host || {};
|
|
119
|
+
try {
|
|
120
|
+
if (!intent || !intent.borrowed_key_enc) return refuse('no Render key came with this request — enter it again to retry');
|
|
121
|
+
if (host.platform !== 'render') return refuse(`this project's app is not set to run on Render (platform '${host.platform || 'none'}')`);
|
|
122
|
+
if (!host.ownerId) return refuse('no Render workspace was chosen');
|
|
123
|
+
const age = Date.now() - new Date(intent.created_at || 0).getTime();
|
|
124
|
+
if (!(age < KEY_TTL_MS)) return refuse('the Render key waited too long to be used and was cleared — enter it again to retry');
|
|
125
|
+
if (host.mode !== 'adopt' && host.mode !== 'greenfield') return refuse(`unknown Render setup mode '${host.mode}'`);
|
|
126
|
+
|
|
127
|
+
const api = renderApiFor(deps, secretBox.decrypt(intent.borrowed_key_enc), host.ownerId);
|
|
128
|
+
// Membership, never selection: the workspace is the one the owner named, and it must
|
|
129
|
+
// be one this key can act in.
|
|
130
|
+
const owners = await api.listOwners();
|
|
131
|
+
if (!owners.some((o) => o.id === host.ownerId)) return refuse(`this Render key cannot reach workspace ${host.ownerId}`);
|
|
132
|
+
|
|
133
|
+
return host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return refuse(e && e.message ? e.message : String(e));
|
|
136
|
+
} finally {
|
|
137
|
+
if (intent && intent.id != null) await clearBorrowedKey(db, intent.id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function refuse(error) {
|
|
141
|
+
await recordAppHost(db, inst.id, { state: 'failed', error }).catch(() => {});
|
|
142
|
+
log(` ✖ [render] ${inst.slug}: ${error}`);
|
|
143
|
+
return { ok: false, terminal: true, error };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Teardown's half (ADR 0327 §3): forget the Render app, never delete it. The ids stay on
|
|
148
|
+
// the row so an operator can still say which services were the project's; the owner
|
|
149
|
+
// deletes them in their own Render dashboard if they want to stop paying.
|
|
150
|
+
async function forgetRenderApp(inst, { db, log, apply }) {
|
|
151
|
+
const host = inst.app_host;
|
|
152
|
+
if (!host || host.platform !== 'render') return;
|
|
153
|
+
const what = [host.serviceId && `service ${host.serviceId}`, host.postgresId && `database ${host.postgresId}`].filter(Boolean).join(' and ') || 'nothing yet';
|
|
154
|
+
log(` [render] ${apply ? 'disconnected' : 'would disconnect'} from Render (${what}); nothing on Render is deleted — the owner removes it in their own dashboard`);
|
|
155
|
+
if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { renderStandupInstance, forgetRenderApp, clearBorrowedKey, recordAppHost, KEY_TTL_MS };
|
|
@@ -42,6 +42,8 @@ async function teardownInstance(inst, deps) {
|
|
|
42
42
|
await deps.doApi.deleteDroplet(inst.droplet_id);
|
|
43
43
|
}
|
|
44
44
|
parkAddress(inst, { controlExec, log, apply });
|
|
45
|
+
// The app's Render services are the OWNER's: forget them, never delete them (ADR 0327 §3, task 1004183).
|
|
46
|
+
await require('./provision-render.js').forgetRenderApp(inst, { db, log, apply });
|
|
45
47
|
// Revoke the instance's deploy key (ADR 0176) and remove its local halves. The
|
|
46
48
|
// checkout itself is deliberately left in place (data is precious), so the key
|
|
47
49
|
// files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1293,10 +1293,10 @@ async function cmdRunIntents(deps) {
|
|
|
1293
1293
|
else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
|
|
1294
1294
|
else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
|
|
1295
1295
|
else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
|
|
1296
|
-
else if (intent.action === 'repo-private') result = await require('./provision-repo-private.js').repoPrivateInstance(inst, deps); // wire the deploy key, THEN flip (task 1004193); required here — this file is at the size ratchet
|
|
1296
|
+
else if (intent.action === 'repo-private' || intent.action === 'render-standup') result = intent.action === 'repo-private' ? await require('./provision-repo-private.js').repoPrivateInstance(inst, deps) : await require('./provision-render.js').renderStandupInstance(inst, deps, intent); // wire the deploy key, THEN flip (task 1004193) · spend the borrowed Render key once (task 1004183); required here — this file is at the size ratchet
|
|
1297
1297
|
else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
|
|
1298
1298
|
if (result && result.ok === false) {
|
|
1299
|
-
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
1299
|
+
if (intent.attempts >= MAX_INTENT_ATTEMPTS || result.terminal) { // terminal: a leg that must not be retried unattended (the Render leg's key is already gone — task 1004183)
|
|
1300
1300
|
await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
|
|
1301
1301
|
// A retired intent stops silently otherwise (F19): record the reason
|
|
1302
1302
|
// on the instance WITHOUT changing its lifecycle status — passing the
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// scripts/gds/render-api.js — a thin Render API v1 client for the control plane, with
|
|
2
|
+
// the workspace (ownerId) guard (task 1004183; ADR 0327).
|
|
3
|
+
//
|
|
4
|
+
// Control-plane code, like its model scripts/gds/do-api.js: only the provisioning runner
|
|
5
|
+
// uses it, never the web tier (ADR 0111 §2). Same skeleton (JsonHttpApi): injectable
|
|
6
|
+
// transport, dry-run by default, and live mode refuses to start without a token.
|
|
7
|
+
//
|
|
8
|
+
// THE GUARD. A Render API key belongs to a USER, not a workspace, and carries that user's
|
|
9
|
+
// rights in every workspace they belong to — measured 2026-09-22: one owner's key reached
|
|
10
|
+
// three workspaces, one of them another builder's, holding live production services. So:
|
|
11
|
+
//
|
|
12
|
+
// - the client is built for ONE workspace (`ownerId`) and refuses to exist without it —
|
|
13
|
+
// never defaulted, and never derived by taking the first of GET /owners;
|
|
14
|
+
// - every call that names an owner sends this one;
|
|
15
|
+
// - every response that carries an owner is ASSERTED to carry this one, and a missing
|
|
16
|
+
// or different owner is a hard error (RenderOwnerMismatch). Assert on the response,
|
|
17
|
+
// not the request: a request you got right says nothing about a response that came
|
|
18
|
+
// from somewhere else. A list is checked element by element.
|
|
19
|
+
//
|
|
20
|
+
// Render's own shapes decide where the owner sits: a service carries `ownerId`, a
|
|
21
|
+
// Postgres carries `owner.id` (both measured by the pilot, task 1004142). A few calls
|
|
22
|
+
// answer with no owner at all (a database's connection-info, an env-var PUT, a deploy
|
|
23
|
+
// trigger). Those are only reachable through a method that first reads the parent
|
|
24
|
+
// resource through the guard, so nothing is ever sent to or read from a resource whose
|
|
25
|
+
// workspace was not checked.
|
|
26
|
+
//
|
|
27
|
+
// NO DELETE. The platform must not hold the ability to destroy infrastructure in an
|
|
28
|
+
// account it does not own (ADR 0327 §3). There is no delete method, and _req refuses the
|
|
29
|
+
// verb outright, so adding one later is a visible change to this file, not a one-liner.
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const { JsonHttpApi, makeApiError } = require('./http-api-client');
|
|
34
|
+
|
|
35
|
+
const RENDER_API_BASE = 'https://api.render.com/v1';
|
|
36
|
+
|
|
37
|
+
const RenderApiError = makeApiError('RenderApiError', 'RENDER_API_ERROR');
|
|
38
|
+
const RenderOwnerMismatch = makeApiError('RenderOwnerMismatch', 'RENDER_OWNER_MISMATCH');
|
|
39
|
+
|
|
40
|
+
// The owner id a Render resource carries, in either of the two shapes Render uses.
|
|
41
|
+
// null when it carries none.
|
|
42
|
+
function ownerOf(resource) {
|
|
43
|
+
if (!resource || typeof resource !== 'object') return null;
|
|
44
|
+
if (typeof resource.ownerId === 'string' && resource.ownerId) return resource.ownerId;
|
|
45
|
+
if (resource.owner && typeof resource.owner.id === 'string' && resource.owner.id) return resource.owner.id;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Env var values are the owner's secrets on their way to Render. A dry-run log is the one
|
|
50
|
+
// place this client would otherwise print a request body, so it prints the names only.
|
|
51
|
+
function redact(body) {
|
|
52
|
+
if (!body || typeof body !== 'object') return body;
|
|
53
|
+
const out = { ...body };
|
|
54
|
+
if (Array.isArray(out.envVars)) out.envVars = out.envVars.map((v) => ({ key: v && v.key, value: '[redacted]' }));
|
|
55
|
+
if ('value' in out) out.value = '[redacted]';
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class RenderApi extends JsonHttpApi {
|
|
60
|
+
// opts: { token, ownerId, dryRun=true, fetchImpl=globalThis.fetch, base, log }
|
|
61
|
+
constructor(opts = {}) {
|
|
62
|
+
super(opts, { base: RENDER_API_BASE, logTag: 'Render', ErrorClass: RenderApiError });
|
|
63
|
+
if (typeof opts.ownerId !== 'string' || !opts.ownerId.trim()) {
|
|
64
|
+
throw new RenderApiError('a Render client needs the workspace it acts in (ownerId) — it is never defaulted');
|
|
65
|
+
}
|
|
66
|
+
this.ownerId = opts.ownerId.trim();
|
|
67
|
+
if (!this.dryRun && !this.token) {
|
|
68
|
+
throw new RenderApiError('live Render call requested with no API key');
|
|
69
|
+
}
|
|
70
|
+
if (!this.dryRun && typeof this.fetchImpl !== 'function') {
|
|
71
|
+
throw new RenderApiError('no fetch implementation available for live mode');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async _req(method, path, body, opts = {}) {
|
|
76
|
+
if (method === 'DELETE') throw new RenderApiError(`refusing ${method} ${path}: the platform never deletes in an account it does not own (ADR 0327 §3)`);
|
|
77
|
+
if (this.dryRun) {
|
|
78
|
+
this.log(`[dry-run] Render ${method} ${path}${body ? ' ' + JSON.stringify(redact(body)) : ''}`);
|
|
79
|
+
return { dryRun: true, ...(opts.dryRunResult || {}) };
|
|
80
|
+
}
|
|
81
|
+
return super._req(method, path, body, opts);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Throws unless `resource` carries exactly this client's workspace.
|
|
85
|
+
assertOwned(resource, what) {
|
|
86
|
+
const got = ownerOf(resource);
|
|
87
|
+
if (got !== this.ownerId) {
|
|
88
|
+
throw new RenderOwnerMismatch(
|
|
89
|
+
got
|
|
90
|
+
? `Render answered ${what} from workspace ${got}, not ${this.ownerId} — refusing to go on`
|
|
91
|
+
: `Render answered ${what} with no workspace on it — refusing to go on`,
|
|
92
|
+
{ body: null });
|
|
93
|
+
}
|
|
94
|
+
return resource;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- workspaces ------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// The workspaces the key can reach, as [{ id, name, type }]. Used for MEMBERSHIP only —
|
|
100
|
+
// "is the workspace the owner named one this key can act in" — never to pick one.
|
|
101
|
+
async listOwners() {
|
|
102
|
+
const json = await this._req('GET', '/owners?limit=100', null, {
|
|
103
|
+
dryRunResult: { items: [{ owner: { id: this.ownerId, name: '(dry-run)', type: 'team' } }] },
|
|
104
|
+
});
|
|
105
|
+
const items = Array.isArray(json) ? json : (json && json.items) || [];
|
|
106
|
+
return items.map((e) => (e && e.owner) || e).filter((o) => o && typeof o.id === 'string');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- services --------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
async getService(serviceId) {
|
|
112
|
+
const json = await this._req('GET', `/services/${encodeURIComponent(serviceId)}`, null, {
|
|
113
|
+
dryRunResult: { id: serviceId, ownerId: this.ownerId, serviceDetails: { url: null } },
|
|
114
|
+
});
|
|
115
|
+
return this.assertOwned(json && json.service ? json.service : json, `service ${serviceId}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// payload: the POST /services body (render-payload.js). Its ownerId must be ours — the
|
|
119
|
+
// payload builder takes it as an argument, so this is checked, not overwritten.
|
|
120
|
+
async createService(payload) {
|
|
121
|
+
if (!payload || payload.ownerId !== this.ownerId) throw new RenderApiError('a service payload must name this client\'s workspace');
|
|
122
|
+
const json = await this._req('POST', '/services', payload, {
|
|
123
|
+
dryRunResult: { deployId: null, service: { id: `dryrun-srv-${payload.name}`, ownerId: this.ownerId, serviceDetails: { url: null } } },
|
|
124
|
+
});
|
|
125
|
+
return { deployId: (json && json.deployId) || null, service: this.assertOwned(json && json.service, 'the new service') };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Set one env var on a service this workspace owns. The PUT answers with no owner, so
|
|
129
|
+
// the service is read through the guard first.
|
|
130
|
+
async putEnvVar(serviceId, key, value) {
|
|
131
|
+
await this.getService(serviceId);
|
|
132
|
+
return this._req('PUT', `/services/${encodeURIComponent(serviceId)}/env-vars/${encodeURIComponent(key)}`, { value }, { dryRunResult: { key } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async triggerDeploy(serviceId) {
|
|
136
|
+
await this.getService(serviceId);
|
|
137
|
+
return this._req('POST', `/services/${encodeURIComponent(serviceId)}/deploys`, {}, { dryRunResult: { id: null } });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- Postgres --------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
async createPostgres(payload) {
|
|
143
|
+
if (!payload || payload.ownerId !== this.ownerId) throw new RenderApiError('a Postgres payload must name this client\'s workspace');
|
|
144
|
+
const json = await this._req('POST', '/postgres', payload, {
|
|
145
|
+
dryRunResult: { id: `dryrun-pg-${payload.name}`, status: 'creating', owner: { id: this.ownerId } },
|
|
146
|
+
});
|
|
147
|
+
return this.assertOwned(json, 'the new database');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async getPostgres(postgresId) {
|
|
151
|
+
const json = await this._req('GET', `/postgres/${encodeURIComponent(postgresId)}`, null, {
|
|
152
|
+
dryRunResult: { id: postgresId, status: 'available', owner: { id: this.ownerId } },
|
|
153
|
+
});
|
|
154
|
+
return this.assertOwned(json, `database ${postgresId}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The database's connection strings. The answer carries no owner, so the database is
|
|
158
|
+
// read through the guard first.
|
|
159
|
+
async postgresConnectionInfo(postgresId) {
|
|
160
|
+
await this.getPostgres(postgresId);
|
|
161
|
+
return this._req('GET', `/postgres/${encodeURIComponent(postgresId)}/connection-info`, null, {
|
|
162
|
+
dryRunResult: { internalConnectionString: 'postgres://dry-run' },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Poll until a new database is 'available'. `sleepFn` lets tests drive it with no clock.
|
|
167
|
+
async settlePostgres(postgresId, { tries = 60, sleepMs = 5000, sleepFn } = {}) {
|
|
168
|
+
const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
169
|
+
for (let i = 0; i < tries; i++) {
|
|
170
|
+
const pg = await this.getPostgres(postgresId);
|
|
171
|
+
if (pg.status === 'available') return pg;
|
|
172
|
+
await sleep(sleepMs);
|
|
173
|
+
}
|
|
174
|
+
throw new RenderApiError(`database ${postgresId} did not become available in time`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { RenderApi, RenderApiError, RenderOwnerMismatch, RENDER_API_BASE, ownerOf, redact };
|