@bongos/core 1.19.1081 → 1.20.1
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 +526 -191
- package/.claude/skills/planning-session/SKILL.md +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +32 -0
- package/clients/bongos-client/index.cjs +32 -0
- package/clients/bongos-client/index.d.ts +51 -6
- package/clients/bongos-client/index.mjs +32 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +851 -19
- package/docs/api-reference.md +33 -9
- package/docs/architecture.md +6 -2
- package/docs/copy-inventory.md +620 -553
- package/docs/copy-registry.json +1467 -820
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +6 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1674 -1566
- package/migrations/core_260_goals_working_area.sql +63 -0
- package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
- package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
- package/modules/agents/lib/validate.js +43 -0
- package/modules/autonomy/gauge.js +38 -2
- package/modules/grading/grader-subagent.js +37 -2
- package/modules/grading/grader-workers/reader.js +65 -5
- package/modules/hall-ui/public/approval-queue.css +33 -7
- package/modules/hall-ui/public/approval-queue.js +9 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/brand-holes.js +121 -0
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +6 -1
- package/modules/hall-ui/public/deploy.js +22 -8
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/dom-utils.js +20 -0
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/idea.js +28 -0
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/ideas.js +19 -3
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile-nudge.js +21 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +19 -30
- package/modules/hall-ui/public/settings.js +24 -177
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/shell.js +4 -2
- package/modules/hall-ui/public/studio.css +14 -8
- package/modules/hall-ui/public/studio.html +5 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
- package/modules/hall-ui/public/tweak-editor.css +142 -11
- package/modules/hall-ui/public/tweak-editor.html +25 -9
- package/modules/hall-ui/public/tweak-editor.js +243 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/approval-queue.md +6 -0
- package/modules/hall-ui/records/tweak-editor.md +6 -0
- package/modules/ideas/ratify.js +36 -8
- package/modules/ideas/routes/ratify-goal.js +6 -0
- package/modules/lifecycle/db-analytics.js +79 -10
- package/modules/lifecycle/db-claims.js +48 -1
- package/modules/lifecycle/db-deps-criteria.js +94 -40
- package/modules/lifecycle/db-goals.js +4 -1
- package/modules/lifecycle/db-grade.js +9 -1
- package/modules/lifecycle/db-relevance-flags.js +183 -0
- package/modules/lifecycle/db-versions.js +4 -3
- package/modules/lifecycle/db.js +19 -0
- package/modules/lifecycle/done-when.js +5 -3
- package/modules/lifecycle/est-advisory.js +31 -4
- package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
- package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
- package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +8 -1
- package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/lifecycle/routes/tasks.js +27 -24
- package/modules/lifecycle/routes/visuals.js +9 -0
- package/modules/lifecycle/ship-preflight.js +7 -11
- package/modules/lifecycle/task-visuals.js +109 -5
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/preview/commands.js +81 -0
- package/modules/npm-release/preview/divert.js +95 -0
- package/modules/npm-release/preview/env.js +78 -0
- package/modules/npm-release/preview/proxy.js +107 -0
- package/modules/npm-release/preview/runtime.js +83 -0
- package/modules/npm-release/preview/supervisor.js +175 -0
- package/modules/npm-release/public/work.js +65 -1
- package/modules/npm-release/routes/preview.js +133 -0
- package/modules/provisioning/app-status.js +83 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +44 -1
- package/modules/provisioning/routes/render-standup.js +81 -6
- package/modules/provisioning/starter-bundles.js +4 -1
- package/modules/public-landing/public/account-private.states.json +14 -0
- package/modules/public-landing/public/account.html +513 -0
- package/modules/public-landing/public/account.probes.json +37 -0
- package/modules/public-landing/public/account.states.json +16 -0
- package/modules/public-landing/public/index.html +5 -2
- package/modules/public-landing/public/projects.html +661 -18
- package/modules/public-landing/public/projects.states.json +8 -1
- package/modules/render-deploy/deploys.js +203 -0
- package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
- package/modules/render-deploy/module.json +25 -0
- package/modules/render-deploy/public/deploy.css +10 -0
- package/modules/render-deploy/public/deploy.js +249 -0
- package/modules/render-deploy/render.js +56 -0
- package/modules/render-deploy/routes/act.js +88 -0
- package/modules/render-deploy/routes/app.js +65 -0
- package/modules/render-deploy/routes/history.js +38 -0
- package/modules/specialities/db.js +27 -8
- package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
- package/modules/specialities/routes/specialities.js +51 -3
- package/modules/specialities/skills.js +94 -0
- package/modules/specialities/specialities.js +56 -9
- package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
- package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
- package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
- package/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/serve.js +96 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +163 -0
- package/scripts/gds/agents-sync.js +13 -3
- package/scripts/gds/autobongos-run.js +80 -2
- package/scripts/gds/autobongos-service.cmd +12 -0
- package/scripts/gds/copy-apply.js +14 -0
- package/scripts/gds/dev-box-guard.js +3 -2
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/grade-correlation-audit.js +42 -12
- package/scripts/gds/grade-replay.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +38 -7
- package/scripts/gds/provision-pin-key.js +176 -0
- package/scripts/gds/provision-render.js +62 -31
- package/scripts/gds/provision-teardown.js +7 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +10 -10
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +19 -0
- package/scripts/gds/render-payload.js +39 -65
- package/scripts/gds/run-unit-tests.js +10 -0
- package/scripts/gds/ship-finish.js +19 -20
- package/scripts/gds/smoke-dependencies.sh +37 -8
- package/scripts/gds/status.js +12 -4
- package/scripts/gds/upgrade.js +2 -2
- package/scripts/public-mirror-export.js +7 -1
- package/src/bongos/module-scope-map.js +13 -0
- package/src/bongos/serve-internal.js +3 -1
- package/src/module-api.js +10 -1
- package/src/platform-server.js +39 -0
- package/tests/account_privacy_flags.mjs +10 -6
- package/tests/agents_authoring.mjs +15 -1
- package/tests/agents_sync.mjs +106 -5
- package/tests/agents_validate.mjs +45 -0
- package/tests/api_path_404.mjs +6 -0
- package/tests/autobongos_cadence.mjs +7 -0
- package/tests/autobongos_loop.mjs +136 -1
- package/tests/autonomy_gauge.mjs +62 -0
- package/tests/blocker_hall_live_proof.mjs +275 -0
- package/tests/claim_gate_ci_unblock.mjs +116 -0
- package/tests/claim_gate_rebase.mjs +15 -3
- package/tests/collab_page.mjs +10 -0
- package/tests/conductor_main_e2e.mjs +97 -0
- package/tests/core_262_drop_box_blocked_db.mjs +143 -0
- package/tests/core_upgrade_runner.mjs +1 -0
- package/tests/dependency_writes_atomic.mjs +191 -0
- package/tests/deploy_page_projects.mjs +2 -1
- package/tests/effective_visibility_predicate.mjs +15 -0
- package/tests/est_advisory.mjs +27 -0
- package/tests/goal_map_page.mjs +26 -0
- package/tests/goal_task_relevance.mjs +518 -0
- package/tests/goal_working_area.mjs +146 -0
- package/tests/grade_attempts.mjs +61 -0
- package/tests/grade_attribution.mjs +23 -0
- package/tests/grade_correlation_audit.mjs +54 -1
- package/tests/grader_reader_lens.mjs +55 -2
- package/tests/grader_root_outage.mjs +6 -1
- package/tests/grader_subagent_tools_arg.mjs +102 -0
- package/tests/hall_approval_queue.mjs +54 -2
- package/tests/hall_settings_world.mjs +3 -1
- package/tests/hall_tweak_editor.mjs +316 -11
- package/tests/hub_account_page.mjs +394 -0
- package/tests/idea_detail_page.mjs +64 -0
- package/tests/idea_goal_ratification.mjs +64 -0
- package/tests/idea_spark_hall.mjs +60 -9
- package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
- package/tests/landing_page.mjs +6 -3
- package/tests/module-scope-map.mjs +31 -8
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/no_phantom_mirror_workflow.mjs +24 -0
- package/tests/npm_release_preview_commands.mjs +89 -0
- package/tests/npm_release_preview_divert.mjs +164 -0
- package/tests/npm_release_preview_env.mjs +100 -0
- package/tests/npm_release_preview_proxy.mjs +162 -0
- package/tests/npm_release_preview_routes.mjs +208 -0
- package/tests/npm_release_preview_supervisor.mjs +203 -0
- package/tests/pin_write_key.mjs +383 -0
- package/tests/planning_session_skill.mjs +27 -0
- package/tests/platform_boot.mjs +71 -0
- package/tests/profile_nudge_links.mjs +86 -0
- package/tests/profile_ui_cross_project.mjs +1 -1
- package/tests/projects_hub.mjs +2 -2
- package/tests/projects_hub_app_status.mjs +249 -0
- package/tests/projects_hub_app_step.mjs +282 -52
- package/tests/projects_hub_render_connect.mjs +295 -0
- package/tests/provision_render.mjs +121 -28
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +126 -6
- package/tests/render_check.mjs +2 -1
- package/tests/render_deploy.mjs +495 -0
- package/tests/ship_preflight.mjs +61 -8
- package/tests/smoke_dependencies_witness.mjs +46 -0
- package/tests/speciality_skills.mjs +214 -0
- package/tests/studio_room_height.mjs +70 -0
- package/tests/task_detail_includes.mjs +10 -0
- package/tests/task_visuals_instance_root.mjs +150 -0
- package/tests/tweak_batch_apply.mjs +21 -0
- package/tests/ui_design_kit.mjs +36 -4
- package/tests/upgrade.mjs +1 -1
- package/tests/upgrade_persist_pin.mjs +24 -0
|
@@ -57,8 +57,11 @@ const INDEPENDENT_KAPPA = 0.4;
|
|
|
57
57
|
// A worker's vote on one grade: 1 objects, 0 clears, null no signal.
|
|
58
58
|
function voteOf(w) {
|
|
59
59
|
if (!w || typeof w !== 'object') return null;
|
|
60
|
-
|
|
61
|
-
const
|
|
60
|
+
// A stored attempt keeps severities only (task 1004292); a full grade keeps issues.
|
|
61
|
+
const severities = Array.isArray(w.severities)
|
|
62
|
+
? w.severities
|
|
63
|
+
: (Array.isArray(w.issues) ? w.issues : []).map((i) => i && i.severity);
|
|
64
|
+
const substantive = severities.some((sv) => sv === 'blocker' || sv === 'major');
|
|
62
65
|
if (w.error && !substantive) return null;
|
|
63
66
|
if (w.verdict === 'pass') return 0;
|
|
64
67
|
if (w.verdict === 'concerns' || w.verdict === 'fail') return 1;
|
|
@@ -148,23 +151,47 @@ function classify(kappa) {
|
|
|
148
151
|
// One stored grade → the slim row the audit reads (review prose hashed, as in
|
|
149
152
|
// the replay corpus): { task_id, task_status, passed, workers }, or null when
|
|
150
153
|
// it is not a panel grade.
|
|
154
|
+
//
|
|
155
|
+
// task 1004292: grade.attempts (every round that kept its verdicts, oldest first,
|
|
156
|
+
// served beside the grade) becomes `rounds`; a task with none falls back to its
|
|
157
|
+
// final grade alone, exactly as before.
|
|
151
158
|
function toAuditRow({ task_id, task_status, grade }) {
|
|
159
|
+
const attempts = grade && Array.isArray(grade.attempts) ? grade.attempts : [];
|
|
160
|
+
const rounds = attempts.map((a) => (a && Array.isArray(a.workers) ? a.workers : null)).filter((w) => w && w.length);
|
|
152
161
|
const row = trimRecordedGrade({ task_id, grade });
|
|
153
|
-
|
|
162
|
+
if (!row && rounds.length === 0) return null;
|
|
163
|
+
return {
|
|
164
|
+
task_id: String(task_id),
|
|
165
|
+
task_status: task_status || null,
|
|
166
|
+
passed: !!(grade && grade.passed === true),
|
|
167
|
+
workers: row ? row.workers : rounds[rounds.length - 1],
|
|
168
|
+
...(rounds.length ? { rounds } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The panels one dataset row contributes: every recorded round when there are
|
|
173
|
+
// any (the LAST one is the final grade, the one the owner's decision is about),
|
|
174
|
+
// else the final grade alone.
|
|
175
|
+
function panelsOf(g) {
|
|
176
|
+
const sets = Array.isArray(g.rounds) && g.rounds.length ? g.rounds : [g.workers];
|
|
177
|
+
return sets
|
|
178
|
+
.map((workers, i) => ({ task_id: String(g.task_id), final: i === sets.length - 1, votes: panelVotes(workers) }))
|
|
179
|
+
.filter((p) => Object.keys(p.votes).length > 0);
|
|
154
180
|
}
|
|
155
181
|
|
|
156
182
|
// The whole report, pure over a dataset { grades: [toAuditRow…], overrides: [{task_id, status}] }.
|
|
157
183
|
function audit(dataset, { minN = MIN_N } = {}) {
|
|
158
184
|
const floor = Math.max(MIN_N, Number(minN) || MIN_N);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
// Agreement between workers reads EVERY round; the owner's decision is about
|
|
186
|
+
// the work as it landed, so the owner-facing statistics read final rounds only.
|
|
187
|
+
const panels = (dataset.grades || []).flatMap(panelsOf);
|
|
188
|
+
const finals = panels.filter((p) => p.final);
|
|
162
189
|
const labels = ownerLabels(dataset);
|
|
163
190
|
// 1 when the worker's vote disagrees with the owner (objected to accepted
|
|
164
191
|
// work, or cleared rejected work); only on labelled tasks it voted on.
|
|
165
|
-
const errorOf = (p, kind) => (labels.has(p.task_id) && kind in p.votes
|
|
192
|
+
const errorOf = (p, kind) => (!p.final ? null : (labels.has(p.task_id) && kind in p.votes
|
|
166
193
|
? Number(p.votes[kind] !== (labels.get(p.task_id).label === 'reject' ? 1 : 0))
|
|
167
|
-
: null);
|
|
194
|
+
: null));
|
|
168
195
|
|
|
169
196
|
const pairwise = [];
|
|
170
197
|
for (let i = 0; i < PANEL.length; i++) {
|
|
@@ -186,7 +213,7 @@ function audit(dataset, { minN = MIN_N } = {}) {
|
|
|
186
213
|
const byLabel = { accept: 0, reject: 0 };
|
|
187
214
|
for (const v of labels.values()) byLabel[v.label]++;
|
|
188
215
|
const perWorker = PANEL.map((kind) => {
|
|
189
|
-
const pairs =
|
|
216
|
+
const pairs = finals
|
|
190
217
|
.filter((p) => labels.has(p.task_id) && kind in p.votes)
|
|
191
218
|
.map((p) => [p.votes[kind], labels.get(p.task_id).label === 'reject' ? 1 : 0]);
|
|
192
219
|
return { kind, ...guardedStats(pairs, floor) };
|
|
@@ -195,6 +222,7 @@ function audit(dataset, { minN = MIN_N } = {}) {
|
|
|
195
222
|
return {
|
|
196
223
|
min_n: floor,
|
|
197
224
|
panels: panels.length,
|
|
225
|
+
tasks: finals.length,
|
|
198
226
|
pairwise,
|
|
199
227
|
mean_pairwise_phi: meanPhi,
|
|
200
228
|
effective_votes: meanPhi == null ? null : effectiveVotes(PANEL.length, meanPhi),
|
|
@@ -223,7 +251,7 @@ const statLine = (s) => (s.refused ? `REFUSED — ${s.refused}` : `n=${s.n} agr
|
|
|
223
251
|
|
|
224
252
|
function printReport(r) {
|
|
225
253
|
const out = [];
|
|
226
|
-
out.push(`grade correlation audit — ${r.panels} recorded panel
|
|
254
|
+
out.push(`grade correlation audit — ${r.panels} recorded panel round(s) over ${r.tasks} task(s), minimum n = ${r.min_n}`);
|
|
227
255
|
out.push('A vote is OBJECTS (concerns/fail) or CLEARS (pass); an errored worker with no finding casts none.');
|
|
228
256
|
out.push('');
|
|
229
257
|
out.push('(a) Do the workers fail differently? — agreement between each pair on the same task');
|
|
@@ -247,8 +275,9 @@ function printReport(r) {
|
|
|
247
275
|
out.push('');
|
|
248
276
|
out.push('Caveats: labels exist only where a person decided — override requests and manual confirms of work the');
|
|
249
277
|
out.push('panel did not pass — so they are a selected set, not a sample. A grade that auto-landed on a pass is');
|
|
250
|
-
out.push('never a label (that is the grader agreeing with itself).
|
|
251
|
-
out.push('verdicts (grade_attempts
|
|
278
|
+
out.push('never a label (that is the grader agreeing with itself). Agreement (a) reads every round that kept its');
|
|
279
|
+
out.push('per-worker verdicts (grade_attempts.worker_verdicts); rounds graded before that column existed are lost,');
|
|
280
|
+
out.push('and such a task counts once, as its final grade. Owner comparisons (b) and error correlation read final rounds only.');
|
|
252
281
|
console.log(out.join('\n'));
|
|
253
282
|
}
|
|
254
283
|
|
|
@@ -271,6 +300,7 @@ module.exports = {
|
|
|
271
300
|
effectiveVotes,
|
|
272
301
|
ownerLabels,
|
|
273
302
|
panelVotes,
|
|
303
|
+
panelsOf,
|
|
274
304
|
toAuditRow,
|
|
275
305
|
voteOf,
|
|
276
306
|
};
|
|
@@ -160,7 +160,7 @@ async function fetchRecentGrades({ recent = 150, statuses = ['shipped', 'complet
|
|
|
160
160
|
const out = new Array(picked.length).fill(null);
|
|
161
161
|
for (let i = 0; i < picked.length; i += FETCH_CONCURRENCY) {
|
|
162
162
|
await Promise.all(picked.slice(i, i + FETCH_CONCURRENCY).map(async (t, j) => {
|
|
163
|
-
const r = await api.tasks.getTasksId({ id: t.id, query: { include: 'grade' } });
|
|
163
|
+
const r = await api.tasks.getTasksId({ id: t.id, query: { include: 'grade,grade_attempts' } });
|
|
164
164
|
if (r.ok && r.data && r.data.grade) out[i + j] = { task_id: t.id, task_status: t.status, at: t.at, grade: r.data.grade };
|
|
165
165
|
}));
|
|
166
166
|
}
|
|
@@ -35,6 +35,7 @@ const path = require('path');
|
|
|
35
35
|
const { spawnSync } = require('child_process');
|
|
36
36
|
const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
|
|
37
37
|
const { appUser, dbName, instanceUser, parseTargetRef, resolveOwnerGithubToken, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
|
|
38
|
+
const { ensurePinWriteKey, hasOwnAccount, pinKeyPath, pinSshCommand } = require('./provision-pin-key.js');
|
|
38
39
|
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
39
40
|
const { RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES, parseChangeRecords, notesBetween } = require('./release-notes.js');
|
|
40
41
|
|
|
@@ -329,16 +330,20 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
329
330
|
// on top of them would change what the live box runs with no restart and no health check.
|
|
330
331
|
// That is reported for an operator to reconcile, never resolved here.
|
|
331
332
|
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
333
|
+
// WHAT MAKES IT DURABLE (task 1004291, ADR 0350). The owner's token is kept for an hour
|
|
334
|
+
// after they sign in with repository access (GITHUB_TOKEN_TTL_MS in src/bongos/db.js — one
|
|
335
|
+
// wizard sitting), so on its own it cannot carry an upgrade days later. The hub therefore
|
|
336
|
+
// holds a per-project WRITE deploy key (provision-pin-key.js) and tries it FIRST: write key
|
|
337
|
+
// → owner token (when fresh) → the box's own remote, which can only read a private repo.
|
|
338
|
+
// A fresh token also mints the key when the project has none, so reconnecting GitHub once
|
|
339
|
+
// is enough for every later upgrade. The note says which of these is missing, in words.
|
|
336
340
|
|
|
337
341
|
const REPO_NOTE_PREFIX = 'repo_not_updated';
|
|
338
342
|
const REPO_NOTES = Object.freeze({
|
|
339
343
|
no_repo: 'This project has no GitHub repository on record, so the new version was saved on the server only.',
|
|
340
344
|
no_branch: 'The server\'s copy of the project is not on a branch, so there was nothing to save to GitHub.',
|
|
341
|
-
no_access: 'The hub had no permission to write to the project\'s GitHub repository: the owner\'s GitHub access is only kept for an hour after they connect GitHub, and the server\'s own key can only read the repository.',
|
|
345
|
+
no_access: 'The hub had no permission to write to the project\'s GitHub repository: it holds no write key for it yet, the owner\'s GitHub access is only kept for an hour after they connect GitHub, and the server\'s own key can only read the repository. Reconnecting GitHub with repository access sets up lasting access.',
|
|
346
|
+
needs_account: 'The hub could not set up lasting write access to the project\'s GitHub repository: this project needs its own account on the server first. Once it has one, reconnecting GitHub with repository access sets up lasting access.',
|
|
342
347
|
refused: 'GitHub turned down the owner\'s GitHub access — it may have expired, or lack permission to write to the repository.',
|
|
343
348
|
behind: 'GitHub has changes the server does not have, so the new version could not be added on top of them. An operator needs to bring the two back in line; the site itself is running normally.',
|
|
344
349
|
failed: 'The hub could not save the new version to GitHub.',
|
|
@@ -399,6 +404,9 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
399
404
|
};
|
|
400
405
|
const repo = parseTargetRef(inst.target_ref);
|
|
401
406
|
if (!repo) return miss('no_repo');
|
|
407
|
+
// Exported and callable on its own, and the slug reaches shell strings below (the write
|
|
408
|
+
// key's path), so it is checked here rather than trusting coreUpgradeInstance's own guard.
|
|
409
|
+
if (!isValidSlug(inst.slug)) return miss('failed', '(the project name is not a valid slug)');
|
|
402
410
|
const head = exec(`git -C ${instanceDir} symbolic-ref --short HEAD`, { allowFail: true });
|
|
403
411
|
const branch = failedRun(head) ? '' : String(head.stdout || '').trim();
|
|
404
412
|
if (!BRANCH_RE.test(branch)) return miss('no_branch');
|
|
@@ -407,6 +415,26 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
407
415
|
const env = { GIT_TERMINAL_PROMPT: '0' }; // a refused credential fails; it never waits at a prompt
|
|
408
416
|
|
|
409
417
|
const token = await resolveOwnerGithubToken(inst, deps, { tag: '[core-upgrade]' });
|
|
418
|
+
|
|
419
|
+
// 1. The hub's own WRITE key (ADR 0350). A fresh token mints it when the project has
|
|
420
|
+
// none yet — the reconnect the deploy page asks for — and the mint is best effort: a
|
|
421
|
+
// miss falls through to the token, then to the box's remote, exactly as before.
|
|
422
|
+
const fsImpl = deps.fsImpl || fs;
|
|
423
|
+
const keyPath = pinKeyPath(inst, deps);
|
|
424
|
+
if (token && !fsImpl.existsSync(keyPath)) await ensurePinWriteKey(inst, deps, { token });
|
|
425
|
+
if (fsImpl.existsSync(keyPath)) {
|
|
426
|
+
const r = exec(`${hardened} -c ${pinSshCommand(keyPath)} push git@github.com:${repo.owner}/${repo.name}.git HEAD:${branch}`,
|
|
427
|
+
{ allowFail: true, env });
|
|
428
|
+
if (!failedRun(r)) {
|
|
429
|
+
log(` ✓ pushed the pin to ${at} from the hub, over its write deploy key`);
|
|
430
|
+
return { pushed: true, via: 'write-key', branch };
|
|
431
|
+
}
|
|
432
|
+
const fault = pushFault(r);
|
|
433
|
+
// The key WORKED and the history stopped it: no other credential changes that.
|
|
434
|
+
if (fault === 'behind') return miss('behind', gitLine(r));
|
|
435
|
+
log(` • ${inst.slug}: the write key did not push (${fault}) — trying the next credential`);
|
|
436
|
+
}
|
|
437
|
+
|
|
410
438
|
if (token) {
|
|
411
439
|
const credFile = path.join(os.tmpdir(), `bongos-gitcred-upgrade-${inst.slug}-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
|
|
412
440
|
let r = null;
|
|
@@ -436,7 +464,10 @@ async function pushUpgradePin(inst, deps, { instanceDir }) {
|
|
|
436
464
|
const fault = pushFault(r);
|
|
437
465
|
// A refusal here is not the owner's access being turned down — there was none to turn
|
|
438
466
|
// down. It is the read-only key, which is the no-access case.
|
|
439
|
-
|
|
467
|
+
// ...unless the project has no account of its own, in which case no key CAN be minted
|
|
468
|
+
// for it (ADR 0350) and reconnecting GitHub alone will not help: say that instead.
|
|
469
|
+
if (fault === 'refused') return miss(hasOwnAccount(inst, exec) ? 'no_access' : 'needs_account', gitLine(r));
|
|
470
|
+
return miss(fault, gitLine(r));
|
|
440
471
|
}
|
|
441
472
|
|
|
442
473
|
// The pre-flight lines a dry run emits, and the refusal when it has one. upgrade.js writes
|
|
@@ -990,4 +1021,4 @@ async function snapshotAllCoreVersions(deps) {
|
|
|
990
1021
|
return { seen };
|
|
991
1022
|
}
|
|
992
1023
|
|
|
993
|
-
module.exports = { UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
1024
|
+
module.exports = { pushUpgradePin, UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// provision-pin-key.js — the hub's per-project WRITE deploy key (task 1004291, ADR 0350,
|
|
3
|
+
// amending ADR 0176).
|
|
4
|
+
//
|
|
5
|
+
// WHY. A hosted project's upgrade commits a new core pin in the project's checkout, and
|
|
6
|
+
// the hub pushes it to the owner's GitHub repo (task 1004065, pushUpgradePin). The owner's
|
|
7
|
+
// token that would do that is stored for one hour only, and the checkout's own `origin`
|
|
8
|
+
// is a READ-ONLY deploy key (ADR 0176) — so most pushes ended "GitHub was not updated" and
|
|
9
|
+
// the box and the repo drifted. A second deploy key, registered WRITABLE and held by the
|
|
10
|
+
// runner alone, is the lasting credential.
|
|
11
|
+
//
|
|
12
|
+
// WHERE IT LIVES AND WHO CAN READ IT. Outside every project checkout, in a directory of
|
|
13
|
+
// the RUNNER's own account (mode 0700, key 0600). The project's own unix account
|
|
14
|
+
// (bongos-<slug>, ADR 0281) can read neither. A project still running as the shared app
|
|
15
|
+
// user has the SAME uid as the runner and could read it, so it is REFUSED a key
|
|
16
|
+
// ("needs its own account first") rather than handed one.
|
|
17
|
+
//
|
|
18
|
+
// NEVER THROWS. A failure to mint is a missed convenience, not a broken standup: every
|
|
19
|
+
// path returns `{ skipped }` and logs. The read-only pull key (deployKeyPath in
|
|
20
|
+
// provision-repo.js) is untouched by anything here.
|
|
21
|
+
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { REPO_ROOT } = require('./provision-config.js');
|
|
26
|
+
const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
|
|
27
|
+
const { instanceUser, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
|
|
28
|
+
|
|
29
|
+
const NEEDS_OWN_ACCOUNT = 'needs its own account first';
|
|
30
|
+
|
|
31
|
+
// Both names below reach a shell (ssh-keygen -C/-f, rm -f), so the slug is checked HERE, at
|
|
32
|
+
// the point of use, rather than trusting that some caller validated it first.
|
|
33
|
+
function safeSlug(inst) {
|
|
34
|
+
const slug = inst && inst.slug;
|
|
35
|
+
if (!isValidSlug(slug)) throw new Error(`refusing to build a write-key path from an invalid slug ${JSON.stringify(slug)}`);
|
|
36
|
+
return slug;
|
|
37
|
+
}
|
|
38
|
+
function pinKeyTitle(inst) { return `cloudbongos-pin-${safeSlug(inst)}`; }
|
|
39
|
+
|
|
40
|
+
// The runner-only directory. PROVISION_PIN_KEY_DIR pins it; otherwise it sits under the
|
|
41
|
+
// runner account's own home. Never under standaloneBase, so it can never ride along with
|
|
42
|
+
// (or be reached through) a project checkout.
|
|
43
|
+
function pinKeyDir(deps = {}) {
|
|
44
|
+
const pinned = deps.pinKeyDir || process.env.PROVISION_PIN_KEY_DIR;
|
|
45
|
+
if (pinned) return String(pinned).replace(/\\/g, '/');
|
|
46
|
+
return path.posix.join(os.homedir().replace(/\\/g, '/'), '.config', 'cloudbongos', 'pin-keys');
|
|
47
|
+
}
|
|
48
|
+
function pinKeyPath(inst, deps = {}) { return path.posix.join(pinKeyDir(deps), `${safeSlug(inst)}.key`); }
|
|
49
|
+
|
|
50
|
+
const failed = (r) => !r || r.softFailed === true || r.ok === false;
|
|
51
|
+
|
|
52
|
+
// Does this project have a unix account of its own? Fail CLOSED: only a positive answer
|
|
53
|
+
// counts, because the wrong guess hands a tenant process the key that pushes to the
|
|
54
|
+
// owner's repo.
|
|
55
|
+
function hasOwnAccount(inst, exec) {
|
|
56
|
+
let account;
|
|
57
|
+
try { account = instanceUser(inst); } catch { return false; }
|
|
58
|
+
const r = exec(`id -u ${account}`, { allowFail: true });
|
|
59
|
+
return !failed(r) && /^\d+$/.test(String((r && r.stdout) || '').trim());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create the directory (0700) and mint the key (0600), then PROVE the modes: a key that
|
|
63
|
+
// GitHub trusts to write must never be group- or world-readable.
|
|
64
|
+
function mintLocal(inst, exec, deps) {
|
|
65
|
+
const dir = pinKeyDir(deps), keyPath = pinKeyPath(inst, deps), title = pinKeyTitle(inst);
|
|
66
|
+
exec(`(umask 077 && mkdir -p ${dir}) && chmod 700 ${dir}`);
|
|
67
|
+
exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
|
|
68
|
+
exec(`(umask 077 && ssh-keygen -q -t ed25519 -N '' -C ${title} -f ${keyPath})`);
|
|
69
|
+
exec(`chmod 600 ${keyPath}`);
|
|
70
|
+
const modes = exec(`stat -c '%a' ${dir} ${keyPath}`, { allowFail: true });
|
|
71
|
+
const got = String((modes && modes.stdout) || '').trim().split(/\s+/);
|
|
72
|
+
if (failed(modes) || got[0] !== '700' || got[1] !== '600') {
|
|
73
|
+
exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
|
|
74
|
+
throw new Error(`the write key's permissions could not be confirmed as 700/600 (saw ${got.join('/') || 'nothing'})`);
|
|
75
|
+
}
|
|
76
|
+
return keyPath;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const keyBody = (k) => String(k || '').trim().split(/\s+/).slice(0, 2).join(' ');
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Make sure a per-project WRITE deploy key exists locally and is registered on the repo
|
|
83
|
+
* with `read_only: false`. `opts.token` is an owner token already in hand. Idempotent
|
|
84
|
+
* (by title; a stale or read-only same-title registration is replaced). NEVER THROWS.
|
|
85
|
+
* Returns `{ minted, keyPath }` or `{ skipped: <why>, note }`.
|
|
86
|
+
*/
|
|
87
|
+
async function ensurePinWriteKey(inst, deps, opts = {}) {
|
|
88
|
+
const log = deps.log || (() => {});
|
|
89
|
+
const exec = deps.controlExec || deps.exec;
|
|
90
|
+
if (!inst || inst.hosting_shape !== 'cloud-host') return { skipped: 'shape' };
|
|
91
|
+
if (!isValidSlug(inst.slug)) return { skipped: 'invalid_slug' };
|
|
92
|
+
const repo = parseTargetRef(inst.target_ref);
|
|
93
|
+
if (!repo) return { skipped: 'no_repo' };
|
|
94
|
+
if (deps.apply === false) {
|
|
95
|
+
log(` [pin-key] would mint a writable deploy key "${pinKeyTitle(inst)}" for ${repo.owner}/${repo.name}, held by the runner outside the checkout (ADR 0350)`);
|
|
96
|
+
return { planned: true };
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
if (!hasOwnAccount(inst, exec)) {
|
|
100
|
+
log(` [pin-key] ${inst.slug} ${NEEDS_OWN_ACCOUNT} — no write key minted (it runs as the runner's own user and could read it; ADR 0350)`);
|
|
101
|
+
return { skipped: 'needs_own_account', note: `This project ${NEEDS_OWN_ACCOUNT}.` };
|
|
102
|
+
}
|
|
103
|
+
const token = opts.token || await resolveOwnerGithubToken(inst, deps, { tag: '[pin-key]' });
|
|
104
|
+
if (!token) return { skipped: 'no_token' };
|
|
105
|
+
const gh = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos')); // eslint-disable-line global-require
|
|
106
|
+
const info = await gh.getUserRepo(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
107
|
+
if (info && info.admin === false) {
|
|
108
|
+
log(` [pin-key] the owner's credential is not admin on ${repo.owner}/${repo.name}; a deploy key needs admin — no write key minted`);
|
|
109
|
+
return { skipped: 'not_admin' };
|
|
110
|
+
}
|
|
111
|
+
const fsImpl = deps.fsImpl || fs;
|
|
112
|
+
const title = pinKeyTitle(inst), keyPath = pinKeyPath(inst, deps);
|
|
113
|
+
const target = { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl };
|
|
114
|
+
if (!fsImpl.existsSync(keyPath) || !fsImpl.existsSync(`${keyPath}.pub`)) mintLocal(inst, exec, deps);
|
|
115
|
+
let pub = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
|
|
116
|
+
const ours = (await gh.listDeployKeys(token, target)).find((k) => k.title === title);
|
|
117
|
+
if (ours && keyBody(ours.key) === keyBody(pub) && ours.read_only === false) {
|
|
118
|
+
return { minted: false, keyPath };
|
|
119
|
+
}
|
|
120
|
+
if (ours) await gh.deleteDeployKey(token, { ...target, keyId: ours.id });
|
|
121
|
+
const add = () => gh.addDeployKey(token, { ...target, title, key: pub, readOnly: false });
|
|
122
|
+
let added;
|
|
123
|
+
try { added = await add(); } catch (e) {
|
|
124
|
+
if (!e || e.code !== 'key_in_use') throw e;
|
|
125
|
+
mintLocal(inst, exec, deps);
|
|
126
|
+
pub = String(fsImpl.readFileSync(`${keyPath}.pub`, 'utf8')).trim();
|
|
127
|
+
added = await add();
|
|
128
|
+
}
|
|
129
|
+
if (added && added.read_only === true) {
|
|
130
|
+
// GitHub registered it read-only anyway: it would never write, and a key that looks
|
|
131
|
+
// like a write key but is not is worse than none.
|
|
132
|
+
await gh.deleteDeployKey(token, { ...target, keyId: added.id }).catch(() => {});
|
|
133
|
+
return { skipped: 'registered_read_only' };
|
|
134
|
+
}
|
|
135
|
+
log(` [pin-key] registered writable deploy key "${title}" on ${repo.owner}/${repo.name}`);
|
|
136
|
+
return { minted: true, keyPath };
|
|
137
|
+
} catch (e) {
|
|
138
|
+
log(` [pin-key] write key not set up for ${inst.slug}: ${(e && e.message) || e}`);
|
|
139
|
+
return { skipped: 'failed', note: String((e && e.message) || e).slice(0, 160) };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The `-c` that points ONE git push at the write key. IdentitiesOnly + -F /dev/null so no
|
|
144
|
+
// agent or ~/.ssh/config identity can answer instead. PURE.
|
|
145
|
+
function pinSshCommand(keyPath) {
|
|
146
|
+
return `core.sshCommand="ssh -F /dev/null -i ${keyPath} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Remove the write key: the registration (when a token is at hand) and the local files.
|
|
151
|
+
* Best effort, never throws. `keys` is an already-fetched deploy-key list, to save a call.
|
|
152
|
+
*/
|
|
153
|
+
async function revokePinWriteKey(inst, deps, { token = null, repo = null, keys = null } = {}) {
|
|
154
|
+
const log = deps.log || (() => {});
|
|
155
|
+
const exec = deps.controlExec || deps.exec;
|
|
156
|
+
const title = pinKeyTitle(inst), keyPath = pinKeyPath(inst, deps);
|
|
157
|
+
if (token && repo) {
|
|
158
|
+
try {
|
|
159
|
+
const gh = deps.githubRepos || require(path.join(REPO_ROOT, 'src/bongos/github-repos')); // eslint-disable-line global-require
|
|
160
|
+
const list = keys || await gh.listDeployKeys(token, { owner: repo.owner, name: repo.name, fetchImpl: deps.fetchImpl });
|
|
161
|
+
const ours = list.find((k) => k.title === title);
|
|
162
|
+
if (ours) {
|
|
163
|
+
if (deps.apply === false) log(` would revoke the "${title}" deploy key on ${repo.owner}/${repo.name} if present`);
|
|
164
|
+
else {
|
|
165
|
+
await gh.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
|
|
166
|
+
log(` revoked the "${title}" write deploy key on ${repo.owner}/${repo.name}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (e) { log(` (write-key revoke skipped: ${(e && e.message) || e})`); }
|
|
170
|
+
} else if (repo) {
|
|
171
|
+
log(` (write-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${title}" deploy key, remove it in the repo's settings)`);
|
|
172
|
+
}
|
|
173
|
+
if (deps.apply !== false) exec(`rm -f ${keyPath} ${keyPath}.pub`, { allowFail: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
module.exports = { NEEDS_OWN_ACCOUNT, hasOwnAccount, pinKeyTitle, pinKeyDir, pinKeyPath, ensurePinWriteKey, revokePinWriteKey, pinSshCommand };
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// provision-render.js — the Render standup leg: the runner half of the `render-standup`
|
|
3
|
-
// intent (task 1004183; ADR 0327, ADR 0345).
|
|
3
|
+
// intent (task 1004183; ADR 0327, ADR 0345). What it stands up is the project's APP, from
|
|
4
|
+
// the project's own repository — never the Bongos hall, which we host (task 1004350). Its own file because provision.js sits at
|
|
4
5
|
// the size ratchet; the dispatch there requires it inline.
|
|
5
6
|
//
|
|
6
7
|
// WHAT IT DOES. Stands a project up on its OWNER's Render account, with a key the owner
|
|
7
8
|
// lent for this one run:
|
|
8
|
-
// - ADOPT links a Render service the owner already runs. It reads
|
|
9
|
-
// the workspace guard
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// - ADOPT links a Render service the owner already runs for this repository. It reads
|
|
10
|
+
// that service through the workspace guard, checks it builds this project's
|
|
11
|
+
// repository, and records it. It makes NO write call to their account.
|
|
12
|
+
// - GREENFIELD creates the Postgres when the owner asked for one, then the web service
|
|
13
|
+
// (render-payload.js decides what they are). Render deploys a new service on its own.
|
|
12
14
|
// Ids are written to the row as each resource is created, so an owner who re-enters their
|
|
13
15
|
// key after a failure resumes onto what already exists instead of creating a second one.
|
|
14
16
|
//
|
|
@@ -32,7 +34,6 @@
|
|
|
32
34
|
const { RenderApi } = require('./render-api.js');
|
|
33
35
|
const payload = require('./render-payload.js');
|
|
34
36
|
const { parseTargetRef } = require('./provision-repo-auth.js');
|
|
35
|
-
const { provisionedEnvPrefix } = require('./provision-units.js');
|
|
36
37
|
|
|
37
38
|
// A key waiting longer than this was lent for a run that never came. It is refused and
|
|
38
39
|
// cleared rather than spent late.
|
|
@@ -56,15 +57,20 @@ async function clearBorrowedKey(db, intentId) {
|
|
|
56
57
|
await db.query(`UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = $1`, [intentId]);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Whether a Render service's repository is this project's. Render reports it as a URL;
|
|
61
|
+
// compared as owner/name, case-insensitively, the way GitHub resolves them.
|
|
62
|
+
function sameRepo(renderRepo, targetRef) {
|
|
63
|
+
const a = parseTargetRef(renderRepo), b = parseTargetRef(targetRef);
|
|
64
|
+
return !!(a && b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase());
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
async function adopt(api, inst, host, { db, log }) {
|
|
66
68
|
if (!host.serviceId) throw new Error('adopting needs the id of the Render service to link');
|
|
67
69
|
const svc = await api.getService(host.serviceId);
|
|
70
|
+
// Linking someone's other service to this project would show its URL as this app's.
|
|
71
|
+
if (!sameRepo(svc.repo, inst.target_ref)) {
|
|
72
|
+
throw new Error(`Render service ${svc.id} builds ${svc.repo || 'no repository'}, not ${inst.target_ref}`);
|
|
73
|
+
}
|
|
68
74
|
const url = (svc.serviceDetails && svc.serviceDetails.url) || null;
|
|
69
75
|
await recordAppHost(db, inst.id, { state: 'linked', serviceUrl: url, error: null });
|
|
70
76
|
log(` [render] linked ${inst.slug} to service ${svc.id}${url ? ` (${url})` : ''} — nothing created or changed on Render`);
|
|
@@ -74,39 +80,38 @@ async function adopt(api, inst, host, { db, log }) {
|
|
|
74
80
|
async function greenfield(api, inst, host, deps) {
|
|
75
81
|
const { db, log } = deps;
|
|
76
82
|
const plans = payload.plansFor(host);
|
|
83
|
+
const build = payload.buildFor(host);
|
|
77
84
|
const repo = parseTargetRef(inst.target_ref);
|
|
78
85
|
if (!repo) throw new Error('the project has no GitHub repository for Render to build from');
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
let
|
|
83
|
-
if (
|
|
84
|
-
pg =
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
|
|
87
|
+
// 1. The database, only when the owner asked for one — reused if a previous run got
|
|
88
|
+
// this far.
|
|
89
|
+
let dbUrl = null;
|
|
90
|
+
if (host.database === true) {
|
|
91
|
+
let pg = host.postgresId ? await api.getPostgres(host.postgresId) : null;
|
|
92
|
+
if (!pg) {
|
|
93
|
+
pg = await api.createPostgres(payload.renderPostgresPayload(inst, { ownerId: api.ownerId, dbPlan: plans.dbPlan, region: plans.region }));
|
|
94
|
+
await recordAppHost(db, inst.id, { postgresId: pg.id, dbPlan: plans.dbPlan, state: 'creating' });
|
|
95
|
+
log(` [render] created database ${pg.id} (${plans.dbPlan})`);
|
|
96
|
+
}
|
|
97
|
+
await api.settlePostgres(pg.id, { sleepFn: deps.sleepFn });
|
|
98
|
+
const conn = await api.postgresConnectionInfo(pg.id);
|
|
99
|
+
dbUrl = conn && conn.internalConnectionString;
|
|
100
|
+
if (!dbUrl) throw new Error(`Render gave database ${pg.id} no internal connection string`);
|
|
87
101
|
}
|
|
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
102
|
|
|
93
|
-
// 2. The web service — reused likewise.
|
|
103
|
+
// 2. The app's web service — reused likewise. Render starts its first deploy itself.
|
|
94
104
|
let svc = host.serviceId ? await api.getService(host.serviceId) : null;
|
|
95
105
|
if (!svc) {
|
|
96
|
-
const firstAdmin = (await ownerLoginOf(db, inst)) || repo.owner;
|
|
97
106
|
const created = await api.createService(payload.renderServicePayload(inst, {
|
|
98
107
|
ownerId: api.ownerId, repoUrl: `https://github.com/${repo.owner}/${repo.name}`, branch: host.branch,
|
|
99
|
-
webPlan: plans.webPlan, region: plans.region,
|
|
108
|
+
webPlan: plans.webPlan, region: plans.region, build, dbUrl,
|
|
100
109
|
}));
|
|
101
110
|
svc = created.service;
|
|
102
111
|
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})`);
|
|
112
|
+
log(` [render] created web service ${svc.id} (${plans.webPlan}, ${build.runtime})`);
|
|
104
113
|
}
|
|
105
114
|
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
115
|
await recordAppHost(db, inst.id, { state: 'deploying', serviceUrl: url, error: null });
|
|
111
116
|
log(` [render] ${inst.slug} is deploying at ${url}`);
|
|
112
117
|
return { ok: true };
|
|
@@ -144,6 +149,32 @@ async function renderStandupInstance(inst, deps, intent) {
|
|
|
144
149
|
}
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
// The wizard's path (task 1004352): Create queues the project's `provision` run, and the
|
|
153
|
+
// key the owner lent arrives on THAT run a moment later (render-standup.js
|
|
154
|
+
// attachToProvision). So after the project is set up, this runs the Render leg in the same
|
|
155
|
+
// drain. The key is re-read from the row, because it may have been attached after this
|
|
156
|
+
// run was claimed. Whatever happens the key is gone when this returns:
|
|
157
|
+
// - project up → the Render leg spends and clears it. Its failure is the APP's, recorded
|
|
158
|
+
// on app_host; the project itself is still up, so the provision run stays a success;
|
|
159
|
+
// - project not up → the key is cleared now and the app marked failed, so a provision
|
|
160
|
+
// retry never carries a key into a later run. The owner connects Render again once the
|
|
161
|
+
// project is up.
|
|
162
|
+
async function afterProvision(inst, deps, intent, result) {
|
|
163
|
+
const { db } = deps;
|
|
164
|
+
const { rows } = await db.query(`SELECT borrowed_key_enc FROM provisioning_intents WHERE id = $1`, [intent.id]);
|
|
165
|
+
const keyEnc = rows[0] && rows[0].borrowed_key_enc;
|
|
166
|
+
if (!keyEnc) return result;
|
|
167
|
+
if (!result || result.ok === false) {
|
|
168
|
+
await clearBorrowedKey(db, intent.id);
|
|
169
|
+
await recordAppHost(db, inst.id, { state: 'failed', error: 'the project could not be set up, so its app was not set up on Render — connect Render again once the project is live' }).catch(() => {});
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
const fresh = (await deps.provisioning.getInstanceById(db, inst.id)) || inst;
|
|
173
|
+
const app = await renderStandupInstance(fresh, deps, { ...intent, borrowed_key_enc: keyEnc });
|
|
174
|
+
if (app.ok) return result;
|
|
175
|
+
return { ...result, doneNote: `project is up, but its app was not set up on Render: ${app.error}` };
|
|
176
|
+
}
|
|
177
|
+
|
|
147
178
|
// Teardown's half (ADR 0327 §3): forget the Render app, never delete it. The ids stay on
|
|
148
179
|
// the row so an operator can still say which services were the project's; the owner
|
|
149
180
|
// deletes them in their own Render dashboard if they want to stop paying.
|
|
@@ -155,4 +186,4 @@ async function forgetRenderApp(inst, { db, log, apply }) {
|
|
|
155
186
|
if (apply) await recordAppHost(db, inst.id, { state: 'disconnected' });
|
|
156
187
|
}
|
|
157
188
|
|
|
158
|
-
module.exports = { renderStandupInstance, forgetRenderApp, clearBorrowedKey, recordAppHost, KEY_TTL_MS };
|
|
189
|
+
module.exports = { renderStandupInstance, afterProvision, forgetRenderApp, clearBorrowedKey, recordAppHost, KEY_TTL_MS };
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
const { CONFIG, REPO_ROOT } = require('./provision-config.js');
|
|
14
14
|
const { dbName, deployKeyPath, deployKeyTitle, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo.js');
|
|
15
|
+
const { pinKeyPath, pinKeyTitle, revokePinWriteKey } = require('./provision-pin-key.js');
|
|
15
16
|
const { resolveProcessManager } = require('./provision-process.js');
|
|
16
17
|
const { caddySnippetPath, dnsReleaseEnv } = require('./provision-net.js');
|
|
17
18
|
const apexMod = require('./instance-apex.js');
|
|
@@ -63,12 +64,16 @@ async function teardownInstance(inst, deps) {
|
|
|
63
64
|
await ghRepos.deleteDeployKey(token, { owner: repo.owner, name: repo.name, keyId: ours.id, fetchImpl: deps.fetchImpl });
|
|
64
65
|
log(` revoked the "${deployKeyTitle(inst)}" deploy key on ${repo.owner}/${repo.name}`);
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
+
// The hub's WRITE key (ADR 0350) — the more dangerous of the two to leave behind.
|
|
68
|
+
await revokePinWriteKey(inst, { ...deps, controlExec: boxExec, apply: true, githubRepos: ghRepos }, { token, repo, keys });
|
|
69
|
+
} else log(` would revoke the "${deployKeyTitle(inst)}" and write deploy keys on ${repo.owner}/${repo.name} if present`);
|
|
67
70
|
} else {
|
|
68
|
-
log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
|
|
71
|
+
log(` (deploy-key revoke skipped: no usable owner credential — if ${repo.owner}/${repo.name} carries a "${deployKeyTitle(inst)}" or "${pinKeyTitle(inst)}" deploy key, remove it in the repo's settings)`);
|
|
69
72
|
}
|
|
70
73
|
} catch (e) { log(` (deploy-key revoke skipped: ${e && e.message})`); }
|
|
71
74
|
boxExec(`rm -f ${deployKeyPath(inst)} ${deployKeyPath(inst)}.pub`, { allowFail: true });
|
|
75
|
+
// The write key's local half goes whatever happened above (lookup failed, no token).
|
|
76
|
+
boxExec(`rm -f ${pinKeyPath(inst, deps)} ${pinKeyPath(inst, deps)}.pub`, { allowFail: true });
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
if (apply) {
|
|
@@ -390,6 +390,36 @@ function upsertEnvVars(body, vars) {
|
|
|
390
390
|
return text;
|
|
391
391
|
}
|
|
392
392
|
|
|
393
|
+
// Env names a rename retired, carried forward ON THE BOX by the settings push (task
|
|
394
|
+
// 1004395). The prelaunch-gate rename (task 1003152, ADR 0182) made PRELAUNCH_PASSWORD
|
|
395
|
+
// primary and kept STEALTH_PASSWORD as an alias; the alias can only be dropped (task
|
|
396
|
+
// 1003830) once every live web.env carries the new name, and before this the only way
|
|
397
|
+
// to add it was the owner's SSH (blocker 1000147). [from, to] pairs.
|
|
398
|
+
const ENV_RENAMES = Object.freeze([['STEALTH_PASSWORD', 'PRELAUNCH_PASSWORD']]);
|
|
399
|
+
|
|
400
|
+
// Which renamed names does this web.env still hold ONLY under the old name? Returns
|
|
401
|
+
// { vars, carried }: `vars` is the new name → the old line's right-hand side, copied
|
|
402
|
+
// verbatim (quotes and all, so systemd reads the same value), for upsertEnvVars;
|
|
403
|
+
// `carried` names the pairs and is the ONLY part that may reach a log or an event —
|
|
404
|
+
// the value is a secret read on the box and it never leaves it. A new name that is
|
|
405
|
+
// already set wins, even when it differs. The last line of a key counts, as systemd
|
|
406
|
+
// reads it. PURE.
|
|
407
|
+
function renamedEnvCarry(body) {
|
|
408
|
+
const lines = String(body == null ? '' : body).split(/\r?\n/);
|
|
409
|
+
const rhs = (key) => {
|
|
410
|
+
let v = null;
|
|
411
|
+
for (const l of lines) if (l.startsWith(`${key}=`)) v = l.slice(key.length + 1);
|
|
412
|
+
return v;
|
|
413
|
+
};
|
|
414
|
+
const isSet = (v) => v !== null && !['', '""', "''"].includes(v.trim());
|
|
415
|
+
const vars = {}, carried = [];
|
|
416
|
+
for (const [from, to] of ENV_RENAMES) {
|
|
417
|
+
const oldV = rhs(from);
|
|
418
|
+
if (isSet(oldV) && !isSet(rhs(to))) { vars[to] = oldV; carried.push(`${from} -> ${to}`); }
|
|
419
|
+
}
|
|
420
|
+
return { vars, carried };
|
|
421
|
+
}
|
|
422
|
+
|
|
393
423
|
// ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
|
|
394
424
|
// (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
|
|
395
425
|
// registration is a direct in-process call against the hub pool — no HTTP hop.
|
|
@@ -415,6 +445,7 @@ module.exports = {
|
|
|
415
445
|
serviceUnitPath,
|
|
416
446
|
settingsConsumed,
|
|
417
447
|
settingsEnvVarsFor,
|
|
448
|
+
renamedEnvCarry,
|
|
418
449
|
upsertEnvVars,
|
|
419
450
|
webEnvBody,
|
|
420
451
|
webEnvPath,
|