@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,362 @@
|
|
|
1
|
+
// tests/provision_render.mjs — the Render standup leg (task 1004183; ADR 0327).
|
|
2
|
+
//
|
|
3
|
+
// scripts/gds/provision-render.js driven over the REAL Render client (render-api.js) and a
|
|
4
|
+
// recording fake of Render's API, so the workspace guard is exercised end to end rather
|
|
5
|
+
// than stubbed away. db and the secret box are fakes. No network, no DB.
|
|
6
|
+
//
|
|
7
|
+
// The load-bearing assertions — the task's DONE WHEN, clause by clause:
|
|
8
|
+
// • the borrowed key is CLEARED in the drain that spends it: on success, on a refusal,
|
|
9
|
+
// on a mismatched workspace, on a Render error and on a decrypt failure
|
|
10
|
+
// • the ownerId guard refuses a mismatched response, and the run stops there — nothing
|
|
11
|
+
// is created after it
|
|
12
|
+
// • ADOPT makes no write call to Render at all, and links only a service that builds
|
|
13
|
+
// THIS project's repository; GREENFIELD creates the database only when asked for, then
|
|
14
|
+
// the app's service — built from the project's own repository the way its owner said,
|
|
15
|
+
// never the Bongos core (task 1004350, ADR 0345)
|
|
16
|
+
// • the plan is read from the project's row, not a constant
|
|
17
|
+
// • a failure is TERMINAL: the runner retires it instead of re-queuing it, because the
|
|
18
|
+
// key is gone and the owner retries by entering it again
|
|
19
|
+
// • teardown forgets the Render app and never calls Render
|
|
20
|
+
//
|
|
21
|
+
// Run: node tests/provision_render.mjs
|
|
22
|
+
|
|
23
|
+
import { strict as assert } from 'node:assert';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const { RenderApi } = require('../scripts/gds/render-api.js');
|
|
29
|
+
const { renderStandupInstance, forgetRenderApp, KEY_TTL_MS } = require('../scripts/gds/provision-render.js');
|
|
30
|
+
const P = require('../scripts/gds/provision.js');
|
|
31
|
+
|
|
32
|
+
let passed = 0, failed = 0;
|
|
33
|
+
async function ta(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const OURS = 'tea-ours00000001';
|
|
39
|
+
const THEIRS = 'tea-theirs000001';
|
|
40
|
+
const SVC_URL = 'https://demo.onrender.com';
|
|
41
|
+
|
|
42
|
+
// A fake Render account. `over` replaces any answer; a function answer sees the body.
|
|
43
|
+
function fakeRender(over = {}) {
|
|
44
|
+
const calls = [];
|
|
45
|
+
const answers = {
|
|
46
|
+
'GET /owners?limit=100': [{ owner: { id: THEIRS } }, { owner: { id: OURS } }],
|
|
47
|
+
'POST /postgres': { id: 'dpg-new', status: 'creating', owner: { id: OURS } },
|
|
48
|
+
'GET /postgres/dpg-new': { id: 'dpg-new', status: 'available', owner: { id: OURS } },
|
|
49
|
+
'GET /postgres/dpg-new/connection-info': { internalConnectionString: 'postgres://internal' },
|
|
50
|
+
'POST /services': { deployId: 'dep-1', service: { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } } },
|
|
51
|
+
'GET /services/srv-new': { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } },
|
|
52
|
+
'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, repo: 'https://github.com/Example-Owner/demo', serviceDetails: { url: 'https://old.onrender.com' } },
|
|
53
|
+
'POST /services/srv-new/deploys': { id: 'dep-2' },
|
|
54
|
+
...over,
|
|
55
|
+
};
|
|
56
|
+
const fetchImpl = async (url, init) => {
|
|
57
|
+
const path = url.replace('https://api.render.com/v1', '');
|
|
58
|
+
const body = init.body ? JSON.parse(init.body) : null;
|
|
59
|
+
calls.push({ method: init.method, path, body });
|
|
60
|
+
let key = `${init.method} ${path}`;
|
|
61
|
+
if (init.method === 'PUT' && /\/env-vars\//.test(path)) key = 'PUT env';
|
|
62
|
+
const a = key in answers ? answers[key] : (key === 'PUT env' ? { ok: true } : undefined);
|
|
63
|
+
if (a === undefined) return { ok: false, status: 404, text: async () => '{"message":"not found"}' };
|
|
64
|
+
if (a instanceof Error) return { ok: false, status: 500, text: async () => JSON.stringify({ message: a.message }) };
|
|
65
|
+
return { ok: true, status: 200, text: async () => JSON.stringify(typeof a === 'function' ? a(body) : a) };
|
|
66
|
+
};
|
|
67
|
+
return { calls, fetchImpl };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function harness({ host = {}, intent = {}, render = {}, decryptThrows = false } = {}) {
|
|
71
|
+
const r = fakeRender(render);
|
|
72
|
+
const writes = [], logs = [];
|
|
73
|
+
const db = {
|
|
74
|
+
query: async (sql, params) => {
|
|
75
|
+
writes.push({ sql: sql.replace(/\s+/g, ' ').trim(), params });
|
|
76
|
+
if (/FROM builders/.test(sql)) return { rows: [{ github_login: 'owner-login' }] };
|
|
77
|
+
return { rows: [] };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const deps = {
|
|
81
|
+
db, apply: true, log: (l) => logs.push(l), envPrefix: 'BONGOS', sleepFn: async () => {},
|
|
82
|
+
secretBox: { decrypt: (blob) => { if (decryptThrows) throw new Error('secret-box: bad tag'); assert.equal(blob, 'v1.enc'); return 'rnd_borrowed'; } },
|
|
83
|
+
renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: r.fetchImpl }),
|
|
84
|
+
};
|
|
85
|
+
const inst = { id: 18, slug: 'demo', owner_builder_id: 7, target_ref: 'example-owner/demo', status: 'active',
|
|
86
|
+
app_host: { platform: 'render', mode: 'greenfield', ownerId: OURS, runtime: 'node', buildCommand: 'npm ci', startCommand: 'npm start', database: true, ...host } };
|
|
87
|
+
const it = { id: 44, action: 'render-standup', borrowed_key_enc: 'v1.enc', created_at: new Date().toISOString(), ...intent };
|
|
88
|
+
const cleared = () => writes.filter((w) => /UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = \$1/.test(w.sql) && w.params[0] === 44).length;
|
|
89
|
+
const appHost = () => writes.filter((w) => /SET app_host/.test(w.sql)).map((w) => JSON.parse(w.params[1]));
|
|
90
|
+
const lastHost = () => Object.assign({}, ...appHost());
|
|
91
|
+
return { r, writes, logs, deps, inst, it, cleared, lastHost, run: () => renderStandupInstance(inst, deps, it) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const writesTo = (calls) => calls.filter((c) => c.method !== 'GET');
|
|
95
|
+
|
|
96
|
+
console.log("greenfield — the owner's app:");
|
|
97
|
+
await ta("with a database: the database, then the app's service — and the key cleared", async () => {
|
|
98
|
+
const h = harness();
|
|
99
|
+
const out = await h.run();
|
|
100
|
+
assert.equal(out.ok, true, out.error);
|
|
101
|
+
assert.deepEqual(writesTo(h.r.calls).map((c) => c.method + ' ' + c.path), ['POST /postgres', 'POST /services'],
|
|
102
|
+
'no env var PUTs and no extra deploy: Render deploys a new service on its own');
|
|
103
|
+
assert.equal(h.cleared(), 1, 'the key column is cleared');
|
|
104
|
+
const host = h.lastHost();
|
|
105
|
+
assert.deepEqual([host.postgresId, host.serviceId, host.state, host.serviceUrl, host.webPlan, host.error], ['dpg-new', 'srv-new', 'deploying', SVC_URL, 'starter', null], 'the row records the service, its URL and the plan');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await ta('without a database: no Postgres is created and the app gets no DATABASE_URL', async () => {
|
|
109
|
+
const h = harness({ host: { database: false } });
|
|
110
|
+
const out = await h.run();
|
|
111
|
+
assert.equal(out.ok, true, out.error);
|
|
112
|
+
assert.ok(!h.r.calls.some((c) => c.path.startsWith('/postgres')), 'nothing about a database is called');
|
|
113
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
114
|
+
assert.deepEqual(svc.envVars, []);
|
|
115
|
+
assert.equal(h.lastHost().postgresId, undefined);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await ta("the service is the owner's app: their repository, runtime and commands — not the Bongos core", async () => {
|
|
119
|
+
const h = harness();
|
|
120
|
+
await h.run();
|
|
121
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
122
|
+
assert.equal(svc.ownerId, OURS);
|
|
123
|
+
assert.equal(svc.repo, 'https://github.com/example-owner/demo');
|
|
124
|
+
assert.equal(svc.serviceDetails.runtime, 'node');
|
|
125
|
+
assert.deepEqual(svc.serviceDetails.envSpecificDetails, { buildCommand: 'npm ci', startCommand: 'npm start' });
|
|
126
|
+
const src = JSON.stringify(svc);
|
|
127
|
+
assert.doesNotMatch(src, /bongos|platform-server|migrate[.]sh|FIRST_ADMIN|BUILDER_SECRET_KEY|_ORIGIN/i, 'nothing of the hall rides along');
|
|
128
|
+
assert.deepEqual(svc.envVars, [{ key: 'DATABASE_URL', value: 'postgres://internal' }], 'the only value the platform sets');
|
|
129
|
+
const stored = JSON.stringify(h.writes.map((w) => w.params));
|
|
130
|
+
assert.ok(!stored.includes('postgres://internal') && !stored.includes('rnd_borrowed'), 'no env var value and no key is ever written to our database');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await ta("docker builds the repository's own Dockerfile, with no commands", async () => {
|
|
134
|
+
const h = harness({ host: { runtime: 'docker', buildCommand: undefined, startCommand: undefined } });
|
|
135
|
+
const out = await h.run();
|
|
136
|
+
assert.equal(out.ok, true, out.error);
|
|
137
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
138
|
+
assert.equal(svc.serviceDetails.runtime, 'docker');
|
|
139
|
+
assert.deepEqual(svc.serviceDetails.envSpecificDetails, {});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await ta('a native runtime with no start command is refused before anything is created', async () => {
|
|
143
|
+
const h = harness({ host: { startCommand: undefined } });
|
|
144
|
+
const out = await h.run();
|
|
145
|
+
assert.equal(out.ok, false);
|
|
146
|
+
assert.match(out.error, /start command/);
|
|
147
|
+
assert.deepEqual(writesTo(h.r.calls), []);
|
|
148
|
+
assert.equal(h.cleared(), 1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await ta("the plan is the project's own setting, not a constant", async () => {
|
|
152
|
+
const h = harness({ host: { webPlan: 'standard', dbPlan: 'basic_1gb', region: 'frankfurt' } });
|
|
153
|
+
await h.run();
|
|
154
|
+
const pg = h.r.calls.find((c) => c.path === '/postgres').body;
|
|
155
|
+
const svc = h.r.calls.find((c) => c.path === '/services').body;
|
|
156
|
+
assert.equal(pg.plan, 'basic_1gb');
|
|
157
|
+
assert.equal(pg.region, 'frankfurt');
|
|
158
|
+
assert.equal(svc.serviceDetails.plan, 'standard');
|
|
159
|
+
const d = harness();
|
|
160
|
+
await d.run();
|
|
161
|
+
assert.equal(d.r.calls.find((c) => c.path === '/postgres').body.plan, 'basic_256mb', 'the default is paid: free Postgres has no backups');
|
|
162
|
+
assert.equal(d.r.calls.find((c) => c.path === '/services').body.serviceDetails.plan, 'starter');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await ta('a retry resumes onto what an earlier run created — no second database or service', async () => {
|
|
166
|
+
const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-new' } });
|
|
167
|
+
const out = await h.run();
|
|
168
|
+
assert.equal(out.ok, true, out.error);
|
|
169
|
+
assert.deepEqual(writesTo(h.r.calls), []);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
console.log('\nadopt:');
|
|
173
|
+
await ta('links an existing service and makes NO write call to Render', async () => {
|
|
174
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' } });
|
|
175
|
+
const out = await h.run();
|
|
176
|
+
assert.equal(out.ok, true, out.error);
|
|
177
|
+
assert.deepEqual(writesTo(h.r.calls), [], 'only reads');
|
|
178
|
+
assert.equal(h.lastHost().state, 'linked');
|
|
179
|
+
assert.equal(h.lastHost().serviceUrl, 'https://old.onrender.com');
|
|
180
|
+
assert.equal(h.cleared(), 1);
|
|
181
|
+
});
|
|
182
|
+
await ta('a service that builds a DIFFERENT repository is refused — its URL would pose as this app', async () => {
|
|
183
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' }, render: { 'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, repo: 'https://github.com/example-owner/other-app' } } });
|
|
184
|
+
const out = await h.run();
|
|
185
|
+
assert.equal(out.ok, false);
|
|
186
|
+
assert.ok(out.error.includes('other-app, not example-owner/demo'), out.error);
|
|
187
|
+
assert.deepEqual(writesTo(h.r.calls), []);
|
|
188
|
+
assert.equal(h.lastHost().state, 'failed');
|
|
189
|
+
assert.equal(h.cleared(), 1);
|
|
190
|
+
});
|
|
191
|
+
await ta('adopting a service from another workspace is refused, and the key still cleared', async () => {
|
|
192
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' }, render: { 'GET /services/srv-old': { id: 'srv-old', ownerId: THEIRS } } });
|
|
193
|
+
const out = await h.run();
|
|
194
|
+
assert.equal(out.ok, false);
|
|
195
|
+
assert.equal(out.terminal, true);
|
|
196
|
+
assert.match(out.error, /from workspace tea-theirs/);
|
|
197
|
+
assert.equal(h.lastHost().state, 'failed');
|
|
198
|
+
assert.equal(h.cleared(), 1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
console.log('\nthe guard stops the run:');
|
|
202
|
+
await ta('a database answered from another workspace stops everything after it', async () => {
|
|
203
|
+
const h = harness({ render: { 'POST /postgres': { id: 'dpg-x', status: 'creating', owner: { id: THEIRS } } } });
|
|
204
|
+
const out = await h.run();
|
|
205
|
+
assert.equal(out.ok, false);
|
|
206
|
+
assert.match(out.error, /refusing to go on/);
|
|
207
|
+
assert.deepEqual(writesTo(h.r.calls).map((c) => c.path), ['/postgres'], 'no service, no env vars, no deploy');
|
|
208
|
+
assert.equal(h.cleared(), 1);
|
|
209
|
+
});
|
|
210
|
+
await ta('a workspace the key cannot reach is refused before any other call', async () => {
|
|
211
|
+
const h = harness({ render: { 'GET /owners?limit=100': [{ owner: { id: THEIRS } }] } });
|
|
212
|
+
const out = await h.run();
|
|
213
|
+
assert.equal(out.ok, false);
|
|
214
|
+
assert.match(out.error, /cannot reach workspace/);
|
|
215
|
+
assert.equal(h.r.calls.length, 1);
|
|
216
|
+
assert.equal(h.cleared(), 1);
|
|
217
|
+
});
|
|
218
|
+
await ta('the workspace is never taken from the list — with none named, nothing is called', async () => {
|
|
219
|
+
const h = harness({ host: { ownerId: undefined } });
|
|
220
|
+
const out = await h.run();
|
|
221
|
+
assert.equal(out.ok, false);
|
|
222
|
+
assert.equal(h.r.calls.length, 0);
|
|
223
|
+
assert.equal(h.cleared(), 1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
console.log('\nthe key is cleared whatever happens:');
|
|
227
|
+
for (const [name, opts, re] of [
|
|
228
|
+
['Render answers an error', { render: { 'POST /services': new Error('plan not allowed') } }, /plan not allowed/],
|
|
229
|
+
['the key cannot be decrypted', { decryptThrows: true }, /bad tag/],
|
|
230
|
+
['the key waited too long', { intent: { created_at: new Date(Date.now() - KEY_TTL_MS - 1000).toISOString() } }, /waited too long/],
|
|
231
|
+
['no key came with the request', { intent: { borrowed_key_enc: null } }, /no Render key/],
|
|
232
|
+
['the project is not set to Render', { host: { platform: 'vercel' } }, /not set to run on Render/],
|
|
233
|
+
['an unknown plan', { host: { webPlan: 'huge' } }, /unknown Render web plan/],
|
|
234
|
+
]) {
|
|
235
|
+
await ta(`${name} → terminal, reason recorded, key cleared`, async () => {
|
|
236
|
+
const h = harness(opts);
|
|
237
|
+
const out = await h.run();
|
|
238
|
+
assert.equal(out.ok, false);
|
|
239
|
+
assert.equal(out.terminal, true);
|
|
240
|
+
assert.match(out.error, re);
|
|
241
|
+
assert.equal(h.lastHost().state, 'failed');
|
|
242
|
+
assert.equal(h.cleared(), 1);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log('\nthe runner retires a terminal failure — no silent retry:');
|
|
247
|
+
function drain(resultOfLeg) {
|
|
248
|
+
let claims = 0; const resolved = [], requeued = [];
|
|
249
|
+
const h = harness(resultOfLeg);
|
|
250
|
+
const deps = {
|
|
251
|
+
...h.deps, exec: () => '', controlExec: () => '', sleep: async () => {},
|
|
252
|
+
db: { query: async (sql, params) => { if (/state='pending'/.test(sql)) requeued.push(params); return h.deps.db.query(sql, params); } },
|
|
253
|
+
provisioning: {
|
|
254
|
+
claimNextIntent: async () => (claims++ === 0 ? { ...h.it, instance_id: 18, attempts: 1 } : null),
|
|
255
|
+
getInstanceById: async () => ({ ...h.inst }),
|
|
256
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
257
|
+
setInstanceStatus: async () => {}, recordEvent: async () => {},
|
|
258
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
259
|
+
},
|
|
260
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
261
|
+
};
|
|
262
|
+
return { deps, resolved, requeued, h };
|
|
263
|
+
}
|
|
264
|
+
await ta("cmdRunIntents routes 'render-standup' to the leg, and a success resolves done", async () => {
|
|
265
|
+
const d = drain({ host: { mode: 'adopt', serviceId: 'srv-old' } });
|
|
266
|
+
const out = await P.cmdRunIntents(d.deps);
|
|
267
|
+
assert.equal(out.errors, 0, JSON.stringify(out));
|
|
268
|
+
assert.deepEqual(d.resolved, [{ id: 44, state: 'done', err: null }]);
|
|
269
|
+
});
|
|
270
|
+
await ta('a failure is retired on its FIRST attempt, never re-queued', async () => {
|
|
271
|
+
const d = drain({ render: { 'GET /owners?limit=100': [] } });
|
|
272
|
+
const out = await P.cmdRunIntents(d.deps);
|
|
273
|
+
assert.equal(out.errors, 1);
|
|
274
|
+
assert.deepEqual(d.requeued, [], 'not put back to pending');
|
|
275
|
+
assert.equal(d.resolved.length, 1);
|
|
276
|
+
assert.equal(d.resolved[0].state, 'error');
|
|
277
|
+
assert.match(d.resolved[0].err, /cannot reach workspace/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
console.log('\nthe wizard path — the app straight after the project, in the same run (task 1004352):');
|
|
281
|
+
const { afterProvision } = require('../scripts/gds/provision-render.js');
|
|
282
|
+
function afterHarness({ keyOnRow = 'v1.enc', host = {} } = {}) {
|
|
283
|
+
const h = harness({ host });
|
|
284
|
+
const base = h.deps.db.query;
|
|
285
|
+
h.deps.db = { query: async (sql, params) => (/SELECT borrowed_key_enc FROM provisioning_intents/.test(sql) ? { rows: [{ borrowed_key_enc: keyOnRow }] } : base(sql, params)) };
|
|
286
|
+
h.deps.provisioning = { getInstanceById: async () => ({ ...h.inst }) };
|
|
287
|
+
return h;
|
|
288
|
+
}
|
|
289
|
+
await ta('the project is up and a key rode the run → the app is set up, the key cleared, the run a success', async () => {
|
|
290
|
+
const h = afterHarness();
|
|
291
|
+
const out = await afterProvision(h.inst, h.deps, { id: 44, created_at: new Date().toISOString() }, { ok: true, port: 3100 });
|
|
292
|
+
assert.deepEqual(out, { ok: true, port: 3100 });
|
|
293
|
+
assert.ok(h.r.calls.some((c) => c.method === 'POST' && c.path === '/services'));
|
|
294
|
+
assert.equal(h.cleared(), 1);
|
|
295
|
+
});
|
|
296
|
+
await ta('the key is re-read from the row — one attached after the run was claimed is still spent', async () => {
|
|
297
|
+
const h = afterHarness();
|
|
298
|
+
await afterProvision(h.inst, h.deps, { id: 44, created_at: new Date().toISOString(), borrowed_key_enc: null }, { ok: true });
|
|
299
|
+
assert.ok(h.r.calls.length > 0);
|
|
300
|
+
assert.equal(h.cleared(), 1);
|
|
301
|
+
});
|
|
302
|
+
await ta('the app fails but the project is up → the run stays a success, with a note, and the key cleared', async () => {
|
|
303
|
+
const h = afterHarness();
|
|
304
|
+
h.r.calls.length = 0;
|
|
305
|
+
const hh = afterHarness({ host: { ownerId: 'tea-unreachable1' } });
|
|
306
|
+
const out = await afterProvision(hh.inst, hh.deps, { id: 44, created_at: new Date().toISOString() }, { ok: true });
|
|
307
|
+
assert.equal(out.ok, true);
|
|
308
|
+
assert.match(out.doneNote, /not set up on Render: this Render key cannot reach workspace/);
|
|
309
|
+
assert.equal(hh.lastHost().state, 'failed');
|
|
310
|
+
assert.equal(hh.cleared(), 1);
|
|
311
|
+
});
|
|
312
|
+
await ta('the project failed → Render is never called, the key cleared, the app marked failed', async () => {
|
|
313
|
+
const h = afterHarness();
|
|
314
|
+
const out = await afterProvision(h.inst, h.deps, { id: 44 }, { ok: false, error: 'DNS preflight failed' });
|
|
315
|
+
assert.deepEqual(out, { ok: false, error: 'DNS preflight failed' }, 'the provision result is passed through, so its retry rules are unchanged');
|
|
316
|
+
assert.equal(h.r.calls.length, 0);
|
|
317
|
+
assert.equal(h.cleared(), 1, 'a provision retry never carries a key into a later run');
|
|
318
|
+
assert.match(h.lastHost().error, /connect Render again/);
|
|
319
|
+
});
|
|
320
|
+
await ta('no key on the run → the provision result is untouched and Render is never called', async () => {
|
|
321
|
+
const h = afterHarness({ keyOnRow: null });
|
|
322
|
+
const r = { ok: true, already: true };
|
|
323
|
+
assert.equal(await afterProvision(h.inst, h.deps, { id: 44 }, r), r);
|
|
324
|
+
assert.equal(h.r.calls.length, 0);
|
|
325
|
+
assert.equal(h.cleared(), 0);
|
|
326
|
+
});
|
|
327
|
+
await ta('the runner reaches afterProvision on every provision run', () => {
|
|
328
|
+
const src = readFileSync(new URL('../scripts/gds/provision.js', import.meta.url), 'utf8');
|
|
329
|
+
assert.match(src, /intent\.action === 'provision'\) result = await require\('\.\/provision-render\.js'\)\.afterProvision\(inst, deps, intent, await provisionInstance\(inst, deps\)\)/);
|
|
330
|
+
});
|
|
331
|
+
await ta('a link needs a repository to check the service against (1004350 review)', async () => {
|
|
332
|
+
const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' } });
|
|
333
|
+
h.inst.target_ref = null;
|
|
334
|
+
const out = await h.run();
|
|
335
|
+
assert.equal(out.ok, false);
|
|
336
|
+
assert.equal(h.cleared(), 1);
|
|
337
|
+
});
|
|
338
|
+
await ta('an app with no build step sends no build command at all (1004350 review)', async () => {
|
|
339
|
+
const h = harness({ host: { buildCommand: undefined } });
|
|
340
|
+
await h.run();
|
|
341
|
+
const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
|
|
342
|
+
assert.deepEqual(svc.serviceDetails.envSpecificDetails, { startCommand: 'npm start' });
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
console.log('\nteardown forgets, never deletes:');
|
|
346
|
+
await ta('forgetRenderApp records the disconnect and calls nothing', async () => {
|
|
347
|
+
const writes = [], logs = [];
|
|
348
|
+
const inst = { id: 18, app_host: { platform: 'render', serviceId: 'srv-new', postgresId: 'dpg-new' } };
|
|
349
|
+
await forgetRenderApp(inst, { db: { query: async (sql, p) => { writes.push(p); return { rows: [] }; } }, log: (l) => logs.push(l), apply: true });
|
|
350
|
+
assert.equal(JSON.parse(writes[0][1]).state, 'disconnected');
|
|
351
|
+
assert.match(logs.join('\n'), /nothing on Render is deleted/);
|
|
352
|
+
await forgetRenderApp({ id: 19, app_host: null }, { db: { query: async () => { throw new Error('must not write'); } }, log: () => {}, apply: true });
|
|
353
|
+
});
|
|
354
|
+
await ta('the teardown leg reaches forgetRenderApp, and the leg has no path to a Render delete', () => {
|
|
355
|
+
const src = readFileSync(new URL('../scripts/gds/provision-teardown.js', import.meta.url), 'utf8');
|
|
356
|
+
assert.match(src, /forgetRenderApp\(inst/);
|
|
357
|
+
const leg = readFileSync(new URL('../scripts/gds/provision-render.js', import.meta.url), 'utf8');
|
|
358
|
+
assert.doesNotMatch(leg, /'DELETE'|\.delete[A-Z]/);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
362
|
+
if (failed) process.exit(1);
|
|
@@ -567,5 +567,67 @@ await ta('cmdRunIntents (--apply): a dedicated refusal RETIRES at the cap with t
|
|
|
567
567
|
assert.match(statusWrites[0].patch.error_note, /dedicated/);
|
|
568
568
|
});
|
|
569
569
|
|
|
570
|
+
console.log('\nrenamedEnvCarry — the prelaunch password carried on the box (task 1004395):');
|
|
571
|
+
|
|
572
|
+
// A value that could never appear anywhere by accident, so "not in the text" means something.
|
|
573
|
+
const SECRET = 'zq7-Wall-Pass-$&-9x';
|
|
574
|
+
|
|
575
|
+
t('carries the old name to the new one when ONLY the old name is set, copying the right-hand side verbatim', () => {
|
|
576
|
+
const r = P.renamedEnvCarry(`PORT=3002\nSTEALTH_PASSWORD=${SECRET}\n`);
|
|
577
|
+
assert.deepEqual(r.vars, { PRELAUNCH_PASSWORD: SECRET });
|
|
578
|
+
assert.deepEqual(r.carried, ['STEALTH_PASSWORD -> PRELAUNCH_PASSWORD']);
|
|
579
|
+
assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD="quoted pw"\n').vars, { PRELAUNCH_PASSWORD: '"quoted pw"' }, 'quotes kept, so systemd reads the same value');
|
|
580
|
+
assert.deepEqual(P.renamedEnvCarry(`A=1\r\nSTEALTH_PASSWORD=${SECRET}\r\n`).vars, { PRELAUNCH_PASSWORD: SECRET }, 'a CRLF file carries no stray \\r');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
t('no-op when the new name is already set — even to a different value', () => {
|
|
584
|
+
for (const body of [`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=${SECRET}\n`, `STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=other\n`, `PRELAUNCH_PASSWORD=${SECRET}\n`]) {
|
|
585
|
+
assert.deepEqual(P.renamedEnvCarry(body), { vars: {}, carried: [] }, body);
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
t('no-op when the old name is absent, empty or commented out; an EMPTY new line is still filled', () => {
|
|
590
|
+
for (const body of ['PORT=3002\n', 'STEALTH_PASSWORD=\n', 'STEALTH_PASSWORD=""\n', `# STEALTH_PASSWORD=${SECRET}\n`, 'X_STEALTH_PASSWORD=abc\n', '', null]) {
|
|
591
|
+
assert.deepEqual(P.renamedEnvCarry(body), { vars: {}, carried: [] }, String(body));
|
|
592
|
+
}
|
|
593
|
+
assert.deepEqual(P.renamedEnvCarry(`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=\n`).vars, { PRELAUNCH_PASSWORD: SECRET });
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
t('the last line of a key wins, as systemd reads an EnvironmentFile', () => {
|
|
597
|
+
assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD=first\nSTEALTH_PASSWORD=second\n').vars, { PRELAUNCH_PASSWORD: 'second' });
|
|
598
|
+
assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD=pw\nPRELAUNCH_PASSWORD=set\nPRELAUNCH_PASSWORD=\n').vars, { PRELAUNCH_PASSWORD: 'pw' }, 'a later blank line unsets it');
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
await ta('the leg carries it in the SAME in-place patch: every other line untouched, the old name kept, the value in NO log, event or result', async () => {
|
|
602
|
+
const envBody = `${LIVE_ENV}STEALTH_PASSWORD=${SECRET}\n`;
|
|
603
|
+
const { deps, writes, events, logs } = recorder({ apply: true, envBody });
|
|
604
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
605
|
+
assert.equal(r.ok, true, JSON.stringify(r));
|
|
606
|
+
const out = writes[0].body;
|
|
607
|
+
assert.ok(out.endsWith(`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=${SECRET}\n`), 'appended after the old line, which stays for task 1003830 to retire');
|
|
608
|
+
assert.equal(out.split('\n').length, envBody.split('\n').length + 1, 'exactly one line added');
|
|
609
|
+
assert.match(out, /_GITHUB_CLIENT_SECRET=0123456789abcdef0123456789abcdef01234567/, 'creds untouched');
|
|
610
|
+
assert.match(events[0].detail, /carried STEALTH_PASSWORD -> PRELAUNCH_PASSWORD/, 'the event names the carry — how the owner verifies it without reading the file');
|
|
611
|
+
assert.ok(logs.some((l) => /carry the renamed env forward: STEALTH_PASSWORD -> PRELAUNCH_PASSWORD/.test(l)));
|
|
612
|
+
const said = [...logs, ...events.map((e) => e.detail), JSON.stringify(r)].join('\n');
|
|
613
|
+
assert.ok(!said.includes(SECRET) && !said.includes('zq7-Wall'), 'the password never leaves the file');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
await ta('nothing to carry → the leg is byte-identical to before: no extra line, no carry wording', async () => {
|
|
617
|
+
const { deps, writes, events, logs } = recorder({ apply: true });
|
|
618
|
+
await P.settingsApplyInstance(activeStandalone, deps);
|
|
619
|
+
assert.equal(writes[0].body.split('\n').length, LIVE_ENV.split('\n').length);
|
|
620
|
+
assert.doesNotMatch(writes[0].body, /PRELAUNCH_PASSWORD/);
|
|
621
|
+
assert.doesNotMatch(events[0].detail, /carried/);
|
|
622
|
+
assert.ok(!logs.some((l) => /carry the renamed env/.test(l)));
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
await ta('a failed write reports the error without the value', async () => {
|
|
626
|
+
const { deps } = recorder({ apply: true, writeThrows: true, envBody: `${LIVE_ENV}STEALTH_PASSWORD=${SECRET}\n` });
|
|
627
|
+
const r = await P.settingsApplyInstance(activeStandalone, deps);
|
|
628
|
+
assert.equal(r.ok, false);
|
|
629
|
+
assert.ok(!r.error.includes(SECRET), r.error);
|
|
630
|
+
});
|
|
631
|
+
|
|
570
632
|
console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
|
|
571
633
|
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// tests/provisioning_app_status.mjs — what the owner is told about their app's setup on
|
|
2
|
+
// Render, and the knock that says when it is live (task 1004353).
|
|
3
|
+
//
|
|
4
|
+
// The load-bearing assertions:
|
|
5
|
+
// • the read is OWNER ONLY, returns env var NAMES only, and never the key or a value
|
|
6
|
+
// • a failure comes back as one sentence, with the answers a Retry re-sends — the
|
|
7
|
+
// answers only, never a key
|
|
8
|
+
// • an app address is only ever an onrender.com one, and a Render link is only ever
|
|
9
|
+
// built from a checked service id — a stored value cannot shape either
|
|
10
|
+
// • the poller marks an app live only when it answers at that address, never on
|
|
11
|
+
// Render's no-deploy page, never on a 5xx; it follows no redirect; and its write is
|
|
12
|
+
// compare-and-set, so it never overwrites what the runner wrote since
|
|
13
|
+
//
|
|
14
|
+
// DB-free and network-free. Run: node tests/provisioning_app_status.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const { appStatus, appUrl, serviceLinks, oneSentence, retryAnswers } = require('../modules/provisioning/app-status.js');
|
|
23
|
+
const liveness = require('../modules/provisioning/pollers/app-liveness.js');
|
|
24
|
+
|
|
25
|
+
let passed = 0, failed = 0;
|
|
26
|
+
async function ta(name, fn) {
|
|
27
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
28
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HOST = { platform: 'render', mode: 'greenfield', ownerId: 'tea-aaaaaaaa1111', serviceId: 'srv-aaaaaaaa1111', serviceUrl: 'https://demo.onrender.com',
|
|
32
|
+
runtime: 'node', startCommand: 'npm start', buildCommand: 'npm ci', webPlan: 'starter', database: true, dbPlan: 'basic_256mb' };
|
|
33
|
+
|
|
34
|
+
console.log('the status (app-status.js):');
|
|
35
|
+
await ta('no app host, or another platform → null', () => {
|
|
36
|
+
assert.equal(appStatus(null), null);
|
|
37
|
+
assert.equal(appStatus({ platform: 'vercel' }), null);
|
|
38
|
+
});
|
|
39
|
+
await ta('each runner state maps to the stage the owner sees', () => {
|
|
40
|
+
const stage = (state) => appStatus({ ...HOST, state }).stage;
|
|
41
|
+
assert.equal(stage('requested'), 'creating');
|
|
42
|
+
assert.equal(stage('creating'), 'creating');
|
|
43
|
+
assert.equal(stage('deploying'), 'building');
|
|
44
|
+
assert.equal(stage('linked'), 'building', 'a link waits for the app to answer too');
|
|
45
|
+
assert.equal(stage('live'), 'live');
|
|
46
|
+
assert.equal(appStatus({ ...HOST, state: 'failed', error: 'x' }).stage, null, 'a failure is not a stage');
|
|
47
|
+
});
|
|
48
|
+
await ta('a live app carries its address and the two Render links', () => {
|
|
49
|
+
const s = appStatus({ ...HOST, state: 'live' });
|
|
50
|
+
assert.equal(s.url, 'https://demo.onrender.com');
|
|
51
|
+
assert.deepEqual(s.links, { service: 'https://dashboard.render.com/web/srv-aaaaaaaa1111', env: 'https://dashboard.render.com/web/srv-aaaaaaaa1111/env' });
|
|
52
|
+
assert.equal(s.error, null);
|
|
53
|
+
assert.equal(s.retry, null);
|
|
54
|
+
});
|
|
55
|
+
await ta('a stored value cannot shape the address or a link', () => {
|
|
56
|
+
assert.equal(appUrl('https://evil.example.com'), null);
|
|
57
|
+
assert.equal(appUrl('http://demo.onrender.com'), null, 'https only');
|
|
58
|
+
assert.equal(appUrl('https://demo.onrender.com.evil.io'), null);
|
|
59
|
+
assert.equal(appUrl('javascript:alert(1)'), null);
|
|
60
|
+
assert.equal(serviceLinks('srv-x/../../account'), null);
|
|
61
|
+
assert.equal(serviceLinks(null), null);
|
|
62
|
+
});
|
|
63
|
+
await ta('a failure is ONE sentence, and a Retry re-sends the answers — never a key', () => {
|
|
64
|
+
const s = appStatus({ ...HOST, state: 'failed', error: 'this Render key cannot reach workspace tea-aaaaaaaa1111\nstack…' });
|
|
65
|
+
assert.equal(s.error, 'This Render key cannot reach workspace tea-aaaaaaaa1111.');
|
|
66
|
+
assert.deepEqual(s.retry, { owner_id: 'tea-aaaaaaaa1111', mode: 'greenfield', runtime: 'node', start_command: 'npm start', build_command: 'npm ci', web_plan: 'starter', database: true, db_plan: 'basic_256mb' });
|
|
67
|
+
assert.ok(!('key' in s.retry));
|
|
68
|
+
assert.equal(oneSentence(''), 'The setup stopped without saying why.');
|
|
69
|
+
assert.deepEqual(retryAnswers({ mode: 'adopt', ownerId: 'tea-aaaaaaaa1111', serviceId: 'srv-aaaaaaaa1111' }), { owner_id: 'tea-aaaaaaaa1111', mode: 'adopt', service_id: 'srv-aaaaaaaa1111' });
|
|
70
|
+
assert.deepEqual(retryAnswers({ mode: 'greenfield', ownerId: 'tea-aaaaaaaa1111', runtime: 'docker', startCommand: 'ignored' }), { owner_id: 'tea-aaaaaaaa1111', mode: 'greenfield', runtime: 'docker', database: false });
|
|
71
|
+
});
|
|
72
|
+
await ta('a run still holding the lent key reads as waiting, not as something to retry', () => {
|
|
73
|
+
const s = appStatus({ ...HOST, state: 'requested' }, { keyWaiting: true });
|
|
74
|
+
assert.equal(s.waiting, true);
|
|
75
|
+
assert.equal(s.retry, null);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
console.log('\nGET /provisioning/instances/:id/render:');
|
|
79
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
80
|
+
const envManifest = require('../modules/provisioning/env-manifest.js');
|
|
81
|
+
const api = require('../src/module-api.js');
|
|
82
|
+
let instance = null, builder = null, waiting = false, queries = [];
|
|
83
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
84
|
+
envManifest.listEnvVars = async () => [
|
|
85
|
+
{ name: 'DATABASE_URL', required: true, description: 'x' },
|
|
86
|
+
{ name: 'SESSION_SECRET', required: true },
|
|
87
|
+
{ name: 'STRIPE_KEY', required: false },
|
|
88
|
+
];
|
|
89
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
90
|
+
api.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
|
|
91
|
+
Object.defineProperty(api, 'pool', { configurable: true, get: () => ({ query: async (sql, p) => { queries.push(sql); return { rows: [{ waiting }] }; } }) });
|
|
92
|
+
const express = require('express');
|
|
93
|
+
const buildRouter = require('../modules/provisioning/routes/render-standup.js');
|
|
94
|
+
const app = express();
|
|
95
|
+
app.use(express.json());
|
|
96
|
+
app.use((_req, res, next) => { res.fail = (code, opts) => res.status((opts && opts.status) || (typeof opts === 'number' ? opts : 400)).json({ error: { code } }); next(); });
|
|
97
|
+
app.use('/api/bongos', buildRouter());
|
|
98
|
+
const server = app.listen(0);
|
|
99
|
+
await new Promise((r) => server.once('listening', r));
|
|
100
|
+
const get = (id) => new Promise((resolve, reject) => {
|
|
101
|
+
http.get(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances/${id}/render`, (res) => {
|
|
102
|
+
let d = ''; res.on('data', (c) => { d += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(d || '{}'), raw: d }));
|
|
103
|
+
}).on('error', reject);
|
|
104
|
+
});
|
|
105
|
+
function reset(host) { instance = { owner_builder_id: 7, slug: 'demo', status: 'active', app_host: host }; builder = { id: 7 }; waiting = false; queries = []; }
|
|
106
|
+
|
|
107
|
+
await ta('the owner reads the status, with env var NAMES only, and DATABASE_URL marked as set for them', async () => {
|
|
108
|
+
reset({ ...HOST, state: 'live' });
|
|
109
|
+
const r = await get(18);
|
|
110
|
+
assert.equal(r.status, 200, r.raw);
|
|
111
|
+
assert.equal(r.body.app.stage, 'live');
|
|
112
|
+
assert.deepEqual(r.body.env_vars, [
|
|
113
|
+
{ name: 'DATABASE_URL', required: true, set_for_you: true },
|
|
114
|
+
{ name: 'SESSION_SECRET', required: true, set_for_you: false },
|
|
115
|
+
{ name: 'STRIPE_KEY', required: false, set_for_you: false },
|
|
116
|
+
]);
|
|
117
|
+
assert.equal(r.body.values_held, false);
|
|
118
|
+
assert.doesNotMatch(r.raw, /description|borrowed_key|rnd_/, 'no description, no key column, no key');
|
|
119
|
+
});
|
|
120
|
+
await ta('someone else\'s project — even for an archon — and a missing one are the same 404', async () => {
|
|
121
|
+
reset({ ...HOST, state: 'live' }); builder = { id: 42, rank: 'archon' };
|
|
122
|
+
assert.equal((await get(18)).status, 404);
|
|
123
|
+
reset(null); instance = null;
|
|
124
|
+
assert.equal((await get(18)).status, 404);
|
|
125
|
+
});
|
|
126
|
+
await ta('no platform chosen → app null, and no env list', async () => {
|
|
127
|
+
reset(null);
|
|
128
|
+
const r = await get(18);
|
|
129
|
+
assert.equal(r.status, 200);
|
|
130
|
+
assert.equal(r.body.app, null);
|
|
131
|
+
assert.deepEqual(r.body.env_vars, []);
|
|
132
|
+
});
|
|
133
|
+
await ta('a lent key still waiting on a run reads as waiting', async () => {
|
|
134
|
+
reset({ ...HOST, state: 'requested' }); waiting = true;
|
|
135
|
+
const r = await get(18);
|
|
136
|
+
assert.equal(r.body.app.waiting, true);
|
|
137
|
+
assert.match(queries[0], /borrowed_key_enc IS NOT NULL/, 'asked as a yes/no — the key column is never selected');
|
|
138
|
+
assert.doesNotMatch(queries[0], /SELECT borrowed_key_enc/);
|
|
139
|
+
});
|
|
140
|
+
await ta('the read is capped per builder — two surfaces polling fit well inside it', async () => {
|
|
141
|
+
reset({ ...HOST, state: 'live' }); builder = { id: 777 };
|
|
142
|
+
let last = null;
|
|
143
|
+
for (let i = 0; i < 61; i++) last = await get(18);
|
|
144
|
+
assert.equal(last.status, 429);
|
|
145
|
+
});
|
|
146
|
+
server.close();
|
|
147
|
+
|
|
148
|
+
console.log('\nthe knock (pollers/app-liveness.js):');
|
|
149
|
+
const res = (status, routing) => ({ status, headers: { get: (h) => (h === 'x-render-routing' ? routing || null : null) } });
|
|
150
|
+
await ta('live only when the app answers: not on the no-deploy page, not on a 5xx, not on an error', async () => {
|
|
151
|
+
const f = (r) => async () => r;
|
|
152
|
+
assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(200)) }), true);
|
|
153
|
+
assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(404)) }), true, 'a 404 at the root is still an app answering');
|
|
154
|
+
assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(502, 'no-deploy')) }), false);
|
|
155
|
+
assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(404, 'no-deploy')) }), false);
|
|
156
|
+
assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: f(res(503)) }), false);
|
|
157
|
+
assert.equal(await liveness.appAnswers('https://demo.onrender.com', { fetchImpl: async () => { throw new Error('ECONNRESET'); } }), false);
|
|
158
|
+
});
|
|
159
|
+
await ta('it knocks only on an onrender.com address, and follows no redirect', async () => {
|
|
160
|
+
const seen = [];
|
|
161
|
+
const fetchImpl = async (url, init) => { seen.push({ url, redirect: init.redirect, auth: init.headers.Authorization }); return res(200); };
|
|
162
|
+
assert.equal(await liveness.appAnswers('https://10.0.0.1', { fetchImpl }), false);
|
|
163
|
+
assert.equal(await liveness.appAnswers('https://metadata.google.internal', { fetchImpl }), false);
|
|
164
|
+
assert.equal(seen.length, 0, 'never even asked');
|
|
165
|
+
await liveness.appAnswers('https://demo.onrender.com', { fetchImpl });
|
|
166
|
+
assert.equal(seen[0].redirect, 'manual');
|
|
167
|
+
assert.equal(seen[0].auth, undefined, 'no credential of any kind');
|
|
168
|
+
});
|
|
169
|
+
await ta('a sweep marks only answering apps live, compare-and-set on the state it read', async () => {
|
|
170
|
+
const writes = [];
|
|
171
|
+
const db = { query: async (sql, params) => {
|
|
172
|
+
if (/^\s*SELECT/.test(sql)) {
|
|
173
|
+
assert.match(sql, /app_host->>'state' IN \('deploying', 'linked'\)/);
|
|
174
|
+
return { rows: [
|
|
175
|
+
{ id: 1, slug: 'up', app_host: { state: 'deploying', serviceUrl: 'https://up.onrender.com' } },
|
|
176
|
+
{ id: 2, slug: 'building', app_host: { state: 'deploying', serviceUrl: 'https://building.onrender.com' } },
|
|
177
|
+
{ id: 3, slug: 'linked', app_host: { state: 'linked', serviceUrl: 'https://linked.onrender.com' } },
|
|
178
|
+
] };
|
|
179
|
+
}
|
|
180
|
+
writes.push({ sql: sql.replace(/\s+/g, ' '), params });
|
|
181
|
+
return { rowCount: 1 };
|
|
182
|
+
} };
|
|
183
|
+
const fetchImpl = async (url) => (url.includes('building') ? res(502, 'no-deploy') : res(200));
|
|
184
|
+
const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
|
|
185
|
+
assert.deepEqual(out, { checked: 3, live: 2 });
|
|
186
|
+
assert.deepEqual(writes.map((w) => w.params), [[1, 'deploying'], [3, 'linked']]);
|
|
187
|
+
assert.match(writes[0].sql, /WHERE id = \$1 AND app_host->>'state' = \$2/, 'never overwrites what the runner wrote since');
|
|
188
|
+
});
|
|
189
|
+
await ta('the knocks run a few at a time, never all at once and never one by one', async () => {
|
|
190
|
+
let inFlight = 0, peak = 0;
|
|
191
|
+
const rows = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, slug: `a${i}`, app_host: { state: 'deploying', serviceUrl: `https://a${i}.onrender.com` } }));
|
|
192
|
+
const db = { query: async (sql) => (/^\s*SELECT/.test(sql) ? { rows } : { rowCount: 1 }) };
|
|
193
|
+
const fetchImpl = async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setTimeout(r, 5)); inFlight--; return res(200); };
|
|
194
|
+
const out = await liveness.sweepOnce({ pool: db, fetchImpl, log: () => {} });
|
|
195
|
+
assert.deepEqual(out, { checked: 12, live: 12 });
|
|
196
|
+
assert.equal(peak, 5);
|
|
197
|
+
});
|
|
198
|
+
await ta('the module starts the poller', () => {
|
|
199
|
+
const manifest = JSON.parse(readFileSync(new URL('../modules/provisioning/module.json', import.meta.url), 'utf8'));
|
|
200
|
+
assert.ok(manifest.contributes.pollers.includes('app-liveness'));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
console.log(`\nprovisioning_app_status: ${passed} passed, ${failed} failed`);
|
|
204
|
+
process.exit(failed ? 1 : 0);
|