@bongos/core 1.19.1080 → 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 +573 -188
- 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 +34 -0
- package/clients/bongos-client/index.cjs +34 -0
- package/clients/bongos-client/index.d.ts +53 -4
- package/clients/bongos-client/index.mjs +34 -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/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/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 +4 -0
- package/docs/api/openapi.json +995 -40
- package/docs/api-reference.md +33 -8
- 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 +8 -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/migrations/provisioning_028_render_standup.sql +46 -0
- package/modules/provisioning/module.json +4 -2
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/provisioning.js +5 -5
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +134 -0
- package/modules/provisioning/routes/render-standup.js +165 -0
- 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 +173 -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 +189 -0
- package/scripts/gds/provision-teardown.js +9 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +12 -12
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +197 -0
- package/scripts/gds/render-payload.js +84 -0
- 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 +362 -0
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +336 -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/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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/preview/supervisor.js — starts, watches and stops the ONE version
|
|
4
|
+
// preview this hall may run (task 1004300, ADR 0349).
|
|
5
|
+
//
|
|
6
|
+
// A preview is a second Bongos process, running an installed copy of an older core against
|
|
7
|
+
// a COPY of the live database, reached through the live hall's own address (divert.js). This
|
|
8
|
+
// file owns its lifecycle and nothing else: install → copy the database → migrate → launch →
|
|
9
|
+
// wait until it answers. Every outward step goes through an injected function (run for a
|
|
10
|
+
// command, spawn for the long-lived child, probe for the health check, sleep, now), which is
|
|
11
|
+
// what lets the whole lifecycle be tested without a process, a database or a network.
|
|
12
|
+
//
|
|
13
|
+
// ONE AT A TIME, STOPPED WHEN IDLE. A second preview is refused, not queued; a running one
|
|
14
|
+
// that nothing has touched for IDLE_MS is stopped and its copy of the database dropped. That
|
|
15
|
+
// bound is what keeps a preview from becoming a permanent second tenant of the droplet.
|
|
16
|
+
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { buildPreviewEnv } = require('./env');
|
|
21
|
+
const cmds = require('./commands');
|
|
22
|
+
|
|
23
|
+
const IDLE_MS = 20 * 60 * 1000;
|
|
24
|
+
const DEFAULT_PORT = 3190;
|
|
25
|
+
const HEALTH_TRIES = 40;
|
|
26
|
+
const HEALTH_INTERVAL_MS = 1500;
|
|
27
|
+
|
|
28
|
+
// How far behind the running version a preview may reach, counted in PUBLISHED versions.
|
|
29
|
+
const WINDOW = 30;
|
|
30
|
+
|
|
31
|
+
function compare(a, b) {
|
|
32
|
+
const x = a.split('.').map(Number);
|
|
33
|
+
const y = b.split('.').map(Number);
|
|
34
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Is this version one a preview may open? Published versions only, and no older than
|
|
40
|
+
* `window` published versions before the one this hall runs. Newer than the running
|
|
41
|
+
* version is fine — it is how a release is tried before it is adopted.
|
|
42
|
+
*
|
|
43
|
+
* @returns {{ok:true}|{ok:false, reason:string, oldest?:string}} oldest names the earliest version still allowed
|
|
44
|
+
*/
|
|
45
|
+
function previewable(version, { published, running, window = WINDOW } = {}) {
|
|
46
|
+
if (!cmds.VERSION_RE.test(String(version))) return { ok: false, reason: 'not_a_version' };
|
|
47
|
+
const list = Object.keys(published || {}).filter((v) => cmds.VERSION_RE.test(v)).sort(compare);
|
|
48
|
+
if (!list.includes(version)) return { ok: false, reason: 'not_published' };
|
|
49
|
+
const at = running && list.includes(running) ? list.indexOf(running) : list.length - 1;
|
|
50
|
+
const floor = Math.max(0, at - window);
|
|
51
|
+
if (list.indexOf(version) < floor) return { ok: false, reason: 'too_old', oldest: list[floor] };
|
|
52
|
+
return { ok: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} o
|
|
57
|
+
* @param {Function} o.run (command) => Promise of { code, stderr } — runs one command to the end
|
|
58
|
+
* @param {Function} o.spawn node:child_process spawn (the long-lived child)
|
|
59
|
+
* @param {Function} o.probe (port) => Promise of boolean — does the preview answer its health check
|
|
60
|
+
* @param {string} o.liveDb the live database name (the source of the copy)
|
|
61
|
+
* @param {string} o.cacheRoot where versions are installed
|
|
62
|
+
* @param {object} [o.env] the live environment (read by buildPreviewEnv only)
|
|
63
|
+
*/
|
|
64
|
+
function createSupervisor({
|
|
65
|
+
run, spawn, probe, liveDb, cacheRoot,
|
|
66
|
+
env = process.env, port = DEFAULT_PORT,
|
|
67
|
+
now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
68
|
+
fsImpl = fs, tmpdir = os.tmpdir(), idleMs = IDLE_MS,
|
|
69
|
+
log = { info() {}, warn() {}, error() {} },
|
|
70
|
+
} = {}) {
|
|
71
|
+
let state = { state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null };
|
|
72
|
+
let child = null;
|
|
73
|
+
let workDir = null;
|
|
74
|
+
|
|
75
|
+
const set = (patch) => { state = { ...state, ...patch }; };
|
|
76
|
+
const status = () => ({ ...state, port, idle_ms: idleMs });
|
|
77
|
+
|
|
78
|
+
async function step(stage, command) {
|
|
79
|
+
set({ stage });
|
|
80
|
+
const r = await run(command);
|
|
81
|
+
if (!r || r.code !== 0) {
|
|
82
|
+
const tail = r && r.stderr ? `: ${String(r.stderr).trim().slice(-300)}` : '';
|
|
83
|
+
throw new Error(`${stage} failed (exit ${r ? r.code : '?'})${tail}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Everything a start or a stop leaves behind: the child, the copy, the scratch directory.
|
|
88
|
+
async function cleanup() {
|
|
89
|
+
if (child) { try { child.kill(); } catch (e) { log.warn(`preview child would not stop: ${e && e.message}`); } child = null; }
|
|
90
|
+
try { await run(cmds.dropDbCommand()); } catch (e) { log.warn(`preview copy could not be dropped: ${e && e.message}`); }
|
|
91
|
+
if (workDir) { try { fsImpl.rmSync(workDir, { recursive: true, force: true }); } catch { /* best effort */ } workDir = null; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function launch(version) {
|
|
95
|
+
try {
|
|
96
|
+
workDir = fsImpl.mkdtempSync(path.join(tmpdir, 'bongos-preview-'));
|
|
97
|
+
const home = path.join(workDir, 'home');
|
|
98
|
+
fsImpl.mkdirSync(home, { recursive: true });
|
|
99
|
+
const dumpFile = path.join(workDir, 'live.dump');
|
|
100
|
+
const childEnv = buildPreviewEnv({ env, port, scratchHome: home });
|
|
101
|
+
|
|
102
|
+
await step('install', { ...cmds.installCommand({ cacheRoot, version }), env: childEnv });
|
|
103
|
+
await step('drop-old-copy', cmds.dropDbCommand());
|
|
104
|
+
await step('create-copy', cmds.createDbCommand());
|
|
105
|
+
await step('dump', cmds.dumpCommand({ liveDb, dumpFile }));
|
|
106
|
+
await step('restore', cmds.restoreCommand({ dumpFile }));
|
|
107
|
+
await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv }));
|
|
108
|
+
|
|
109
|
+
set({ stage: 'launch' });
|
|
110
|
+
child = spawn(process.execPath, [cmds.entryPath(cacheRoot, version)], {
|
|
111
|
+
cwd: cmds.versionDir(cacheRoot, version), env: childEnv, stdio: 'ignore',
|
|
112
|
+
});
|
|
113
|
+
child.on('exit', (code) => {
|
|
114
|
+
// A child that dies on its own must not leave a copy behind or a page waiting on it.
|
|
115
|
+
if (child && state.state === 'running') {
|
|
116
|
+
log.warn(`preview of ${version} exited (${code})`);
|
|
117
|
+
child = null;
|
|
118
|
+
set({ state: 'failed', stage: 'exited', error: `the preview process exited (${code})` });
|
|
119
|
+
cleanup();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
set({ stage: 'health' });
|
|
124
|
+
let up = false;
|
|
125
|
+
for (let i = 0; i < HEALTH_TRIES && !up; i++) {
|
|
126
|
+
up = await probe(port);
|
|
127
|
+
if (!up) await sleep(HEALTH_INTERVAL_MS);
|
|
128
|
+
}
|
|
129
|
+
if (!up) throw new Error('the preview did not answer in time');
|
|
130
|
+
set({ state: 'running', stage: null, startedAt: now(), lastTouch: now() });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
log.error(`preview of ${version} failed: ${e && e.message}`);
|
|
133
|
+
set({ state: 'failed', error: e && e.message ? e.message : String(e) });
|
|
134
|
+
await cleanup();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Begin previewing a version. Returns straight away with ok, status and done: the long
|
|
140
|
+
* steps run behind it (done settles when they finish, for tests), and callers follow
|
|
141
|
+
* along with status().
|
|
142
|
+
*/
|
|
143
|
+
function start(version) {
|
|
144
|
+
if (!cmds.VERSION_RE.test(String(version))) return { ok: false, code: 'not_a_version', status: status() };
|
|
145
|
+
if (state.state === 'starting' || state.state === 'running') {
|
|
146
|
+
if (state.version === version) return { ok: true, already: true, status: status(), done: Promise.resolve() };
|
|
147
|
+
return { ok: false, code: 'preview_busy', status: status() };
|
|
148
|
+
}
|
|
149
|
+
set({ state: 'starting', version, stage: 'queued', error: null, startedAt: null, lastTouch: now() });
|
|
150
|
+
return { ok: true, status: status(), done: launch(version) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Stop the preview and drop its copy. Safe to call at any point. */
|
|
154
|
+
async function stop() {
|
|
155
|
+
await cleanup();
|
|
156
|
+
set({ state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null });
|
|
157
|
+
return status();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Note that someone used the preview: every forwarded request calls this. */
|
|
161
|
+
function touch() { if (state.state === 'running') set({ lastTouch: now() }); }
|
|
162
|
+
|
|
163
|
+
/** Stop a running preview nobody has touched for the idle window. */
|
|
164
|
+
async function reapIdle() {
|
|
165
|
+
if (state.state !== 'running') return false;
|
|
166
|
+
if (now() - state.lastTouch < idleMs) return false;
|
|
167
|
+
log.info(`preview of ${state.version} idle, stopping`);
|
|
168
|
+
await stop();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { start, stop, status, touch, reapIdle };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { createSupervisor, previewable, IDLE_MS, DEFAULT_PORT, WINDOW };
|
|
@@ -166,11 +166,69 @@
|
|
|
166
166
|
<span class="ov-group__name ov-key">${escapeHtml(v.version)}</span>
|
|
167
167
|
<span class="ov-group__n">${v.entries ? v.entries.length : '?'}</span>
|
|
168
168
|
<span class="ov-group__note">${escapeHtml(note)}</span>
|
|
169
|
+
<button type="button" class="ov-toggle" data-preview="${escapeHtml(v.version)}"${preview && preview.state !== 'idle' && preview.state !== 'failed' ? ' disabled' : ''}>View</button>
|
|
169
170
|
</div>
|
|
170
171
|
${body}
|
|
171
172
|
</div>`;
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
// ---- previewing a version (task 1004300, ADR 0349) -----------------------------------
|
|
176
|
+
// The server owns the state (idle, starting, running, failed); this only shows it and
|
|
177
|
+
// asks for changes. One preview at a time, so one panel above the version list.
|
|
178
|
+
let preview = null;
|
|
179
|
+
let previewErr = '';
|
|
180
|
+
let pollTimer = null;
|
|
181
|
+
const PREVIEW_STEP = {
|
|
182
|
+
queued: 'getting ready', install: 'installing that version', 'drop-old-copy': 'clearing the old copy',
|
|
183
|
+
'create-copy': 'making a copy of the data', dump: 'copying the live data', restore: 'loading the copy',
|
|
184
|
+
migrate: 'running its migrations on the copy', launch: 'starting it', health: 'waiting for it to answer',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
function previewHtml() {
|
|
188
|
+
if (previewErr) return `<div class="ov-note ov-note--warn"><p>${escapeHtml(previewErr)}</p></div>`;
|
|
189
|
+
if (!preview || preview.state === 'idle') return '';
|
|
190
|
+
const v = escapeHtml(preview.version || '');
|
|
191
|
+
if (preview.state === 'starting') {
|
|
192
|
+
return `<div class="ov-note"><p><strong>Preparing a preview of ${v}</strong> — ${escapeHtml(PREVIEW_STEP[preview.stage] || 'working')}. This can take a few minutes.</p></div>`;
|
|
193
|
+
}
|
|
194
|
+
if (preview.state === 'failed') {
|
|
195
|
+
return `<div class="ov-note ov-note--warn"><p><strong>The preview of ${v} did not start.</strong> ${escapeHtml(preview.error || '')}</p><button type="button" class="ov-toggle" data-preview-stop>Clear</button></div>`;
|
|
196
|
+
}
|
|
197
|
+
return `<div class="ov-note"><p><strong>Preview of ${v} is running</strong> on a copy of this hall's data. Nothing done there reaches the live hall. It stops by itself after ${Math.round((preview.idle_ms || 0) / 60000)} minutes unused.</p>
|
|
198
|
+
<a class="ov-toggle" href="${API}/npm-release/preview/enter?v=${encodeURIComponent(preview.version)}">Open the preview</a>
|
|
199
|
+
<button type="button" class="ov-toggle" data-preview-stop>Stop it</button></div>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function previewCall(method, body) {
|
|
203
|
+
const res = await client.request(method, `${API}/npm-release/preview`, body ? { body } : {});
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
const err = (res.data && res.data.error) || {};
|
|
206
|
+
throw new Error(err.message || `preview → ${res.status}`);
|
|
207
|
+
}
|
|
208
|
+
return res.data;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function followPreview() {
|
|
212
|
+
if (pollTimer) return;
|
|
213
|
+
pollTimer = setInterval(async () => {
|
|
214
|
+
try {
|
|
215
|
+
preview = (await previewCall('GET')).preview;
|
|
216
|
+
} catch (e) { previewErr = e.message || String(e); }
|
|
217
|
+
if (!preview || preview.state !== 'starting') { clearInterval(pollTimer); pollTimer = null; }
|
|
218
|
+
paint();
|
|
219
|
+
}, 3000);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function previewAct(fn) {
|
|
223
|
+
previewErr = '';
|
|
224
|
+
try {
|
|
225
|
+
const d = await fn();
|
|
226
|
+
preview = d.preview;
|
|
227
|
+
if (preview && preview.state === 'starting') followPreview();
|
|
228
|
+
} catch (e) { previewErr = e.message || String(e); }
|
|
229
|
+
paint();
|
|
230
|
+
}
|
|
231
|
+
|
|
174
232
|
let data = null;
|
|
175
233
|
let history = [];
|
|
176
234
|
let hasMore = false;
|
|
@@ -193,6 +251,7 @@
|
|
|
193
251
|
if (vers) {
|
|
194
252
|
vers.innerHTML = `
|
|
195
253
|
<h2 class="scroll__h" id="nr-versions-h">Versions</h2>
|
|
254
|
+
${previewHtml()}
|
|
196
255
|
<p class="scroll__lede">What each version carried, newest first — the task each merge named, in the words its ship wrote.</p>
|
|
197
256
|
${history.map(versionHtml).join('')}
|
|
198
257
|
${hasMore ? `<button type="button" class="ov-toggle" id="nr-older"${loadingMore ? ' disabled' : ''}>${loadingMore ? 'Loading…' : 'Show older versions'}</button>` : ''}`;
|
|
@@ -231,6 +290,8 @@
|
|
|
231
290
|
history = d.history.versions;
|
|
232
291
|
hasMore = d.history.more;
|
|
233
292
|
paint();
|
|
293
|
+
// A quiet first read: a preview already running (or half started) shows at once.
|
|
294
|
+
previewCall('GET').then((r) => { preview = r.preview; if (preview.state === 'starting') followPreview(); paint(); }).catch(() => {});
|
|
234
295
|
} catch (e) {
|
|
235
296
|
work.innerHTML = `<h2 class="scroll__h">Where the work is</h2>
|
|
236
297
|
<div class="ov-note ov-note--warn"><p><strong>Could not read where the work is.</strong> ${escapeHtml(e.message || String(e))}</p></div>`;
|
|
@@ -259,7 +320,10 @@
|
|
|
259
320
|
if (!(t instanceof Element)) return;
|
|
260
321
|
const ex = t.closest('[data-expand]');
|
|
261
322
|
if (ex) { expanded.add(ex.getAttribute('data-expand')); paint(); return; }
|
|
262
|
-
if (t.closest('#nr-older')) loadOlder();
|
|
323
|
+
if (t.closest('#nr-older')) { loadOlder(); return; }
|
|
324
|
+
const pv = t.closest('[data-preview]');
|
|
325
|
+
if (pv) { previewAct(() => previewCall('POST', { version: pv.getAttribute('data-preview') })); return; }
|
|
326
|
+
if (t.closest('[data-preview-stop]')) previewAct(() => previewCall('DELETE'));
|
|
263
327
|
});
|
|
264
328
|
|
|
265
329
|
load();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/routes/preview.js — start, watch, enter, leave and stop a version
|
|
4
|
+
// preview from the deploy page (task 1004300, ADR 0349).
|
|
5
|
+
//
|
|
6
|
+
// GET /npm-release/preview what is running, and how far a start has got
|
|
7
|
+
// POST /npm-release/preview start one { version }
|
|
8
|
+
// DELETE /npm-release/preview stop it and drop its copy of the database
|
|
9
|
+
// GET /npm-release/preview/enter?v= set the steering cookie, go to the hall inside it
|
|
10
|
+
// GET /npm-release/preview/exit clear the cookie, come back to the deploy page
|
|
11
|
+
//
|
|
12
|
+
// EVERY ROUTE IS GATED ON core.pin.move, the permission /deploy itself needs (an atom, not
|
|
13
|
+
// a rank — tests/government_require_permission.mjs). Starting a preview runs a
|
|
14
|
+
// published package and copies the live database, so it is no wider than moving the pin.
|
|
15
|
+
//
|
|
16
|
+
// This file also registers this module's provider for the kernel's request.divert seam:
|
|
17
|
+
// the hook that lets the platform hand a request to divert.js before anything else answers.
|
|
18
|
+
// An instance that does not have this module on never registers it, and the kernel's seam
|
|
19
|
+
// call then falls straight through.
|
|
20
|
+
|
|
21
|
+
const express = require('express');
|
|
22
|
+
const api = require('../../../src/module-api');
|
|
23
|
+
const { createDivert, clearCookie } = require('../preview/divert');
|
|
24
|
+
const { createProxy, COOKIE_NAME } = require('../preview/proxy');
|
|
25
|
+
const { previewable } = require('../preview/supervisor');
|
|
26
|
+
const { VERSION_RE, PACKAGE } = require('../preview/commands');
|
|
27
|
+
|
|
28
|
+
const log = api.logger('npm-release');
|
|
29
|
+
|
|
30
|
+
// Published versions of the core, from the registry the deploy page already reads.
|
|
31
|
+
async function publishedVersions() {
|
|
32
|
+
const { registry } = await api.readPackageRegistry({ pkg: PACKAGE, haveNotesFor: () => true });
|
|
33
|
+
return registry.ok ? registry.published : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The real supervisor is built on first use, so a boot that never previews anything starts
|
|
37
|
+
// no timer and touches no process. Until then it reads as idle, which is what it is.
|
|
38
|
+
function lazySupervisor(make) {
|
|
39
|
+
let real = null;
|
|
40
|
+
const get = () => { if (!real) real = make(); return real; };
|
|
41
|
+
return {
|
|
42
|
+
status: () => (real ? real.status() : { state: 'idle', version: null, stage: null, error: null, port: null }),
|
|
43
|
+
touch: () => { if (real) real.touch(); },
|
|
44
|
+
start: (v) => get().start(v),
|
|
45
|
+
stop: async () => (real ? real.stop() : undefined),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = function buildNpmReleasePreviewRouter({
|
|
50
|
+
supervisor = null,
|
|
51
|
+
listPublished = publishedVersions,
|
|
52
|
+
runningVersion = () => api.CORE_VERSION,
|
|
53
|
+
registerDivert = true,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const router = express.Router();
|
|
56
|
+
const sup = supervisor || lazySupervisor(() => require('../preview/runtime').createRuntime({
|
|
57
|
+
liveDb: process.env.PGDATABASE || api.instanceDbName(),
|
|
58
|
+
log,
|
|
59
|
+
}));
|
|
60
|
+
const mayPreview = api.requirePermission('core.pin.move');
|
|
61
|
+
|
|
62
|
+
if (registerDivert && !api.hasProvider('request.divert')) {
|
|
63
|
+
const forward = createProxy({
|
|
64
|
+
port: () => sup.status().port,
|
|
65
|
+
banner: () => ({ viewing: sup.status().version || '?', running: runningVersion() }),
|
|
66
|
+
exitHref: '/api/bongos/npm-release/preview/exit',
|
|
67
|
+
});
|
|
68
|
+
api.registerProvider('request.divert', createDivert({
|
|
69
|
+
supervisor: sup,
|
|
70
|
+
forward,
|
|
71
|
+
requireBuilder: api.requireBuilder,
|
|
72
|
+
requirePermission: api.requirePermission,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const failure = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
|
77
|
+
const view = () => ({ preview: sup.status(), running: runningVersion() });
|
|
78
|
+
|
|
79
|
+
router.get('/npm-release/preview', api.requireBuilder, mayPreview, (req, res) => res.json(view()));
|
|
80
|
+
|
|
81
|
+
router.post('/npm-release/preview', api.requireBuilder, mayPreview, async (req, res) => {
|
|
82
|
+
if (api.validateOrRespond(req, res, { version: { required: true, type: 'string', maxLength: 32 } })) return;
|
|
83
|
+
const version = req.body.version;
|
|
84
|
+
if (!VERSION_RE.test(version)) return failure(res, 400, 'not_a_version', 'That is not a version number.');
|
|
85
|
+
try {
|
|
86
|
+
const published = await listPublished();
|
|
87
|
+
if (!published) return failure(res, 502, 'registry_unreadable', 'The list of published versions could not be read, so nothing was started.');
|
|
88
|
+
const verdict = previewable(version, { published, running: runningVersion() });
|
|
89
|
+
if (!verdict.ok) {
|
|
90
|
+
const why = verdict.reason === 'too_old'
|
|
91
|
+
? `That version is older than previews reach back. The oldest you can preview now is ${verdict.oldest}; pick that one or a newer one.`
|
|
92
|
+
: 'That version has not been published. Pick one from the list below.';
|
|
93
|
+
return failure(res, 400, verdict.reason, why);
|
|
94
|
+
}
|
|
95
|
+
const r = sup.start(version);
|
|
96
|
+
if (!r.ok) return failure(res, 409, r.code, 'A preview of another version is already running. Stop it first.');
|
|
97
|
+
log.info(`preview of ${version} requested by builder ${req.builder && req.builder.id}`);
|
|
98
|
+
return res.status(202).json(view());
|
|
99
|
+
} catch (e) {
|
|
100
|
+
log.error(`preview start failed: ${e && e.message}`);
|
|
101
|
+
return failure(res, 500, 'preview_start_failed', 'Could not start the preview.');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
router.delete('/npm-release/preview', api.requireBuilder, mayPreview, async (req, res) => {
|
|
106
|
+
try {
|
|
107
|
+
await sup.stop();
|
|
108
|
+
res.json(view());
|
|
109
|
+
} catch (e) {
|
|
110
|
+
log.error(`preview stop failed: ${e && e.message}`);
|
|
111
|
+
failure(res, 500, 'preview_stop_failed', 'Could not stop the preview.');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
router.get('/npm-release/preview/enter', api.requireBuilder, mayPreview, (req, res) => {
|
|
116
|
+
const v = typeof req.query.v === 'string' ? req.query.v : '';
|
|
117
|
+
const st = sup.status();
|
|
118
|
+
if (!VERSION_RE.test(v) || st.state !== 'running' || st.version !== v) {
|
|
119
|
+
return failure(res, 409, 'preview_not_running', 'That preview is not running.');
|
|
120
|
+
}
|
|
121
|
+
const secure = req.secure ? '; Secure' : '';
|
|
122
|
+
res.append('Set-Cookie', `${COOKIE_NAME}=${v}; Path=/; HttpOnly; SameSite=Lax${secure}`);
|
|
123
|
+
sup.touch();
|
|
124
|
+
return res.redirect(302, '/');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
router.get('/npm-release/preview/exit', api.requireBuilder, mayPreview, (req, res) => {
|
|
128
|
+
res.append('Set-Cookie', clearCookie());
|
|
129
|
+
res.redirect(302, '/deploy');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return router;
|
|
133
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// modules/provisioning/app-status.js — what the owner is told about their app's setup on
|
|
2
|
+
// Render (task 1004353; ADR 0327, ADR 0345, ADR 0348).
|
|
3
|
+
//
|
|
4
|
+
// The setup runs in the runner and can take minutes, often after the wizard has closed,
|
|
5
|
+
// and the key is gone once it has run. So the owner needs one honest answer, from the row
|
|
6
|
+
// alone: where the setup is, the app's address once it answers, the way to Render, and —
|
|
7
|
+
// on a failure — the sentence that says what went wrong, with a retry that is theirs to
|
|
8
|
+
// start (a retry needs the key again; the platform never retries by itself).
|
|
9
|
+
//
|
|
10
|
+
// Nothing here calls Render or holds a key. The address check that moves `deploying` to
|
|
11
|
+
// `live` is the poller beside this file (pollers/app-liveness.js): a plain GET of the
|
|
12
|
+
// app's own public address, the same credential-free pull ADR 0252 made for halls.
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
// The one dashboard the owner is sent to. Service ids are checked before they are put in
|
|
16
|
+
// a link, so a stored value can never shape one.
|
|
17
|
+
const DASHBOARD = 'https://dashboard.render.com';
|
|
18
|
+
const SERVICE_ID_RE = /^srv-[a-z0-9]{8,64}$/;
|
|
19
|
+
// Render gives a web service a https://<name>.onrender.com address. Only that shape is
|
|
20
|
+
// shown as the app's address or probed by the poller — a stored value can never point
|
|
21
|
+
// either at something else.
|
|
22
|
+
const RENDER_URL_RE = /^https:\/\/[a-z0-9-]{1,63}\.onrender\.com\/?$/;
|
|
23
|
+
|
|
24
|
+
// The stages the owner sees, in order. `failed` is not a stage: it is the setup stopping.
|
|
25
|
+
const STAGES = ['creating', 'building', 'live'];
|
|
26
|
+
const STAGE_OF = { requested: 'creating', creating: 'creating', deploying: 'building', linked: 'building', live: 'live' };
|
|
27
|
+
|
|
28
|
+
function serviceLinks(serviceId) {
|
|
29
|
+
if (!SERVICE_ID_RE.test(String(serviceId || ''))) return null;
|
|
30
|
+
const base = `${DASHBOARD}/web/${serviceId}`;
|
|
31
|
+
return { service: base, env: `${base}/env` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function appUrl(url) { return RENDER_URL_RE.test(String(url || '')) ? String(url).replace(/\/+$/, '') : null; }
|
|
35
|
+
|
|
36
|
+
// The owner's view of app_host: the stage, the address, the links, the failure sentence,
|
|
37
|
+
// and the answers a retry re-sends (never the key — there is none to send).
|
|
38
|
+
function appStatus(appHost, { keyWaiting = false } = {}) {
|
|
39
|
+
const h = appHost || null;
|
|
40
|
+
if (!h || h.platform !== 'render') return null;
|
|
41
|
+
if (h.state === 'disconnected') return { platform: 'render', state: 'disconnected', stage: null };
|
|
42
|
+
const failed = h.state === 'failed';
|
|
43
|
+
return {
|
|
44
|
+
platform: 'render',
|
|
45
|
+
state: h.state || 'requested',
|
|
46
|
+
stage: failed ? null : (STAGE_OF[h.state] || 'creating'),
|
|
47
|
+
// an intent that still holds the lent key has not run yet, so the owner waits rather
|
|
48
|
+
// than retries
|
|
49
|
+
waiting: !!keyWaiting,
|
|
50
|
+
mode: h.mode === 'adopt' ? 'adopt' : 'greenfield',
|
|
51
|
+
workspace: h.ownerId || null,
|
|
52
|
+
url: appUrl(h.serviceUrl),
|
|
53
|
+
links: serviceLinks(h.serviceId),
|
|
54
|
+
error: failed ? oneSentence(h.error) : null,
|
|
55
|
+
retry: failed ? retryAnswers(h) : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A runner's reason, as one sentence the owner can read: its first line, capped, ending
|
|
60
|
+
// in a full stop. The reasons are the leg's own words (provision-render.js), already
|
|
61
|
+
// written for the owner.
|
|
62
|
+
function oneSentence(err) {
|
|
63
|
+
const s = String(err || 'The setup stopped without saying why.').split('\n')[0].trim().slice(0, 300);
|
|
64
|
+
const first = s.charAt(0).toUpperCase() + s.slice(1);
|
|
65
|
+
return /[.!?]$/.test(first) ? first : first + '.';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// What the owner already answered, so a retry asks only for the key again.
|
|
69
|
+
function retryAnswers(h) {
|
|
70
|
+
const r = { owner_id: h.ownerId || null, mode: h.mode === 'adopt' ? 'adopt' : 'greenfield' };
|
|
71
|
+
if (r.mode === 'adopt') { r.service_id = h.serviceId || null; return r; }
|
|
72
|
+
r.runtime = h.runtime || null;
|
|
73
|
+
if (h.runtime !== 'docker') {
|
|
74
|
+
if (h.startCommand) r.start_command = h.startCommand;
|
|
75
|
+
if (h.buildCommand) r.build_command = h.buildCommand;
|
|
76
|
+
}
|
|
77
|
+
if (h.webPlan) r.web_plan = h.webPlan;
|
|
78
|
+
r.database = h.database === true;
|
|
79
|
+
if (r.database && h.dbPlan) r.db_plan = h.dbPlan;
|
|
80
|
+
return r;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers, STAGES, RENDER_URL_RE, SERVICE_ID_RE };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
-- provisioning_028_render_standup.sql — the Render standup leg (task 1004183; ADR 0327, ADR 0345).
|
|
2
|
+
--
|
|
3
|
+
-- WHY THIS EXISTS. A project's owner lends the platform their Render API key once, so the
|
|
4
|
+
-- runner can create their services on THEIR Render account. Nothing afterwards needs the
|
|
5
|
+
-- key (ADR 0327 §2), so it is never stored — but it must still cross from the web tier,
|
|
6
|
+
-- which receives it, to the runner, which spends it. It rides the intent that asks for the
|
|
7
|
+
-- standup, encrypted with src/bongos/secret-box.js, and the runner clears it within the
|
|
8
|
+
-- drain that claims the intent, whether the standup worked or not.
|
|
9
|
+
--
|
|
10
|
+
-- What the encryption buys, said plainly: it protects the key from a database dump or a
|
|
11
|
+
-- nightly backup taken while the intent waits. It does NOT defend against a compromised web
|
|
12
|
+
-- tier — the key arrives there in plaintext before anything can encrypt it.
|
|
13
|
+
--
|
|
14
|
+
-- WHAT IT ADDS.
|
|
15
|
+
-- provisioning_intents.borrowed_key_enc — the encrypted key, only while the intent is
|
|
16
|
+
-- open. The CHECK below is the backstop: a resolved intent CANNOT keep a key, so a
|
|
17
|
+
-- resolution path that forgets to clear it fails loudly instead of leaving it behind.
|
|
18
|
+
-- provisioning_instances.app_host — where the project's app runs and what the platform
|
|
19
|
+
-- needs to know about it afterwards: {platform, mode, ownerId, serviceId, postgresId,
|
|
20
|
+
-- serviceUrl, webPlan, dbPlan, region, state, error}. Ids, plans and a URL — never the
|
|
21
|
+
-- key and never an env var value. One jsonb column rather than a column per field so
|
|
22
|
+
-- a second platform is an added option (ADR 0345 D3), not a second schema change.
|
|
23
|
+
-- publicInstance() is an allow-list and does not carry it.
|
|
24
|
+
-- 'render-standup' in the intents' action allow-list.
|
|
25
|
+
--
|
|
26
|
+
-- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
|
|
27
|
+
|
|
28
|
+
BEGIN;
|
|
29
|
+
|
|
30
|
+
ALTER TABLE provisioning_intents ADD COLUMN IF NOT EXISTS borrowed_key_enc text;
|
|
31
|
+
|
|
32
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_borrowed_key_open_only;
|
|
33
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_borrowed_key_open_only
|
|
34
|
+
CHECK (borrowed_key_enc IS NULL OR state IN ('pending', 'running'));
|
|
35
|
+
|
|
36
|
+
ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS app_host jsonb;
|
|
37
|
+
|
|
38
|
+
-- INTENT_ACTIONS in modules/provisioning/provisioning.js is the other spelling of this list;
|
|
39
|
+
-- tests/provisioning_restart.mjs and tests/core_upgrade_door.mjs fail if the two disagree.
|
|
40
|
+
ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
|
|
41
|
+
ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
|
|
42
|
+
CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
|
|
43
|
+
'settings-apply', 'restart', 'disconnect', 'core-upgrade',
|
|
44
|
+
'core-upgrade-preflight', 'repo-private', 'render-standup'));
|
|
45
|
+
|
|
46
|
+
COMMIT;
|
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
"disconnect",
|
|
19
19
|
"core-upgrade",
|
|
20
20
|
"env-manifest",
|
|
21
|
-
"repo-private"
|
|
21
|
+
"repo-private",
|
|
22
|
+
"render-standup"
|
|
22
23
|
],
|
|
23
24
|
"migrations": true,
|
|
24
25
|
"pollers": [
|
|
25
26
|
"liveness-sweep",
|
|
26
|
-
"catalog-backfill"
|
|
27
|
+
"catalog-backfill",
|
|
28
|
+
"app-liveness"
|
|
27
29
|
]
|
|
28
30
|
},
|
|
29
31
|
"provides": [
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/pollers/app-liveness.js — when an app the platform set up on its
|
|
4
|
+
// owner's Render account starts answering, say so (task 1004353). The loader's
|
|
5
|
+
// startModulePollers requires this file and calls start(deps) at boot, for the ENABLED
|
|
6
|
+
// module only — the liveness-sweep shape.
|
|
7
|
+
//
|
|
8
|
+
// WHY. The runner creates the service and hands the rest to Render, which builds and
|
|
9
|
+
// deploys on its own; the key is gone by then (ADR 0327 §2), so the platform cannot ask
|
|
10
|
+
// Render how the build went. What it CAN do is what ADR 0252 does for halls: knock on the
|
|
11
|
+
// app's own public address. While Render is still building, that address answers 502
|
|
12
|
+
// with `x-render-routing: no-deploy` (measured by the pilot, task 1004142); once any
|
|
13
|
+
// deploy is live, it answers whatever the app answers.
|
|
14
|
+
//
|
|
15
|
+
// SO:
|
|
16
|
+
// - only rows whose app is `deploying` (a new app) or `linked` (an existing one) are
|
|
17
|
+
// knocked on, and only at a https://<name>.onrender.com address — a stored value can
|
|
18
|
+
// never aim the probe anywhere else (app-status.js RENDER_URL_RE), and redirects are
|
|
19
|
+
// never followed;
|
|
20
|
+
// - any answer below 500 that is not Render's no-deploy page is `live`. A 404 at the
|
|
21
|
+
// root is still an app answering;
|
|
22
|
+
// - the write is compare-and-set on the state it read, so it can never overwrite what
|
|
23
|
+
// the runner wrote since (a retry that failed, a disconnect);
|
|
24
|
+
// - a GET with no credential of any kind. Nothing here calls Render's API.
|
|
25
|
+
//
|
|
26
|
+
// Best-effort: a failed knock is simply "not yet", and the next tick asks again.
|
|
27
|
+
|
|
28
|
+
const api = require('../../../src/module-api');
|
|
29
|
+
const { RENDER_URL_RE } = require('../app-status');
|
|
30
|
+
|
|
31
|
+
const logger = api.logger('provisioning');
|
|
32
|
+
|
|
33
|
+
const INTERVAL_MS = Number(process.env.PROVISIONING_APP_LIVENESS_MS) || 60_000;
|
|
34
|
+
const FIRST_DELAY_MS = 20_000;
|
|
35
|
+
const PROBE_TIMEOUT_MS = 10_000;
|
|
36
|
+
const SWEEP_LIMIT = 50;
|
|
37
|
+
const CONCURRENCY = 5;
|
|
38
|
+
|
|
39
|
+
let timer = null;
|
|
40
|
+
let started = false;
|
|
41
|
+
|
|
42
|
+
// One knock. Returns true when the app answers as deployed.
|
|
43
|
+
async function appAnswers(url, { fetchImpl, timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
|
44
|
+
if (!RENDER_URL_RE.test(String(url || ''))) return false;
|
|
45
|
+
try {
|
|
46
|
+
const res = await (fetchImpl || globalThis.fetch)(url, {
|
|
47
|
+
method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs),
|
|
48
|
+
headers: { accept: 'text/html, application/json;q=0.9, */*;q=0.8' },
|
|
49
|
+
});
|
|
50
|
+
const routing = res.headers && typeof res.headers.get === 'function' ? res.headers.get('x-render-routing') : null;
|
|
51
|
+
if (routing && /no-deploy/i.test(routing)) return false;
|
|
52
|
+
return res.status < 500;
|
|
53
|
+
} catch (_) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function sweepOnce(deps = {}) {
|
|
59
|
+
const db = deps.pool || api.pool;
|
|
60
|
+
const log = deps.log || ((m) => logger.info(m));
|
|
61
|
+
const { rows } = await db.query(
|
|
62
|
+
`SELECT id, slug, app_host FROM provisioning_instances
|
|
63
|
+
WHERE app_host->>'platform' = 'render' AND app_host->>'state' IN ('deploying', 'linked')
|
|
64
|
+
AND status <> 'torn_down'
|
|
65
|
+
ORDER BY id LIMIT ${SWEEP_LIMIT}`);
|
|
66
|
+
let live = 0;
|
|
67
|
+
// a few at a time, so one app timing out never holds the others up for its full timeout
|
|
68
|
+
const answers = [];
|
|
69
|
+
for (let i = 0; i < rows.length; i += CONCURRENCY) {
|
|
70
|
+
const batch = rows.slice(i, i + CONCURRENCY);
|
|
71
|
+
answers.push(...(await Promise.all(batch.map((r) => appAnswers((r.app_host || {}).serviceUrl, { fetchImpl: deps.fetchImpl, timeoutMs: deps.timeoutMs })))));
|
|
72
|
+
}
|
|
73
|
+
for (const [i, r] of rows.entries()) {
|
|
74
|
+
const h = r.app_host || {};
|
|
75
|
+
if (!answers[i]) continue;
|
|
76
|
+
const { rowCount } = await db.query(
|
|
77
|
+
`UPDATE provisioning_instances
|
|
78
|
+
SET app_host = app_host || jsonb_build_object('state', 'live', 'liveAt', now()::text, 'error', NULL)
|
|
79
|
+
WHERE id = $1 AND app_host->>'state' = $2`,
|
|
80
|
+
[r.id, h.state]);
|
|
81
|
+
if (rowCount) { live++; log(`[app-liveness] ${r.slug}: its app answers at ${h.serviceUrl} — live`); }
|
|
82
|
+
}
|
|
83
|
+
return { checked: rows.length, live };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function start(deps = {}) {
|
|
87
|
+
if (process.env.PROVISIONING_APP_LIVENESS_DISABLED === '1') {
|
|
88
|
+
logger.info('[app-liveness] disabled (PROVISIONING_APP_LIVENESS_DISABLED=1)');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (started) return;
|
|
92
|
+
started = true;
|
|
93
|
+
arm(deps, FIRST_DELAY_MS);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function arm(deps, delay) {
|
|
97
|
+
timer = setTimeout(() => {
|
|
98
|
+
sweepOnce(deps)
|
|
99
|
+
.catch((e) => logger.error(`[app-liveness] sweep failed: ${e && e.message ? e.message : e}`))
|
|
100
|
+
.then(() => { if (started) arm(deps, INTERVAL_MS); });
|
|
101
|
+
}, delay);
|
|
102
|
+
if (timer.unref) timer.unref();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stop() {
|
|
106
|
+
started = false;
|
|
107
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { start, stop, sweepOnce, appAnswers, INTERVAL_MS };
|