@bongos/core 1.19.1073 → 1.19.1075
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 +404 -549
- package/.claude/skills/feedback/SKILL.md +2 -2
- package/bin/bongos.js +1 -3
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -60
- package/clients/bongos-client/index.cjs +4 -60
- package/clients/bongos-client/index.d.ts +5 -90
- package/clients/bongos-client/index.mjs +4 -60
- package/config/branding.neutral.json +1 -5
- package/config/modules.neutral.json +2 -3
- package/config/scheduled-routines.json +0 -2
- package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +1 -0
- package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -0
- package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
- package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +1 -1
- package/docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md +2 -0
- package/docs/adr/0345-we-host-every-projects-hall-and-its-app-deploys-where-the-owner-chooses.md +94 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +175 -1665
- package/docs/api-reference.md +9 -41
- package/docs/architecture.md +16 -1
- package/docs/branding-contract.md +0 -3
- package/docs/copy-inventory.md +454 -479
- package/docs/copy-registry.json +746 -978
- package/docs/file-map.md +5 -34
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +13 -1
- package/docs/onboarding/diagrams/04-architecture.mmd +11 -22
- package/docs/onboarding/diagrams/README.md +3 -3
- package/docs/onboarding/diagrams/assertions.json +2 -22
- package/docs/onboarding/primer.md +9 -28
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +1032 -1077
- package/docs/recipes/render-pilot.md +29 -10
- package/migrations/core_257_module_store_registry.sql +87 -0
- package/migrations/core_258_drop_dev_box_tables.sql +55 -0
- package/modules/agents/module.json +1 -0
- package/modules/autonomy/module.json +1 -0
- package/modules/builder-settings/module.json +1 -0
- package/modules/builder-settings/render-prefs.js +4 -5
- package/modules/copy-desk/docx.js +78 -5
- package/modules/copy-desk/module.json +1 -0
- package/modules/copy-desk/routes/copy-desk.js +10 -10
- package/modules/copy-desk/tests/copy_docx.mjs +107 -1
- package/modules/copy-desk/tests/fixtures/word-docx.mjs +30 -0
- package/modules/discord/module.json +1 -0
- package/modules/discord/ship-broadcast.js +3 -3
- package/modules/economy/credits.js +2 -2
- package/modules/economy/module.json +1 -0
- package/modules/economy/routes/credits.js +1 -2
- package/modules/government/catalog.js +13 -17
- package/modules/government/migrations/government_019_retire_box_permissions.sql +33 -0
- package/modules/government/module.json +1 -0
- package/modules/government/protected-surfaces.json +3 -11
- package/modules/government/resolver.js +3 -46
- package/modules/government/routes/government.js +0 -2
- package/modules/government/session-scopes.js +43 -83
- package/modules/government/session-scopes.json +8 -18
- package/modules/grading/module.json +1 -0
- package/modules/hall-ui/module.json +1 -0
- package/modules/hall-ui/public/board-lib.js +2 -2
- package/modules/hall-ui/public/builders.js +29 -40
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/dom-utils.js +1 -1
- package/modules/hall-ui/public/gate.js +2 -2
- package/modules/hall-ui/public/gate.states.json +1 -1
- package/modules/hall-ui/public/hall-render.js +43 -79
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.js +1 -0
- package/modules/hall-ui/public/oversight.css +5 -5
- package/modules/hall-ui/public/palette.js +0 -11
- package/modules/hall-ui/public/primer.js +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-sessions.js +1 -1
- package/modules/hall-ui/public/settings.css +0 -1
- package/modules/hall-ui/public/settings.html +4 -33
- package/modules/hall-ui/public/settings.js +3 -33
- package/modules/hall-ui/public/settings.states.json +2 -2
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +10 -13
- package/modules/hall-ui/public/task.html +4 -1
- package/modules/hall-ui/public/task.js +36 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +18 -1
- package/modules/hall-ui/public/tweak-editor.css +16 -0
- package/modules/hall-ui/public/tweak-editor.html +26 -3
- package/modules/hall-ui/public/tweak-editor.js +32 -1
- package/modules/hall-ui/public/work.js +1 -1
- package/modules/hall-ui/records/tweak-editor.md +1 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/projection.js +1 -1
- package/modules/lifecycle/db-claim-reads.js +1 -58
- package/modules/lifecycle/db-ship.js +4 -9
- package/modules/lifecycle/db.js +0 -4
- package/modules/lifecycle/github-push.js +12 -14
- package/modules/lifecycle/kickoff-checklist.js +0 -4
- package/modules/lifecycle/lifecycle.js +0 -8
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/publish-reconciler.js +10 -10
- package/modules/lifecycle/routes/tasks.js +13 -15
- package/modules/lifecycle/ship-card.js +1 -1
- package/modules/lifecycle/task-visuals.js +2 -2
- package/modules/memory/module.json +1 -0
- package/modules/memory/routes/memory.js +2 -2
- package/modules/npm-release/module.json +1 -0
- package/modules/onboarding/module.json +1 -0
- package/modules/onboarding/onboarding-state.js +17 -36
- package/modules/onboarding/routes/access-requests.js +3 -3
- package/modules/platform-identity/module.json +1 -0
- package/modules/platform-identity/platform-identity.js +13 -5
- package/modules/platform-identity/routes/sso.js +5 -4
- package/modules/platform-identity/tests/platform-identity.mjs +17 -3
- package/modules/provisioning/capacity.js +1 -1
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/provisioning.js +6 -5
- package/modules/provisioning/routes/provisioning.js +4 -4
- package/modules/provisioning/starter-bundles.js +5 -25
- package/modules/public-landing/module.json +1 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/contact.html +1 -1
- package/modules/public-landing/public/privacy.html +1 -1
- package/modules/public-landing/public/projects.html +101 -32
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +4 -3
- package/modules/public-landing/public/terms.html +1 -1
- package/modules/security/module.json +1 -0
- package/modules/sessions/db.js +1 -1
- package/modules/sessions/module.json +1 -0
- package/modules/specialities/module.json +1 -0
- package/modules/status-ui/module.json +1 -0
- package/modules/ui-design/module.json +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +84 -0
- package/scripts/gds/artifact-format.js +203 -0
- package/scripts/gds/artifact-staleness.js +2 -2
- package/scripts/gds/audit-authorship.sh +1 -13
- package/scripts/gds/backfill-task-rewards.js +5 -5
- package/scripts/gds/claim.js +0 -15
- package/scripts/gds/claude-materialize.js +2 -2
- package/scripts/gds/cli-lib.js +15 -15
- package/scripts/gds/codemod-rename-src-gds.js +1 -1
- package/scripts/gds/context-pack.js +5 -7
- package/scripts/gds/diagram-facts.js +12 -23
- package/scripts/gds/do-api.js +13 -80
- package/scripts/gds/doc-cli-guard.js +2 -0
- package/scripts/gds/doctor.js +4 -4
- package/scripts/gds/feedback-latest.js +139 -9
- package/scripts/gds/fitness-checks-identity.js +2 -7
- package/scripts/gds/fitness-checks-packaging.js +4 -4
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/fitness-ratchets.js +5 -5
- package/scripts/gds/fitness.js +10 -10
- package/scripts/gds/gen-api-client.js +4 -4
- package/scripts/gds/gen-api-docs.js +1 -1
- package/scripts/gds/gen-diagrams.js +49 -71
- package/scripts/gds/gen-repo-map.js +13 -14
- package/scripts/gds/gen-session-index.js +8 -8
- package/scripts/gds/http-api-client.js +12 -19
- package/scripts/gds/init.js +1 -1
- package/scripts/gds/leak-scan-allowlist.js +1 -1
- package/scripts/gds/lint-infra-exec.js +1 -1
- package/scripts/gds/local-preview-lib.js +8 -39
- package/scripts/gds/local-preview.js +2 -15
- package/scripts/gds/migration-namespace.js +3 -3
- package/scripts/gds/module-artifact.js +212 -0
- package/scripts/gds/module.js +95 -6
- package/scripts/gds/package-core.js +5 -116
- package/scripts/gds/plain-cards.js +1 -1
- package/scripts/gds/provision-config.js +2 -2
- package/scripts/gds/provision-net.js +2 -3
- package/scripts/gds/provision.js +9 -9
- package/scripts/gds/publish-manifest.js +1 -2
- package/scripts/gds/push-path-brief.js +10 -10
- package/scripts/gds/regen-instance-docs.js +1 -1
- package/scripts/gds/rename-history-check.js +1 -1
- package/scripts/gds/run-unit-tests.js +2 -16
- package/scripts/gds/sandbox-stage.js +39 -341
- package/scripts/gds/session-digest-build.js +2 -2
- package/scripts/gds/ship-deploy-target.js +13 -13
- package/scripts/gds/ship-flow.js +7 -7
- package/scripts/gds/ship-land.js +9 -10
- package/scripts/gds/ship-merge.js +4 -4
- package/scripts/gds/ship-preflight-steps.js +4 -4
- package/scripts/gds/ship-regen.js +15 -49
- package/scripts/gds/ship.js +13 -33
- package/scripts/gds/skill-preflight.js +9 -8
- package/scripts/gds/start.js +0 -15
- package/scripts/hall-preview/README.md +5 -5
- package/scripts/hall-preview/server.js +6 -6
- package/scripts/render-diagrams.sh +1 -1
- package/src/bongos/api-errors.js +1 -1
- package/src/bongos/api-prefix.js +4 -4
- package/src/bongos/auth-github.js +2 -3
- package/src/bongos/auth.js +51 -126
- package/src/bongos/db-kernel.js +2 -3
- package/src/bongos/db.js +5 -28
- package/src/bongos/module-scope-map.js +14 -29
- package/src/bongos/module-store.js +141 -0
- package/src/bongos/platform-visibility-gate.js +1 -1
- package/src/bongos/route-rank-check.js +3 -16
- package/src/bongos/routes/auth.js +0 -116
- package/src/bongos/routes/builders.js +5 -15
- package/src/bongos/routes/instance.js +8 -14
- package/src/bongos/routes/me.js +7 -23
- package/src/bongos/routes/modules.js +83 -0
- package/src/bongos/routes/security.js +3 -3
- package/src/bongos/routes.js +7 -2
- package/src/bongos/serve-internal.js +5 -6
- package/src/bongos-downloads.js +1 -6
- package/src/branding.js +2 -2
- package/src/build-info.js +4 -6
- package/src/instance-config.js +2 -2
- package/src/module-api.js +13 -6
- package/src/module-loader/catalog.js +3 -0
- package/src/module-loader/loader.js +4 -4
- package/src/module-loader/manifest-schema.js +16 -8
- package/src/module-seams.js +2 -3
- package/src/modules.js +42 -14
- package/tests/agents_spend_guard.mjs +1 -1
- package/tests/api_alias_caller_ratchet.mjs +1 -1
- package/tests/api_alias_exceptions.mjs +1 -1
- package/tests/api_client.mjs +1 -16
- package/tests/api_path_404.mjs +1 -1
- package/tests/auth_page_gate.mjs +10 -10
- package/tests/backfill_task_rewards.mjs +1 -1
- package/tests/canonical_profile_url.mjs +1 -3
- package/tests/claim_action.mjs +4 -1
- package/tests/claim_from_task_and_home.mjs +127 -0
- package/tests/{box_ship_permissions.mjs → cli_allowlist_permissions.mjs} +5 -5
- package/tests/cli_sessions.mjs +1 -1
- package/tests/consumer_layout_boot.mjs +2 -0
- package/tests/context_pack.mjs +13 -13
- package/tests/copy_desk_page_docx.mjs +33 -2
- package/tests/copy_inventory.mjs +7 -7
- package/tests/core_upgrade_runner.mjs +6 -6
- package/tests/credit_grant.mjs +2 -2
- package/tests/criterion_suggest.mjs +1 -1
- package/tests/deploy_divergence_line.mjs +11 -11
- package/tests/design_tokens_sync.mjs +3 -2
- package/tests/diagram_facts_offset.mjs +1 -1
- package/tests/do_api.mjs +106 -0
- package/tests/feedback_latest.mjs +122 -0
- package/tests/fitness.mjs +44 -42
- package/tests/fitness_ratchets.mjs +2 -2
- package/tests/gate_approvals.mjs +0 -1
- package/tests/github_push_land_proof.mjs +1 -1
- package/tests/go_live.mjs +3 -3
- package/tests/goal_advisory.mjs +3 -3
- package/tests/goal_suggest.mjs +3 -3
- package/tests/government_abuse_matrix.mjs +21 -19
- package/tests/government_ownership_scope.mjs +33 -35
- package/tests/government_parity.mjs +2 -2
- package/tests/government_protected_surfaces.mjs +56 -37
- package/tests/government_require_permission.mjs +3 -3
- package/tests/government_seed.mjs +37 -4
- package/tests/government_session_scope.mjs +92 -366
- package/tests/hall_audit.mjs +1 -1
- package/tests/hall_error_envelope.mjs +4 -0
- package/tests/hall_landing_boot.mjs +4 -1
- package/tests/hall_palette.mjs +1 -16
- package/tests/hall_record_world.mjs +6 -0
- package/tests/hall_tweak_editor.mjs +52 -1
- package/tests/helpers.mjs +1 -1
- package/tests/host_topology_skips.mjs +3 -4
- package/tests/html_comment_nesting.mjs +137 -0
- package/tests/infra_exec_paths.mjs +9 -10
- package/tests/init.mjs +4 -5
- package/tests/instance_manifest.mjs +18 -25
- package/tests/kickoff_checklist.mjs +1 -1
- package/tests/lib_sh_resolution.mjs +0 -221
- package/tests/local_preview.mjs +0 -12
- package/tests/migration_namespace.mjs +6 -6
- package/tests/module-scope-map.mjs +9 -9
- package/tests/module_api.mjs +2 -2
- package/tests/module_catalog.mjs +1 -1
- package/tests/module_cli.mjs +20 -20
- package/tests/module_contributions.mjs +6 -6
- package/tests/module_loader.mjs +6 -6
- package/tests/module_manifest.mjs +26 -17
- package/tests/module_route_rank.mjs +2 -2
- package/tests/module_store_publish.mjs +307 -0
- package/tests/module_store_publish_route.mjs +121 -0
- package/tests/module_store_registry_migration.mjs +70 -0
- package/tests/modules.mjs +56 -26
- package/tests/modules_route_wiring.mjs +1 -1
- package/tests/onboarding_route_signals.mjs +21 -31
- package/tests/onboarding_state.mjs +35 -49
- package/tests/paste_token_session_store.mjs +1 -1
- package/tests/permission_path.mjs +0 -3
- package/tests/platform_boot.mjs +1 -2
- package/tests/platform_visibility_gate.mjs +1 -1
- package/tests/profile_route.mjs +1 -6
- package/tests/project_modules_ui.mjs +17 -17
- package/tests/projects_hub.mjs +5 -5
- package/tests/projects_hub_app_step.mjs +138 -0
- package/tests/projects_hub_module_picker.mjs +77 -76
- package/tests/projects_hub_pre_uat.mjs +4 -5
- package/tests/provision.mjs +7 -7
- package/tests/provisioning_capacity.mjs +1 -1
- package/tests/provisioning_recommendations.mjs +2 -1
- package/tests/provisioning_starter_bundles.mjs +0 -22
- package/tests/publish_branch_route.mjs +11 -19
- package/tests/publish_manifest.mjs +2 -3
- package/tests/publish_reconciler.mjs +8 -8
- package/tests/push_path_brief.mjs +10 -9
- package/tests/rank_tier_single_source.mjs +1 -13
- package/tests/regrade_eligibility.mjs +1 -1
- package/tests/rename_history_restraint.mjs +1 -1
- package/tests/repo_map.mjs +12 -13
- package/tests/runner_drift.mjs +3 -21
- package/tests/sandbox_stage.mjs +57 -390
- package/tests/seam_wiring_guard.mjs +11 -11
- package/tests/search_isolation.mjs +3 -3
- package/tests/session_records.mjs +1 -1
- package/tests/session_rename_fallback.mjs +1 -22
- package/tests/session_start_freshness.mjs +3 -3
- package/tests/session_token_hash_db.mjs +1 -1
- package/tests/ship_card.mjs +1 -1
- package/tests/ship_ci_deploy.mjs +17 -17
- package/tests/ship_error_shape.mjs +1 -1
- package/tests/ship_premerge.mjs +1 -1
- package/tests/ship_resume.mjs +1 -1
- package/tests/skill_preflight.mjs +2 -2
- package/tests/skip_is_not_pass.mjs +1 -1
- package/tests/task_visual_slots.mjs +31 -0
- package/tests/terms_acceptance.mjs +2 -2
- package/tests/upgrade.mjs +4 -4
- package/tests/watch_sealed_floor.mjs +0 -1
- package/tests/wizard_draft_resume.mjs +2 -2
- package/tests/wizard_intent_resume.mjs +21 -10
- package/tests/wizard_preselect_why.mjs +18 -18
- package/docs/onboarding/browser-terminal-guide.md +0 -73
- package/docs/recipes/managed-settings-remote-control.md +0 -416
- package/modules/dev-box/CLAUDE.md +0 -15
- package/modules/dev-box/box-access.js +0 -623
- package/modules/dev-box/box-credential.js +0 -94
- package/modules/dev-box/box-onboard.js +0 -486
- package/modules/dev-box/boxes.js +0 -1038
- package/modules/dev-box/db.js +0 -39
- package/modules/dev-box/module.json +0 -19
- package/modules/dev-box/routes/box.js +0 -1223
- package/scripts/gds/box-auth-check.js +0 -160
- package/scripts/gds/box-infra.js +0 -321
- package/scripts/gds/box-sync.js +0 -216
- package/scripts/gds/box.js +0 -1332
- package/scripts/gds/cf-tunnel.js +0 -558
- package/scripts/gds/smoke-box.sh +0 -150
- package/scripts/gds/tree-preflight.js +0 -241
- package/src/bongos/app-pair.js +0 -172
- package/tests/app_pair.mjs +0 -168
- package/tests/box_access.mjs +0 -578
- package/tests/box_auth_check.mjs +0 -114
- package/tests/box_code_staleness.mjs +0 -129
- package/tests/box_connect_e2e.mjs +0 -196
- package/tests/box_cost.mjs +0 -70
- package/tests/box_credential.mjs +0 -125
- package/tests/box_dns_repoint.mjs +0 -153
- package/tests/box_env.mjs +0 -64
- package/tests/box_host_keys.mjs +0 -126
- package/tests/box_infra_resolve.mjs +0 -246
- package/tests/box_onboard.mjs +0 -317
- package/tests/box_scope_session.mjs +0 -119
- package/tests/box_sweep_docs.mjs +0 -119
- package/tests/box_sync.mjs +0 -55
- package/tests/box_sync_scope_report.mjs +0 -126
- package/tests/box_task_scope.mjs +0 -169
- package/tests/box_task_scope_pause.mjs +0 -66
- package/tests/box_terminal_ssrf.mjs +0 -96
- package/tests/boxes.mjs +0 -1585
- package/tests/cf_ruleset_preflight.mjs +0 -142
- package/tests/cf_tunnel.mjs +0 -496
- package/tests/docs_scrubber_damage.mjs +0 -111
- package/tests/module_scope_active_claims.mjs +0 -94
- package/tests/ship_api_client_pathspec.mjs +0 -100
- package/tests/tree_preflight.mjs +0 -243
package/scripts/gds/box.js
DELETED
|
@@ -1,1332 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// scripts/gds/box.js — per-builder cloud dev box lifecycle CLI (ADR 0031 §2/§5, #598).
|
|
3
|
-
//
|
|
4
|
-
// The control-plane entrypoint for the box lifecycle. Like provision-staging.sh it
|
|
5
|
-
// must RUN ON A TRUSTED CONTROL PLANE (the prod droplet or an operator's machine),
|
|
6
|
-
// NOT inside a builder box — a box provisioning boxes is privilege escalation, and
|
|
7
|
-
// the builder firewall doesn't even allow-list the DO API. The rank gate reads the
|
|
8
|
-
// builder's LIVE DB rank (what requireRank reads), so it is a real ADR-0016
|
|
9
|
-
// server-side check, not local prose.
|
|
10
|
-
//
|
|
11
|
-
// Subcommands:
|
|
12
|
-
// provision <builder> create + boot a box for a builder (rank-gated)
|
|
13
|
-
// park <builder> snapshot + destroy the droplet (stop compute billing)
|
|
14
|
-
// wake <builder> recreate the droplet from its snapshot
|
|
15
|
-
// deprovision <builder> destroy droplet AND snapshot (full reclaim)
|
|
16
|
-
// sweep-idle PARK every active box idle > BOX_IDLE_MINUTES (snapshot kept)
|
|
17
|
-
// sweep-dormant warn, then deprovision every box parked > BOX_DORMANT_DAYS
|
|
18
|
-
// (the ONLY automated path that destroys a snapshot)
|
|
19
|
-
// status print the box roster + estimated cost
|
|
20
|
-
//
|
|
21
|
-
// <builder> is a GitHub login or a numeric builder id.
|
|
22
|
-
//
|
|
23
|
-
// SAFETY: dry-run is the DEFAULT — nothing touches DigitalOcean or writes the DB
|
|
24
|
-
// without --apply, so run any command bare first to see the plan (as with
|
|
25
|
-
// `pulse.js --release-stale --dry-run`):
|
|
26
|
-
// node scripts/gds/box.js sweep-idle # plan only
|
|
27
|
-
// node scripts/gds/box.js sweep-idle --apply # actually park idle boxes
|
|
28
|
-
//
|
|
29
|
-
// Config (env): DO_API_TOKEN (live only), BOX_PROVISION_MIN_RANK (default xenos),
|
|
30
|
-
// BOX_REGION (nyc3), BOX_SIZE (s-2vcpu-4gb), BOX_BASE_IMAGE (ubuntu-24-04-x64),
|
|
31
|
-
// BOX_IDLE_MINUTES (10), BOX_UNATTENDED_MAX_HOURS (12), BOX_DORMANT_DAYS (14),
|
|
32
|
-
// BOX_SSH_KEY_IDS (comma list),
|
|
33
|
-
// BOX_DNS_HOOK (optional command, gets BOX_HOSTNAME/BOX_IP in env), BOX_TAG.
|
|
34
|
-
|
|
35
|
-
const fs = require('node:fs');
|
|
36
|
-
const path = require('node:path');
|
|
37
|
-
const crypto = require('node:crypto');
|
|
38
|
-
const zlib = require('node:zlib');
|
|
39
|
-
const { execFileSync } = require('node:child_process');
|
|
40
|
-
|
|
41
|
-
// DigitalOcean hard-caps droplet user-data (cloud-init) at 64 KiB. The rendered
|
|
42
|
-
// cloud-init base64-inlines several infra scripts, so it sits close to the cap;
|
|
43
|
-
// #903 (after #898 tipped it over) gzips the biggest one and adds a fail-fast
|
|
44
|
-
// guard. Keep a little headroom under the true 65536 so a stray key/byte is safe.
|
|
45
|
-
const MAX_USERDATA_BYTES = 64 * 1024;
|
|
46
|
-
|
|
47
|
-
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
48
|
-
|
|
49
|
-
// The box cloud-init (infra/builder-box-cloud-init.yaml + its ~9 gz+b64-inlined
|
|
50
|
-
// scripts) is INSTANCE content, deliberately NOT shipped in @bongos/core (the R89
|
|
51
|
-
// packaging split). On a VENDORED instance box.js runs from
|
|
52
|
-
// node_modules/@bongos/core, so REPO_ROOT/infra does not exist — and the box then
|
|
53
|
-
// boots BARE (no clone, no toolchain, no terminal). So resolve infra/ from the
|
|
54
|
-
// INSTANCE root, riding ADR 0108's resolvers, instead of assuming it sits beside
|
|
55
|
-
// box.js. ADR 0150 (BV1.R97/R98).
|
|
56
|
-
const instanceConfig = require(path.join(REPO_ROOT, 'src/instance-config'));
|
|
57
|
-
const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
|
|
58
|
-
const { coreVersionSafe } = require('./provision-config.js');
|
|
59
|
-
const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
|
|
60
|
-
|
|
61
|
-
// Ordered candidate infra/ dirs — first that actually holds the cloud-init
|
|
62
|
-
// template wins. Pure (env + repoRoot injected) so it is unit-testable without
|
|
63
|
-
// touching the real filesystem layout.
|
|
64
|
-
const { CF, CONFIG, boxInfraCandidates, dropletName, hasFlag, hostnameFor, numEnv, readCloudInit, resolveBoxInfraDir, runDnsHook, runDnsHookBlackhole, shortStamp } = require('./box-infra.js');
|
|
65
|
-
function namedTunnelEnabled() { return CF.enabled; }
|
|
66
|
-
function namedTunnelConfigured() { return CF.enabled && CF.apiToken && CF.accountId; }
|
|
67
|
-
|
|
68
|
-
function makeCf(apply) {
|
|
69
|
-
const { CfTunnelApi } = loadDeps();
|
|
70
|
-
return new CfTunnelApi({
|
|
71
|
-
dryRun: !apply,
|
|
72
|
-
token: CF.apiToken,
|
|
73
|
-
accountId: CF.accountId,
|
|
74
|
-
log: (m) => console.log(' ' + m),
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// SECURITY PREFLIGHT (task 1002511, ADR 0053 / SR#11). The edge cookie-strip
|
|
79
|
-
// Transform Rule is applied BEST-EFFORT deep inside ensureNamedTunnel, so a
|
|
80
|
-
// CLOUDFLARE_API_TOKEN missing the zone "Transform Rules" edit scope fails as one
|
|
81
|
-
// buried log line while the apex session cookie keeps reaching box subdomains. This
|
|
82
|
-
// probe runs BEFORE any DigitalOcean spend (dry-run included — a read-only lookup in
|
|
83
|
-
// a plan is the drift-reconcile precedent) and shouts the exact fix.
|
|
84
|
-
//
|
|
85
|
-
// HONESTY: the probe is a GET, so it can only DISPROVE the scope. A readable phase
|
|
86
|
-
// does NOT mean the rule can be written — only the PUT inside ensureNamedTunnel
|
|
87
|
-
// proves that. Never print this as a confirmed control. Never blocks provisioning:
|
|
88
|
-
// the rule is defense-in-depth and a box without it still works.
|
|
89
|
-
async function preflightCookieStripScope() {
|
|
90
|
-
const { cfTunnel } = loadDeps();
|
|
91
|
-
let scope;
|
|
92
|
-
try {
|
|
93
|
-
scope = await cfTunnel.checkRulesetScope({
|
|
94
|
-
zone: CF.zone, phase: cfTunnel.COOKIE_STRIP_PHASE, token: CF.apiToken,
|
|
95
|
-
});
|
|
96
|
-
} catch (e) {
|
|
97
|
-
scope = { readable: false, reason: `ruleset preflight crashed: ${e.message}`, guidance: '' };
|
|
98
|
-
}
|
|
99
|
-
if (scope.readable) {
|
|
100
|
-
console.log(` cookie-strip ruleset is readable on ${CF.zone} — edit scope stays UNPROVEN until the rule is applied (ADR 0053)`);
|
|
101
|
-
return scope;
|
|
102
|
-
}
|
|
103
|
-
console.log('');
|
|
104
|
-
console.log(' ⚠⚠ SECURITY (ADR 0053 / SR#11) — the edge cookie-strip rule cannot be confirmed');
|
|
105
|
-
console.log(` ${scope.reason}`);
|
|
106
|
-
console.log(` IMPACT: the apex session cookie (Domain=.${CF.zone}) is sent by the browser to`);
|
|
107
|
-
console.log(` term-*.${CF.terminalZone} and sandbox-*.${CF.terminalZone} — hosts OUTSIDE the`);
|
|
108
|
-
console.log(' ADR 0016 trust boundary — until that Transform Rule is actually in place.');
|
|
109
|
-
if (scope.guidance) console.log(` FIX: ${scope.guidance}`);
|
|
110
|
-
console.log(' Provisioning CONTINUES — this is defense-in-depth, never a provisioning gate.');
|
|
111
|
-
console.log('');
|
|
112
|
-
return scope;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Lazily-built handles to the heavy dependencies (DB pool, DO/CF clients), so a
|
|
116
|
-
// dry-run or a --help never opens a pool or requires a network client.
|
|
117
|
-
//
|
|
118
|
-
// task 1003507 — this was UNDECLARED. `_deps = {...}` further down is an
|
|
119
|
-
// assignment, which in sloppy mode would have created a global, but
|
|
120
|
-
// `if (_deps)` READS it first, and reading an undeclared identifier is a
|
|
121
|
-
// ReferenceError. So loadDeps() threw on its first call, every time, and every
|
|
122
|
-
// command that reaches makeDo()/makeCf() went with it — including
|
|
123
|
-
// `sweep-idle --apply`, which resolves its DO client before the park loop.
|
|
124
|
-
// The idle sweep therefore could not park ANY box, which is a second and
|
|
125
|
-
// entirely separate reason the abandoned box in this task was never reaped.
|
|
126
|
-
// No test caught it because the only apply:true test relied on the
|
|
127
|
-
// claude_active veto emptying the idle set before makeDo() was reached.
|
|
128
|
-
let _deps = null;
|
|
129
|
-
|
|
130
|
-
function loadDeps() {
|
|
131
|
-
if (_deps) return _deps;
|
|
132
|
-
// eslint-disable-next-line global-require
|
|
133
|
-
const { pool } = require(path.join(REPO_ROOT, 'src/bongos/pool'));
|
|
134
|
-
// eslint-disable-next-line global-require
|
|
135
|
-
const boxes = require(path.join(REPO_ROOT, 'modules/dev-box/boxes'));
|
|
136
|
-
// eslint-disable-next-line global-require
|
|
137
|
-
const boxAccess = require(path.join(REPO_ROOT, 'modules/dev-box/box-access'));
|
|
138
|
-
// eslint-disable-next-line global-require
|
|
139
|
-
const boxOnboard = require(path.join(REPO_ROOT, 'modules/dev-box/box-onboard'));
|
|
140
|
-
// eslint-disable-next-line global-require
|
|
141
|
-
const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
|
|
142
|
-
// eslint-disable-next-line global-require
|
|
143
|
-
const cfTunnel = require(path.join(REPO_ROOT, 'scripts/gds/cf-tunnel'));
|
|
144
|
-
_deps = { pool, boxes, boxAccess, boxOnboard, DoApi, CfTunnelApi: cfTunnel.CfTunnelApi, cfTunnel };
|
|
145
|
-
return _deps;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function makeDo(apply) {
|
|
149
|
-
const { DoApi } = loadDeps();
|
|
150
|
-
return new DoApi({
|
|
151
|
-
dryRun: !apply,
|
|
152
|
-
token: process.env.DO_API_TOKEN || null,
|
|
153
|
-
log: (m) => console.log(' ' + m),
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// A LIVE read-only DO client for enumerating droplets in the drift reconcile —
|
|
158
|
-
// listing is non-mutating, so it runs for real even in dry-run planning (only the
|
|
159
|
-
// DELETEs are gated by --apply, via the separate makeDo(apply) client). Returns
|
|
160
|
-
// null when DO_API_TOKEN is unset (this is a control-plane command; the token
|
|
161
|
-
// always lives on the prod box) so the caller can fail with a clear message
|
|
162
|
-
// instead of the constructor's throw.
|
|
163
|
-
function makeDoRead() {
|
|
164
|
-
const token = process.env.DO_API_TOKEN || null;
|
|
165
|
-
if (!token) return null;
|
|
166
|
-
const { DoApi } = loadDeps();
|
|
167
|
-
return new DoApi({ dryRun: false, token, log: (m) => console.log(' ' + m) });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Best-effort outbound ping to the team Discord (mirrors ship.js's broadcast).
|
|
171
|
-
// OUTBOUND messaging lives in scripts/discord/ as of ADR 0032/#607 — scripts/chat/
|
|
172
|
-
// is inbound-only now, so this must NOT route through chat/post.js (it would fail
|
|
173
|
-
// silently and the builder would never learn their box was reclaimed). Never throws.
|
|
174
|
-
function postReclaimPing(text) {
|
|
175
|
-
try {
|
|
176
|
-
execFileSync('node', ['scripts/discord/post.js', text], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
177
|
-
return { ok: true };
|
|
178
|
-
} catch (e) {
|
|
179
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Resolve a builder by login or numeric id. Returns the full row or null.
|
|
184
|
-
async function resolveBuilder(pool, ref) {
|
|
185
|
-
const isId = /^\d+$/.test(String(ref));
|
|
186
|
-
const { rows } = await pool.query(
|
|
187
|
-
isId
|
|
188
|
-
? `SELECT id, github_login, display_name, rank, status FROM builders WHERE id = $1`
|
|
189
|
-
: `SELECT id, github_login, display_name, rank, status FROM builders WHERE lower(github_login) = lower($1)`,
|
|
190
|
-
[ref]
|
|
191
|
-
);
|
|
192
|
-
return rows[0] || null;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// A short, DNS-safe-ish suffix from the current time (CLI context — Date is fine).
|
|
196
|
-
// ---- provision -------------------------------------------------------------
|
|
197
|
-
|
|
198
|
-
// Pure: renders the /etc/otb/box.env content cloud-init bakes onto a box (task
|
|
199
|
-
// 1002466). Split out of cmdProvision so GDS_BASE — and the named-tunnel block
|
|
200
|
-
// baked in beside it — can be unit-tested without DB/DO/Cloudflare.
|
|
201
|
-
//
|
|
202
|
-
// GDS_BASE is the FULL api base (origin + /api/bongos), matching what
|
|
203
|
-
// infra/box-report-*.sh already derive from the session file when the env var is
|
|
204
|
-
// absent (`"$GDS_BASE/box/host-keys"` with no further path inserted) — every box
|
|
205
|
-
// used to get this only as a fallback derived on-box; baking it explicitly closes
|
|
206
|
-
// the one on-box script (box-source-fetch.sh, out-of-repo) that skips that
|
|
207
|
-
// derivation entirely and hardcodes a different instance's origin instead.
|
|
208
|
-
function buildBoxEnv({ apiOrigin, namedTunnel }) {
|
|
209
|
-
let env = `# /etc/otb/box.env — baked by box.js provision\nGDS_BASE=${String(apiOrigin).replace(/\/+$/, '')}/api/bongos\n`;
|
|
210
|
-
if (namedTunnel) {
|
|
211
|
-
const { token, hostname, credential, previewHostname } = namedTunnel;
|
|
212
|
-
env += `# named tunnel (#771/#774, ADR 0038)\nBOX_TUNNEL_TOKEN=${token}\nBOX_TERMINAL_HOSTNAME=${hostname}\nBOX_TERMINAL_CREDENTIAL=${credential}\nBOX_PREVIEW_HOSTNAME=${previewHostname}\n`;
|
|
213
|
-
}
|
|
214
|
-
return env;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
|
|
218
|
-
const builder = await resolveBuilder(pool, ref);
|
|
219
|
-
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
220
|
-
if (builder.status && builder.status !== 'active') {
|
|
221
|
-
return fail(`builder @${builder.github_login} is ${builder.status}, not active — no box`);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Rank gate (ADR 0016 trust boundary: checked against the live DB rank).
|
|
225
|
-
try {
|
|
226
|
-
boxes.assertProvisionAllowed({ rank: builder.rank, floor: CONFIG.floor });
|
|
227
|
-
} catch (e) {
|
|
228
|
-
return fail(`${e.message}`);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Dry-run must touch nothing (#1034): READ the row instead of ensuring it, so a
|
|
232
|
-
// bare `provision` (no --apply) cannot write a placeholder builder_boxes row. A
|
|
233
|
-
// missing row is semantically 'none' — and 'none' is a valid provision source —
|
|
234
|
-
// so map null→'none' for the gate (NOT the `!box || …` short-circuit the
|
|
235
|
-
// park/wake paths use: those forbid 'none', but provision allows it). The row is
|
|
236
|
-
// materialized under --apply below, mirroring wake/park/deprovision (all read via
|
|
237
|
-
// getBoxByBuilderId).
|
|
238
|
-
let box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
239
|
-
const boxState = box ? box.state : 'none';
|
|
240
|
-
if (!boxes.canTransition('provision', boxState)) {
|
|
241
|
-
return fail(`box for @${builder.github_login} is '${boxState}' — provision only from none/destroyed/error (try wake?)`);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const scope = boxes.boxScopeForRank(builder.rank);
|
|
245
|
-
const name = dropletName(builder.github_login);
|
|
246
|
-
const hostname = hostnameFor(builder.github_login);
|
|
247
|
-
|
|
248
|
-
// Bake the builder's registered SSH keys (#685) into the cloud-init so the
|
|
249
|
-
// FIRST login works before the box has a GDS token. No keys yet → an empty
|
|
250
|
-
// managed block (the box-authorized-keys-fetch cron fills it in once the
|
|
251
|
-
// builder authenticates; onboard warns about this case upstream).
|
|
252
|
-
const sshKeys = await boxOnboard.listSshKeys(pool, builder.id);
|
|
253
|
-
const authorizedKeysBlock = boxOnboard.wrapManagedBlock(sshKeys);
|
|
254
|
-
|
|
255
|
-
console.log(`provision box for @${builder.github_login} (${builder.rank} → scope=${scope})`);
|
|
256
|
-
console.log(` region=${CONFIG.region} size=${CONFIG.size} image=${CONFIG.baseImage} host=${hostname}`);
|
|
257
|
-
console.log(` baked SSH keys: ${sshKeys.length}${sshKeys.length === 0 ? ' (⚠ builder cannot SSH in until a key is registered — see `box.js onboard`)' : ''}`);
|
|
258
|
-
|
|
259
|
-
// ADR 0150 — resolve the box cloud-init source up front (dry-run AND apply) and
|
|
260
|
-
// FAIL LOUD if it can't be located. A missing infra/ means empty user-data, i.e.
|
|
261
|
-
// a box that boots BARE. Aborting here (before any session/tunnel is minted)
|
|
262
|
-
// surfaces the packaging bug in the PLAN, not as a silent bare box hours later.
|
|
263
|
-
const infraDir = resolveBoxInfraDir();
|
|
264
|
-
if (!infraDir) {
|
|
265
|
-
return fail(
|
|
266
|
-
`box cloud-init source not found — no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
|
|
267
|
-
+ `(<PREFIX>_BOX_INFRA_DIR, <PREFIX>_INSTANCE_ROOT / bongos.config.json, ${path.join(REPO_ROOT, 'infra')}, or a node_modules ancestor). `
|
|
268
|
-
+ `A box would boot BARE — refusing to provision. Ship infra/ or point the instance root at it (ADR 0150).`,
|
|
269
|
-
);
|
|
270
|
-
}
|
|
271
|
-
console.log(` cloud-init source: ${infraDir}`);
|
|
272
|
-
|
|
273
|
-
// Named-tunnel automation (#771, ADR 0038 Part B). OPT-IN: off → the box uses
|
|
274
|
-
// the tokenless quick tunnel (#760 default) and box.env is left empty. On but
|
|
275
|
-
// misconfigured → fail loud now (an operator who turned it on meant it).
|
|
276
|
-
if (namedTunnelEnabled() && !namedTunnelConfigured()) {
|
|
277
|
-
return fail('BOX_NAMED_TUNNEL is on but CLOUDFLARE_API_TOKEN and/or CLOUDFLARE_ACCOUNT_ID are not set — supply both (token needs Tunnel + DNS scope) or unset BOX_NAMED_TUNNEL for the quick-tunnel default');
|
|
278
|
-
}
|
|
279
|
-
const termHost = namedTunnelEnabled() ? `term-${builder.github_login}.${CF.terminalZone}` : null;
|
|
280
|
-
if (namedTunnelEnabled()) {
|
|
281
|
-
console.log(` named tunnel: ${termHost} (Cloudflare zone ${CF.zone})`);
|
|
282
|
-
// Before ANY droplet spend, and on the dry-run path too, so `provision <login>`
|
|
283
|
-
// with no --apply tells the operator the truth for free (task 1002511).
|
|
284
|
-
await preflightCookieStripScope();
|
|
285
|
-
} else {
|
|
286
|
-
console.log(' browser terminal: quick tunnel (tokenless trycloudflare.com — set BOX_NAMED_TUNNEL=1 for a stable hostname)');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (!apply) {
|
|
290
|
-
console.log(' [dry-run] would create droplet + mark active. Re-run with --apply.');
|
|
291
|
-
if (namedTunnelEnabled()) await makeCf(false).ensureNamedTunnel({ login: builder.github_login, zone: CF.zone, terminalZone: CF.terminalZone });
|
|
292
|
-
return { ok: true, dryRun: true };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// --apply only: NOW materialize the box row (the dry-run path above left the DB
|
|
296
|
-
// untouched, #1034). ensureBoxRow is idempotent — returns the existing row or
|
|
297
|
-
// creates a fresh state='none' one — so box.id / box.provisioned_at below are
|
|
298
|
-
// always real. Identical to the pre-#1034 --apply behavior; nothing between the
|
|
299
|
-
// gate above and here reads or writes the box row.
|
|
300
|
-
box = await boxes.ensureBoxRow(pool, builder.id);
|
|
301
|
-
|
|
302
|
-
// Create (or reuse) the named tunnel BEFORE the droplet so its connector token
|
|
303
|
-
// bakes into the cloud-init box.env. A failure here aborts provision — better
|
|
304
|
-
// no box than a box that boots without its promised stable terminal.
|
|
305
|
-
// #774: on the named-tunnel path the control plane knows everything the browser
|
|
306
|
-
// on-ramp needs at provision time — the stable hostname (here) + a password we
|
|
307
|
-
// generate. So bake the password into box.env (the box serves ttyd with it) and,
|
|
308
|
-
// once the box is active, RECORD {url, password} into the GDS ourselves
|
|
309
|
-
// (setTerminalAccess): the builder reads them from the hall, with no token placed
|
|
310
|
-
// on the box and no box-side reporter (the quick-tunnel fallback that caused the
|
|
311
|
-
// #771/#774 chicken-and-egg). ADR 0016 holds — no credential funneled into a box.
|
|
312
|
-
// GDS_BASE (task 1002466 — see buildBoxEnv above for the shape/why): a box was
|
|
313
|
-
// never actually bare here EXCEPT the one on-box script that skips the
|
|
314
|
-
// session-file fallback (box-source-fetch.sh, out-of-repo on the control-plane
|
|
315
|
-
// — see the cross-repo blocker filed alongside this fix). Baking it explicitly
|
|
316
|
-
// closes that gap for every future box regardless of which script forgets it.
|
|
317
|
-
const apiOrigin = instanceConfig.resolveEnv('PUBLIC_ORIGIN') || instanceConfig.defaultApiBase();
|
|
318
|
-
let boxEnv = buildBoxEnv({ apiOrigin });
|
|
319
|
-
let terminalHostname = null;
|
|
320
|
-
let terminalCredential = null;
|
|
321
|
-
if (namedTunnelEnabled()) {
|
|
322
|
-
try {
|
|
323
|
-
const { token, hostname: th, previewHostname: ph, edgeRules } = await makeCf(true).ensureNamedTunnel({
|
|
324
|
-
login: builder.github_login, zone: CF.zone, terminalZone: CF.terminalZone,
|
|
325
|
-
});
|
|
326
|
-
terminalHostname = th;
|
|
327
|
-
terminalCredential = crypto.randomBytes(24).toString('hex');
|
|
328
|
-
// ADR 0044: ensureNamedTunnel also created the sandbox-<login> ingress route
|
|
329
|
-
// + DNS for the live game preview. Bake its hostname so the box's
|
|
330
|
-
// box-preview command can show the builder their URL (no DB row needed — the
|
|
331
|
-
// host is deterministic; box-game-preview.service serves :3000 behind it).
|
|
332
|
-
boxEnv = buildBoxEnv({
|
|
333
|
-
apiOrigin,
|
|
334
|
-
namedTunnel: { token, hostname: th, credential: terminalCredential, previewHostname: ph },
|
|
335
|
-
});
|
|
336
|
-
console.log(` ✓ named tunnel ready — ${th}`);
|
|
337
|
-
console.log(` ✓ live game preview — https://${ph} (ADR 0044)`);
|
|
338
|
-
// The rule PUT is the ONLY thing that proves edit scope. Report its real
|
|
339
|
-
// outcome next to the ✓ lines so a silent miss lands in the summary rather
|
|
340
|
-
// than mid-log (task 1002511).
|
|
341
|
-
if (edgeRules && !edgeRules.cookieStrip.applied) {
|
|
342
|
-
console.log(` ⚠⚠ SECURITY (ADR 0053 / SR#11): the edge cookie-strip Transform Rule was NOT applied${edgeRules.cookieStrip.error ? ` — ${edgeRules.cookieStrip.error}` : ''}`);
|
|
343
|
-
console.log(` Platform cookies for .${CF.zone} can reach ${th} and ${ph} until it is.`);
|
|
344
|
-
console.log(' Widen CLOUDFLARE_API_TOKEN with zone "Transform Rules → Edit", or apply the rule');
|
|
345
|
-
console.log(' once by hand — see docs/recipes/builder-box-lifecycle.md.');
|
|
346
|
-
}
|
|
347
|
-
if (edgeRules && !edgeRules.cacheBypass.applied) {
|
|
348
|
-
console.log(` ⚠ the sandbox cache-bypass Cache Rule was NOT applied${edgeRules.cacheBypass.error ? ` — ${edgeRules.cacheBypass.error}` : ''} (stale preview risk, #1065)`);
|
|
349
|
-
}
|
|
350
|
-
} catch (e) {
|
|
351
|
-
await boxes.setBoxState(pool, builder.id, 'error', { error_note: `named tunnel: ${e.message}` });
|
|
352
|
-
return fail(`named tunnel setup failed: ${e.message}`);
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Bootstrap the builder's GDS session onto the box (#818). box-source-fetch (#600)
|
|
357
|
-
// authenticates as the BUILDER to pull the rank-scoped repo; with no session file
|
|
358
|
-
// it no-ops forever (repo never clones → no devcontainer, no /builder-* skills),
|
|
359
|
-
// and the session normally comes from /builder-setup, which lives in the un-cloned
|
|
360
|
-
// repo — the chicken-and-egg. So mint one here and bake it into cloud-init.
|
|
361
|
-
//
|
|
362
|
-
// SECURITY (#919, SR#2): this baked session is source='box' — SCOPED, 24h
|
|
363
|
-
// refresh-on-use. The token sits ON the box (any on-box process + the metadata
|
|
364
|
-
// endpoint can read it), OUTSIDE the ADR 0016 trust boundary, so it must not carry
|
|
365
|
-
// the builder's full authority: requireBuilder denies a 'box' session everywhere
|
|
366
|
-
// except the 4 on-box cron routes (source-access, authorized-keys, heartbeat,
|
|
367
|
-
// terminal — via auth.allowBoxScope). Real work FROM the box needs /builder-reauth
|
|
368
|
-
// (device flow); user_agent='box-provision' is the marker migration 090 keys on.
|
|
369
|
-
const gdsDb = require(path.join(REPO_ROOT, 'src/bongos/db'));
|
|
370
|
-
const sessionToken = crypto.randomBytes(32).toString('hex');
|
|
371
|
-
await gdsDb.createSession({
|
|
372
|
-
builderId: builder.id, token: sessionToken, source: 'box', userAgent: 'box-provision',
|
|
373
|
-
});
|
|
374
|
-
const gdsSession = JSON.stringify({
|
|
375
|
-
token: sessionToken,
|
|
376
|
-
builder: { id: builder.id, github_login: builder.github_login, rank: builder.rank },
|
|
377
|
-
// The instance's OWN public origin (task 1002428) — a hardcoded founder host
|
|
378
|
-
// here would point every newly provisioned box at the wrong instance.
|
|
379
|
-
//
|
|
380
|
-
// defaultApiBase(), deliberately NOT resolveApiBase() (task 1002466): a box
|
|
381
|
-
// belongs to the SERVER it was provisioned on, not to whichever instance this
|
|
382
|
-
// operator's own CLI session happens to be signed into right now — the two can
|
|
383
|
-
// differ (an operator provisioning boxes across instances) and the box must
|
|
384
|
-
// still call home to itself, not to the operator's other login.
|
|
385
|
-
api_base: instanceConfig.resolveEnv('PUBLIC_ORIGIN') || instanceConfig.defaultApiBase(),
|
|
386
|
-
}, null, 2);
|
|
387
|
-
|
|
388
|
-
const doApi = makeDo(apply);
|
|
389
|
-
// Render + size-check the cloud-init BEFORE touching DO (#903). DO rejects
|
|
390
|
-
// user-data over 64 KiB with an opaque error; fail fast with an actionable one
|
|
391
|
-
// so an over-budget cloud-init is caught here, not as a cryptic API 4xx.
|
|
392
|
-
let userData;
|
|
393
|
-
try {
|
|
394
|
-
userData = readCloudInit({ authorizedKeysBlock, boxEnv, gdsSession, infraDir });
|
|
395
|
-
} catch (e) {
|
|
396
|
-
await boxes.setBoxState(pool, builder.id, 'error', { error_note: `cloud-init: ${e.message}`.slice(0, 480) });
|
|
397
|
-
return fail(e.message);
|
|
398
|
-
}
|
|
399
|
-
const userDataBytes = userData ? Buffer.byteLength(userData, 'utf8') : 0;
|
|
400
|
-
// Belt-and-braces (ADR 0150): readCloudInit throws above, but NEVER hand DO empty
|
|
401
|
-
// user-data — that IS the bare-box failure this fix closes.
|
|
402
|
-
if (!userDataBytes) {
|
|
403
|
-
await boxes.setBoxState(pool, builder.id, 'error', { error_note: 'cloud-init render produced empty user-data' });
|
|
404
|
-
return fail('cloud-init render produced EMPTY user-data — a box would boot bare (ADR 0150); refusing to provision.');
|
|
405
|
-
}
|
|
406
|
-
if (userDataBytes > MAX_USERDATA_BYTES) {
|
|
407
|
-
await boxes.setBoxState(pool, builder.id, 'error', {
|
|
408
|
-
error_note: `cloud-init ${userDataBytes}B > ${MAX_USERDATA_BYTES}B`,
|
|
409
|
-
});
|
|
410
|
-
return fail(`cloud-init user-data is ${userDataBytes}B, over DigitalOcean's ${MAX_USERDATA_BYTES}B limit — trim infra/box-source-fetch.sh or gzip more of the inlined files (infra/builder-box-cloud-init.yaml)`);
|
|
411
|
-
}
|
|
412
|
-
await boxes.setBoxState(pool, builder.id, 'provisioning', {
|
|
413
|
-
scope, provisioned_rank: builder.rank, region: CONFIG.region, size_slug: CONFIG.size,
|
|
414
|
-
droplet_name: name, hostname, error_note: null,
|
|
415
|
-
// The droplet about to boot mints FRESH host keys, so anything the row still
|
|
416
|
-
// carries describes a box that is gone. deprovisionOne clears these on teardown
|
|
417
|
-
// (idea 310), but a provision over a row that never went through teardown — an
|
|
418
|
-
// errored/parked box, a re-provision after a manual DO delete — kept serving the
|
|
419
|
-
// predecessor's identity via GET /box/me, and the connect path pinned it: "Host
|
|
420
|
-
// denied (verification failed)". Clear here too; the new box re-reports via
|
|
421
|
-
// POST /box/host-keys, and until then the client falls back to a live keyscan.
|
|
422
|
-
host_keys: null, host_keys_at: null,
|
|
423
|
-
});
|
|
424
|
-
try {
|
|
425
|
-
const { droplet } = await doApi.createDroplet({
|
|
426
|
-
name, region: CONFIG.region, size: CONFIG.size, image: CONFIG.baseImage,
|
|
427
|
-
userData, tags: [CONFIG.tag, `builder:${builder.github_login}`],
|
|
428
|
-
sshKeys: CONFIG.sshKeyIds.length ? CONFIG.sshKeyIds : undefined,
|
|
429
|
-
});
|
|
430
|
-
const { ip } = await doApi.settleDroplet(droplet.id);
|
|
431
|
-
const now = new Date();
|
|
432
|
-
await boxes.setBoxState(pool, builder.id, 'active', {
|
|
433
|
-
droplet_id: String(droplet.id), ip, snapshot_id: null,
|
|
434
|
-
provisioned_at: box.provisioned_at || now, active_since: now, last_activity_at: now,
|
|
435
|
-
});
|
|
436
|
-
await boxes.recordEvent(pool, {
|
|
437
|
-
boxId: box.id, builderId: builder.id, event: 'provision',
|
|
438
|
-
detail: `droplet ${droplet.id} ${ip || ''} ${CONFIG.size}`, actor: actorTag(),
|
|
439
|
-
});
|
|
440
|
-
// #602: log a spin-up event in the per-builder cost ledger.
|
|
441
|
-
await boxes.recordSpinupCost(pool, { box, kind: 'provision', atMs: now.getTime(), actor: actorTag() });
|
|
442
|
-
// #774: record the browser-terminal access NOW (named-tunnel path) so the hall
|
|
443
|
-
// serves it immediately — no waiting on the box to report, no token on the box.
|
|
444
|
-
// The box brings ttyd up at first boot with this same password (baked above).
|
|
445
|
-
if (terminalHostname && terminalCredential) {
|
|
446
|
-
try {
|
|
447
|
-
await boxes.setTerminalAccess(pool, builder.id, {
|
|
448
|
-
url: `https://${terminalHostname}`, credential: terminalCredential,
|
|
449
|
-
});
|
|
450
|
-
console.log(` ✓ terminal access recorded — https://${terminalHostname}`);
|
|
451
|
-
} catch (e) {
|
|
452
|
-
console.log(` ⚠ could not record terminal access (non-fatal): ${e.message}`);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
const dns = runDnsHook(hostname, ip);
|
|
456
|
-
if (!dns.ok) {
|
|
457
|
-
// task 1180: a stale DNS must NOT pass as a clean provision. Record it so the
|
|
458
|
-
// box is queryable as DNS-stale (the box still provisions — failure-open).
|
|
459
|
-
await boxes.recordEvent(pool, {
|
|
460
|
-
boxId: box.id, builderId: builder.id, event: 'error',
|
|
461
|
-
detail: `dns_repoint_failed: ${dns.error || 'DNS hook failed'} (${hostname} -> ${ip})`,
|
|
462
|
-
actor: actorTag(),
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
console.log(` ✓ active — droplet ${droplet.id}${ip ? ` @ ${ip}` : ''}${dns.ok ? '' : ' ⚠ DNS STALE — Desktop SSH will fail until repointed (browser terminal unaffected)'}`);
|
|
466
|
-
return { ok: true };
|
|
467
|
-
} catch (e) {
|
|
468
|
-
await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
|
|
469
|
-
await boxes.recordEvent(pool, { boxId: box.id, builderId: builder.id, event: 'error', detail: e.message, actor: actorTag() });
|
|
470
|
-
return fail(`provision failed: ${e.message}`);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// ---- onboard (the operator one-shot: provision + welcome packet) -----------
|
|
475
|
-
|
|
476
|
-
// `box.js onboard <login>` is the operator's single command for #685: it
|
|
477
|
-
// provisions the box (baking the builder's already-registered SSH keys) and then
|
|
478
|
-
// prints a copy-paste "welcome packet" to forward to the builder. The canonical
|
|
479
|
-
// order is: builder runs box-connect (registers key + installs managed-settings)
|
|
480
|
-
// → operator runs onboard → builder restarts Desktop and connects. Because the
|
|
481
|
-
// box can't fetch its own authorized_keys until the builder has a token ON the
|
|
482
|
-
// box (chicken-and-egg), onboard REQUIRES at least one registered key by default;
|
|
483
|
-
// --allow-no-keys overrides for an operator who knows they'll re-provision later.
|
|
484
|
-
// The instance's product name, for the Claude Desktop environment label the
|
|
485
|
-
// connect instructions name (task 1002428). Neutral fallback, never a foreign brand.
|
|
486
|
-
function boxEnvLabel() {
|
|
487
|
-
try {
|
|
488
|
-
const id = require(path.join(REPO_ROOT, 'src/branding')).branding().identity || {};
|
|
489
|
-
if (id.productName) return id.productName;
|
|
490
|
-
} catch (_) { /* fall through */ }
|
|
491
|
-
return 'Cloud Bongos';
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function gdsBase() {
|
|
495
|
-
// Session-aware, via the single source of truth (task 1002427/1002428) — the
|
|
496
|
-
// operator's signed-in instance, not a hardcoded founder host. <PREFIX>_API_BASE
|
|
497
|
-
// (incl. legacy GDS_API_BASE) still wins, so existing overrides are unchanged.
|
|
498
|
-
return String(instanceConfig.resolveApiBase()).replace(/\/+$/, '');
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function printWelcomePacket(login, scope) {
|
|
502
|
-
const base = gdsBase();
|
|
503
|
-
const host = hostnameFor(login);
|
|
504
|
-
console.log('');
|
|
505
|
-
console.log(`──────── welcome packet for @${login} — forward to the builder ────────`);
|
|
506
|
-
console.log('');
|
|
507
|
-
console.log('If you have not already connected this device, run ONE of these on your');
|
|
508
|
-
console.log('own Mac/Windows (it signs you in, makes + registers your SSH key, and');
|
|
509
|
-
console.log('installs the Claude Desktop connection file):');
|
|
510
|
-
console.log('');
|
|
511
|
-
console.log(` macOS/Linux: curl -fsSL ${base}/api/bongos/box/connect.sh | bash`);
|
|
512
|
-
console.log(` Windows: irm ${base}/api/bongos/box/connect.ps1 | iex`);
|
|
513
|
-
console.log(` (guide: ${base}/api/bongos/box/connect )`);
|
|
514
|
-
console.log('');
|
|
515
|
-
console.log(`Then: restart Claude Desktop → Code → Environment → "${boxEnvLabel()} Dev Box".`);
|
|
516
|
-
console.log('');
|
|
517
|
-
console.log('On a Chromebook / browser-only device (no Claude Desktop)? Skip the above —');
|
|
518
|
-
console.log('your box serves its own terminal in a browser tab (ADR 0038). Once it is');
|
|
519
|
-
console.log('active, get the URL + password and open it:');
|
|
520
|
-
console.log(` GET ${base}/api/bongos/box/terminal (or run /builder-box)`);
|
|
521
|
-
console.log('In that terminal, type `claude` to start working.');
|
|
522
|
-
console.log('');
|
|
523
|
-
console.log(`Your box: ${host} (scope=${scope})`);
|
|
524
|
-
console.log('Your memory + credits are server-side and follow you to any box (ADR 0024).');
|
|
525
|
-
console.log('───────────────────────────────────────────────────────────────────────');
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
async function cmdOnboard(pool, boxes, boxOnboard, ref, { apply, allowNoKeys }) {
|
|
529
|
-
const builder = await resolveBuilder(pool, ref);
|
|
530
|
-
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
531
|
-
|
|
532
|
-
const keys = await boxOnboard.listSshKeys(pool, builder.id);
|
|
533
|
-
if (keys.length === 0 && !allowNoKeys) {
|
|
534
|
-
console.error(`✖ @${builder.github_login} has no registered SSH key yet — the box would have no way in.`);
|
|
535
|
-
console.error('');
|
|
536
|
-
console.error(' Ask the builder to connect FIRST (this registers their key), then re-run onboard:');
|
|
537
|
-
console.error(` macOS/Linux: curl -fsSL ${gdsBase()}/api/bongos/box/connect.sh | bash`);
|
|
538
|
-
console.error(` Windows: irm ${gdsBase()}/api/bongos/box/connect.ps1 | iex`);
|
|
539
|
-
console.error('');
|
|
540
|
-
console.error(' (Override with --allow-no-keys to provision a keyless box anyway — you will');
|
|
541
|
-
console.error(' then have to re-provision once they register a key.)');
|
|
542
|
-
return fail('no SSH key registered for this builder');
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
console.log(`onboard @${builder.github_login} — provision + welcome packet (${keys.length} SSH key(s) registered)`);
|
|
546
|
-
const result = await cmdProvision(pool, boxes, boxOnboard, ref, { apply });
|
|
547
|
-
if (result && result.ok === false) return result;
|
|
548
|
-
|
|
549
|
-
const scope = boxes.boxScopeForRank(builder.rank);
|
|
550
|
-
printWelcomePacket(builder.github_login, scope);
|
|
551
|
-
if (!apply) console.log('\n[dry-run] no box created — re-run with --apply to provision for real.');
|
|
552
|
-
return { ok: true, dryRun: !apply };
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// ---- park (snapshot + destroy) ---------------------------------------------
|
|
556
|
-
|
|
557
|
-
async function parkOne(pool, boxes, doApi, box, { actor }) {
|
|
558
|
-
const now = Date.now();
|
|
559
|
-
await boxes.setBoxState(pool, box.builder_id, 'parking');
|
|
560
|
-
// Power off for a clean snapshot, snapshot, wait, then destroy the droplet.
|
|
561
|
-
await doApi.powerOffDroplet(box.droplet_id).then((r) => r.action && doApi.waitForAction(r.action.id));
|
|
562
|
-
const snapName = `otb-snap-${box.droplet_name || box.builder_id}-${shortStamp()}`;
|
|
563
|
-
const snapRes = await doApi.snapshotDroplet(box.droplet_id, snapName);
|
|
564
|
-
if (snapRes.action) await doApi.waitForAction(snapRes.action.id);
|
|
565
|
-
// Resolve the new snapshot id from the droplet's snapshot_ids (live mode).
|
|
566
|
-
let snapshotId = null;
|
|
567
|
-
if (!doApi.dryRun) {
|
|
568
|
-
const { droplet } = await doApi.getDroplet(box.droplet_id);
|
|
569
|
-
const ids = (droplet && droplet.snapshot_ids) || [];
|
|
570
|
-
snapshotId = ids.length ? String(ids[ids.length - 1]) : null;
|
|
571
|
-
} else {
|
|
572
|
-
snapshotId = `dryrun-snap-${box.builder_id}`;
|
|
573
|
-
}
|
|
574
|
-
// Accrue the compute cost for this active interval BEFORE we lose active_since.
|
|
575
|
-
await boxes.recordComputeCost(pool, { box, untilMs: now, actor });
|
|
576
|
-
await doApi.deleteDroplet(box.droplet_id);
|
|
577
|
-
await boxes.setBoxState(pool, box.builder_id, 'parked', {
|
|
578
|
-
snapshot_id: snapshotId, parked_at: new Date(now),
|
|
579
|
-
droplet_id: null, ip: null, active_since: null,
|
|
580
|
-
// task 1003507 — the droplet is gone, so there is no `claude` process to
|
|
581
|
-
// report. Clearing the flag AND its clock here means a wake starts a fresh
|
|
582
|
-
// session rather than inheriting an expired latch and being reaped at once.
|
|
583
|
-
claude_active: false, claude_active_since: null,
|
|
584
|
-
});
|
|
585
|
-
await boxes.recordEvent(pool, {
|
|
586
|
-
boxId: box.id, builderId: box.builder_id, event: 'park',
|
|
587
|
-
detail: `snapshot ${snapshotId}; droplet ${box.droplet_id} destroyed`, actor,
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
async function cmdPark(pool, boxes, ref, { apply }) {
|
|
592
|
-
const builder = await resolveBuilder(pool, ref);
|
|
593
|
-
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
594
|
-
const box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
595
|
-
if (!box || !boxes.canTransition('park', box.state)) {
|
|
596
|
-
return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — park only from active`);
|
|
597
|
-
}
|
|
598
|
-
console.log(`park box for @${builder.github_login} (droplet ${box.droplet_id})`);
|
|
599
|
-
if (!apply) { console.log(' [dry-run] would snapshot + destroy droplet. Re-run with --apply.'); return { ok: true, dryRun: true }; }
|
|
600
|
-
try {
|
|
601
|
-
await parkOne(pool, boxes, makeDo(apply), box, { actor: actorTag() });
|
|
602
|
-
console.log(' ✓ parked (snapshot kept; droplet destroyed — compute billing stopped)');
|
|
603
|
-
return { ok: true };
|
|
604
|
-
} catch (e) {
|
|
605
|
-
await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
|
|
606
|
-
return fail(`park failed: ${e.message}`);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// ---- wake (recreate from snapshot) -----------------------------------------
|
|
611
|
-
|
|
612
|
-
async function cmdWake(pool, boxes, ref, { apply }) {
|
|
613
|
-
const builder = await resolveBuilder(pool, ref);
|
|
614
|
-
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
615
|
-
const box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
616
|
-
if (!box || !boxes.canTransition('wake', box.state)) {
|
|
617
|
-
return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — wake only from parked`);
|
|
618
|
-
}
|
|
619
|
-
if (!box.snapshot_id) return fail(`parked box for @${builder.github_login} has no snapshot_id — cannot wake (deprovision + re-provision)`);
|
|
620
|
-
console.log(`wake box for @${builder.github_login} (from snapshot ${box.snapshot_id})`);
|
|
621
|
-
if (!apply) { console.log(' [dry-run] would recreate droplet from snapshot. Re-run with --apply.'); return { ok: true, dryRun: true }; }
|
|
622
|
-
const doApi = makeDo(apply);
|
|
623
|
-
await boxes.setBoxState(pool, builder.id, 'waking');
|
|
624
|
-
try {
|
|
625
|
-
const { droplet } = await doApi.createDropletFromSnapshot({
|
|
626
|
-
name: box.droplet_name || dropletName(builder.github_login),
|
|
627
|
-
region: box.region, size: box.size_slug, snapshotId: box.snapshot_id,
|
|
628
|
-
tags: [CONFIG.tag, `builder:${builder.github_login}`],
|
|
629
|
-
sshKeys: CONFIG.sshKeyIds.length ? CONFIG.sshKeyIds : undefined,
|
|
630
|
-
});
|
|
631
|
-
const { ip } = await doApi.settleDroplet(droplet.id);
|
|
632
|
-
const now = new Date();
|
|
633
|
-
await boxes.setBoxState(pool, builder.id, 'active', {
|
|
634
|
-
droplet_id: String(droplet.id), ip, active_since: now, last_activity_at: now, parked_at: null,
|
|
635
|
-
// task 1003507 — a woken box has reported nothing yet. Start the latch and
|
|
636
|
-
// its clock clean so the first real heartbeat sets both, rather than the
|
|
637
|
-
// box re-entering the sweep carrying a stale claim from its last session.
|
|
638
|
-
claude_active: false, claude_active_since: null,
|
|
639
|
-
});
|
|
640
|
-
await boxes.recordEvent(pool, { boxId: box.id, builderId: builder.id, event: 'wake', detail: `droplet ${droplet.id} ${ip || ''}`, actor: actorTag() });
|
|
641
|
-
// #602: the box just ended a PARKED interval (snapshot retained) — accrue its
|
|
642
|
-
// storage cost before active_since restarts compute, then log the wake spin-up.
|
|
643
|
-
// `box` is the pre-wake row, so it still carries parked_at.
|
|
644
|
-
await boxes.recordStorageCost(pool, { box, untilMs: now.getTime(), actor: actorTag() });
|
|
645
|
-
await boxes.recordSpinupCost(pool, { box, kind: 'wake', atMs: now.getTime(), actor: actorTag() });
|
|
646
|
-
const wakeHost = box.hostname || hostnameFor(builder.github_login);
|
|
647
|
-
const dns = runDnsHook(wakeHost, ip);
|
|
648
|
-
if (!dns.ok) {
|
|
649
|
-
await boxes.recordEvent(pool, {
|
|
650
|
-
boxId: box.id, builderId: builder.id, event: 'error',
|
|
651
|
-
detail: `dns_repoint_failed: ${dns.error || 'DNS hook failed'} (${wakeHost} -> ${ip})`,
|
|
652
|
-
actor: actorTag(),
|
|
653
|
-
});
|
|
654
|
-
}
|
|
655
|
-
console.log(` ✓ active — droplet ${droplet.id}${ip ? ` @ ${ip}` : ''}${dns.ok ? '' : ' ⚠ DNS STALE — Desktop SSH will fail until repointed (browser terminal unaffected)'}`);
|
|
656
|
-
return { ok: true };
|
|
657
|
-
} catch (e) {
|
|
658
|
-
await boxes.setBoxState(pool, builder.id, 'error', { error_note: e.message });
|
|
659
|
-
return fail(`wake failed: ${e.message}`);
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// ---- deprovision (destroy droplet + snapshot) ------------------------------
|
|
664
|
-
|
|
665
|
-
// Idempotent delete with a few retries for transient DigitalOcean blips. A 404
|
|
666
|
-
// means the resource is ALREADY gone — that is success, not failure (the whole
|
|
667
|
-
// point of an idempotent teardown). Returns { ok:true } once the resource is
|
|
668
|
-
// confirmed absent, or { ok:false, error } after the retry budget is spent on a
|
|
669
|
-
// real (non-404) failure. `sleepFn` is injectable so tests drive it instantly.
|
|
670
|
-
async function deleteWithRetry(fn, { tries = 3, sleepMs = 2000, sleepFn } = {}) {
|
|
671
|
-
const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
672
|
-
let lastErr = null;
|
|
673
|
-
for (let i = 0; i < tries; i++) {
|
|
674
|
-
try {
|
|
675
|
-
await fn();
|
|
676
|
-
return { ok: true, alreadyGone: false };
|
|
677
|
-
} catch (e) {
|
|
678
|
-
if (e && e.status === 404) return { ok: true, alreadyGone: true };
|
|
679
|
-
lastErr = e;
|
|
680
|
-
if (i < tries - 1) await sleep(sleepMs);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
return { ok: false, error: lastErr || new Error('delete failed') };
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// CONFIRM-BEFORE-DESTROYED (task 1289). The old teardown swallowed a failed
|
|
687
|
-
// droplet delete (`.catch(log)`) and then set state='destroyed' UNCONDITIONALLY,
|
|
688
|
-
// nulling droplet_id — so a transient DO failure left a droplet running + billing
|
|
689
|
-
// + SSH-reachable while the DB claimed it was gone (the zombie). Now the droplet
|
|
690
|
-
// delete is the GATE: it is retried + idempotent, and if it genuinely fails the box
|
|
691
|
-
// is parked at state='error' with its droplet_id PRESERVED (so it's queryable and
|
|
692
|
-
// the drift reconcile can find it) and the function THROWS — the row never reaches
|
|
693
|
-
// 'destroyed' until DO confirms the droplet is gone. Snapshot/tunnel/DNS stay
|
|
694
|
-
// best-effort (a leftover snapshot is cost-only; the box is already gone for the
|
|
695
|
-
// tunnel/DNS), but the snapshot delete is now idempotent too.
|
|
696
|
-
async function deprovisionOne(pool, boxes, doApi, box, { actor, login, sleepFn } = {}) {
|
|
697
|
-
const now = Date.now();
|
|
698
|
-
if (box.state === 'active' && box.droplet_id) {
|
|
699
|
-
await boxes.recordComputeCost(pool, { box, untilMs: now, actor });
|
|
700
|
-
}
|
|
701
|
-
// #602: deprovisioning a PARKED box ends a snapshot-storage interval — accrue it
|
|
702
|
-
// (no-op for an active/never-parked box, where parked_at is null).
|
|
703
|
-
if (box.parked_at) {
|
|
704
|
-
await boxes.recordStorageCost(pool, { box, untilMs: now, actor });
|
|
705
|
-
}
|
|
706
|
-
if (box.droplet_id) {
|
|
707
|
-
const r = await deleteWithRetry(() => doApi.deleteDroplet(box.droplet_id), { sleepFn });
|
|
708
|
-
if (!r.ok) {
|
|
709
|
-
// Do NOT mark 'destroyed' — that is the bug. Park at 'error' with droplet_id
|
|
710
|
-
// intact so the row stays truthful (a droplet is still out there) and throw.
|
|
711
|
-
const msg = (r.error && r.error.message) || 'droplet delete failed';
|
|
712
|
-
await boxes.setBoxState(pool, box.builder_id, 'error', {
|
|
713
|
-
error_note: `droplet ${box.droplet_id} delete failed (not destroyed): ${msg}`,
|
|
714
|
-
});
|
|
715
|
-
await boxes.recordEvent(pool, {
|
|
716
|
-
boxId: box.id, builderId: box.builder_id, event: 'error',
|
|
717
|
-
detail: `deprovision aborted: droplet ${box.droplet_id} delete failed: ${msg}`, actor,
|
|
718
|
-
});
|
|
719
|
-
throw r.error || new Error(msg);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
if (box.snapshot_id) {
|
|
723
|
-
const sr = await deleteWithRetry(() => doApi.deleteSnapshot(box.snapshot_id), { sleepFn });
|
|
724
|
-
if (!sr.ok) console.log(` ⚠ snapshot delete (left behind, cost-only): ${(sr.error && sr.error.message) || 'failed'}`);
|
|
725
|
-
}
|
|
726
|
-
// Named-tunnel teardown (#771, ADR 0038 Part B). Best-effort — the box is gone
|
|
727
|
-
// by now, so a leftover tunnel/DNS is only cosmetic, never a blocker. The
|
|
728
|
-
// droplet was destroyed above, dropping the connector connections, so the
|
|
729
|
-
// tunnel deletes cleanly. login is the deterministic key (tunnel name + host).
|
|
730
|
-
const who = login || box.github_login;
|
|
731
|
-
if (namedTunnelConfigured() && who) {
|
|
732
|
-
try {
|
|
733
|
-
const res = await makeCf(!doApi.dryRun).teardownNamedTunnel({ login: who, zone: CF.zone, terminalZone: CF.terminalZone });
|
|
734
|
-
if (res.tunnelDeleted || res.dnsDeleted) console.log(` ✓ named tunnel torn down (tunnel=${res.tunnelDeleted} dns=${res.dnsDeleted})`);
|
|
735
|
-
for (const err of res.errors) console.log(` ⚠ tunnel teardown ${err}`);
|
|
736
|
-
} catch (e) { console.log(` ⚠ named tunnel teardown: ${e.message}`); }
|
|
737
|
-
}
|
|
738
|
-
// task 1180 + idea 309: park the per-builder SSH A record at the blackhole
|
|
739
|
-
// sentinel (don't delete it) after teardown. This still stops the record
|
|
740
|
-
// dangling at a recycled DO IP (the amplifier behind the stale-DNS outage),
|
|
741
|
-
// AND it avoids the NXDOMAIN gap that a delete-then-recreate opened on a
|
|
742
|
-
// reap→rebuild (idea 309): a deleted name negative-caches for the zone neg-TTL
|
|
743
|
-
// (~30 min), so a client that queried mid-gap kept getting ENOTFOUND long after
|
|
744
|
-
// the box was back. The name now always resolves (to a non-routable sentinel),
|
|
745
|
-
// so there's nothing to negative-cache; the next provision/wake repoints it to
|
|
746
|
-
// the live IP in place. Best-effort/non-fatal, like the tunnel teardown above;
|
|
747
|
-
// box.hostname was stamped at provision, and the browser terminal rides the
|
|
748
|
-
// tunnel, not this record.
|
|
749
|
-
const dnsHost = box.hostname || (who ? hostnameFor(who) : null);
|
|
750
|
-
if (CONFIG.dnsHook && dnsHost) runDnsHookBlackhole(dnsHost);
|
|
751
|
-
await boxes.setBoxState(pool, box.builder_id, 'destroyed', {
|
|
752
|
-
destroyed_at: new Date(now), droplet_id: null, ip: null, snapshot_id: null,
|
|
753
|
-
active_since: null, parked_at: null, claude_active: false, claude_active_since: null,
|
|
754
|
-
// idea 310: a rebuilt box mints fresh SSH host keys, but the GDS row kept the
|
|
755
|
-
// OLD keys until the new box re-reported (~10 min). In that window GET /box/me
|
|
756
|
-
// served a stale identity and the desktop app pinned the WRONG key — the
|
|
757
|
-
// "Host denied (verification failed)" that locked builders out. Clear them on
|
|
758
|
-
// teardown; the next box re-reports via POST /box/host-keys on provision. (A
|
|
759
|
-
// park→wake keeps its keys via the snapshot, so this only fires on full destroy.)
|
|
760
|
-
host_keys: null, host_keys_at: null,
|
|
761
|
-
});
|
|
762
|
-
// Post-'destroyed' audit-log write. The teardown is already COMMITTED above
|
|
763
|
-
// (droplet confirmed gone, row at 'destroyed', ids nulled), so this event is
|
|
764
|
-
// best-effort like the snapshot/tunnel/DNS steps — it MUST NOT throw. A failed
|
|
765
|
-
// audit write here used to propagate to the caller's catch (the sweep /
|
|
766
|
-
// deprovision / reconcile loops), which then flipped the just-committed
|
|
767
|
-
// 'destroyed' back to 'error' with a null droplet_id — the false-error that
|
|
768
|
-
// stranded builder 95 on 2026-07-04 (task 2034). Swallow + log instead; the
|
|
769
|
-
// markErrorUnlessDestroyed guard below is the second line of defence for any
|
|
770
|
-
// OTHER post-commit write (e.g. the reconcile path's recordSourceEvent).
|
|
771
|
-
try {
|
|
772
|
-
await boxes.recordEvent(pool, {
|
|
773
|
-
boxId: box.id, builderId: box.builder_id, event: 'deprovision',
|
|
774
|
-
detail: `droplet ${box.droplet_id || '-'} + snapshot ${box.snapshot_id || '-'} destroyed`, actor,
|
|
775
|
-
});
|
|
776
|
-
} catch (e) {
|
|
777
|
-
console.log(` ⚠ teardown complete (state=destroyed) but the deprovision audit event failed to record: ${e.message}`);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// A teardown that has already COMMITTED state='destroyed' must never be flipped
|
|
782
|
-
// back to 'error' by a LATER failure. deprovisionOne commits 'destroyed' and then
|
|
783
|
-
// runs best-effort follow-ups (its own recordEvent — now wrapped — and, on the
|
|
784
|
-
// reconcile path, recordSourceEvent); if one of those throws it lands in the
|
|
785
|
-
// caller's catch, which would otherwise overwrite the good 'destroyed' with
|
|
786
|
-
// 'error' and a null droplet_id (the false-error that stranded builder 95,
|
|
787
|
-
// 2026-07-04 — task 2034). This guard re-reads the row and only parks at 'error'
|
|
788
|
-
// when the box did NOT reach 'destroyed'. Never throws (it runs inside a catch).
|
|
789
|
-
async function markErrorUnlessDestroyed(pool, boxes, builderId, errNote) {
|
|
790
|
-
try {
|
|
791
|
-
const current = await boxes.getBoxByBuilderId(pool, builderId);
|
|
792
|
-
if (current && current.state === 'destroyed') {
|
|
793
|
-
console.log(` ⚠ teardown completed (state=destroyed); ignoring a post-teardown failure: ${errNote}`);
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
await boxes.setBoxState(pool, builderId, 'error', { error_note: errNote });
|
|
797
|
-
} catch (e) {
|
|
798
|
-
console.log(` ⚠ could not record error state for builder ${builderId}: ${e.message}`);
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
async function cmdDeprovision(pool, boxes, ref, { apply }) {
|
|
803
|
-
const builder = await resolveBuilder(pool, ref);
|
|
804
|
-
if (!builder) return fail(`no builder matches "${ref}"`);
|
|
805
|
-
const box = await boxes.getBoxByBuilderId(pool, builder.id);
|
|
806
|
-
if (!box || !boxes.canTransition('deprovision', box.state)) {
|
|
807
|
-
return fail(`box for @${builder.github_login} is '${box ? box.state : 'none'}' — nothing to deprovision`);
|
|
808
|
-
}
|
|
809
|
-
console.log(`deprovision box for @${builder.github_login} (droplet ${box.droplet_id || '-'}, snapshot ${box.snapshot_id || '-'})`);
|
|
810
|
-
if (!apply) { console.log(' [dry-run] would destroy droplet AND snapshot. Re-run with --apply.'); return { ok: true, dryRun: true }; }
|
|
811
|
-
try {
|
|
812
|
-
await deprovisionOne(pool, boxes, makeDo(apply), box, { actor: actorTag(), login: builder.github_login });
|
|
813
|
-
console.log(' ✓ destroyed (droplet + snapshot gone; all box cost stopped)');
|
|
814
|
-
return { ok: true };
|
|
815
|
-
} catch (e) {
|
|
816
|
-
await markErrorUnlessDestroyed(pool, boxes, builder.id, e.message);
|
|
817
|
-
return fail(`deprovision failed: ${e.message}`);
|
|
818
|
-
}
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// ---- sweep-idle ------------------------------------------------------------
|
|
822
|
-
// Idle boxes are PARKED: snapshot taken, droplet destroyed (compute billing
|
|
823
|
-
// stops), filesystem preserved so the builder wakes into their work. Snapshot
|
|
824
|
-
// storage is ~$0.30/mo — the price of not silently discarding uncommitted work.
|
|
825
|
-
//
|
|
826
|
-
// This sweep deliberately never destroys a snapshot. Full reclaim (droplet AND
|
|
827
|
-
// snapshot) belongs to sweep-dormant, which only fires after BOX_DORMANT_DAYS of
|
|
828
|
-
// being parked and pings the builder first. That split is the whole safety
|
|
829
|
-
// property: no automated path can take work off a builder without warning.
|
|
830
|
-
//
|
|
831
|
-
// History: this sweep used to call deprovisionOne directly, on the theory that
|
|
832
|
-
// boxes are ephemeral and builders provision fresh each session. In practice it
|
|
833
|
-
// meant a box idle past the threshold was annihilated — droplet and snapshot —
|
|
834
|
-
// taking anything uncommitted with it, while the CLI help, the /builder-box
|
|
835
|
-
// skill and the hall all still described park-and-wake. Task 1002726 restored
|
|
836
|
-
// the documented behaviour rather than the documentation.
|
|
837
|
-
|
|
838
|
-
async function cmdSweepIdle(pool, boxes, { apply }) {
|
|
839
|
-
const rows = await boxes.listBoxes(pool, { state: 'active' });
|
|
840
|
-
const sweepNow = Date.now();
|
|
841
|
-
const sel = { idleMinutes: CONFIG.idleMinutes, nowMs: sweepNow, unattendedMaxHours: CONFIG.unattendedMaxHours };
|
|
842
|
-
const idle = boxes.selectIdleBoxes(rows, sel);
|
|
843
|
-
console.log(`idle sweep (threshold=${CONFIG.idleMinutes}m, unattended cap=${CONFIG.unattendedMaxHours}h${apply ? '' : ', dry-run'}): ${idle.length} of ${rows.length} active box(es) idle`);
|
|
844
|
-
if (idle.length === 0) { console.log(' nothing idle — exit clean'); return { parked: 0 }; }
|
|
845
|
-
const doApi = apply ? makeDo(apply) : null;
|
|
846
|
-
let parked = 0, errors = 0;
|
|
847
|
-
for (const box of idle) {
|
|
848
|
-
const why = boxes.idleReason(box, sel);
|
|
849
|
-
if (!apply) { console.log(` would park @${box.github_login}'s box (${why}, droplet ${box.droplet_id})`); continue; }
|
|
850
|
-
try {
|
|
851
|
-
await parkOne(pool, boxes, doApi, box, { actor: 'sweep:idle' });
|
|
852
|
-
parked++;
|
|
853
|
-
console.log(` ✓ parked @${box.github_login} (${why} — snapshot kept, compute stopped)`);
|
|
854
|
-
} catch (e) {
|
|
855
|
-
// A park that fails must NOT fall through to a destroy — leaving the box
|
|
856
|
-
// at 'error' with its droplet intact is strictly safer than reclaiming it,
|
|
857
|
-
// and reconcile-drift picks the row up from there.
|
|
858
|
-
errors++;
|
|
859
|
-
await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
|
|
860
|
-
console.log(` ✖ @${box.github_login}: ${e.message}`);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
console.log(`done — parked=${parked} errors=${errors} dry-run=${!apply}`);
|
|
864
|
-
return { parked, errors };
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// ---- sweep-dormant ---------------------------------------------------------
|
|
868
|
-
// Task 1002727 — since the idle sweep parks rather than destroys (task 1002726),
|
|
869
|
-
// this is the ONLY automated path that deletes a snapshot, i.e. the only one that
|
|
870
|
-
// can take a builder's uncommitted work. So it warns before it takes: a box
|
|
871
|
-
// entering the warning window gets one heads-up, and a box past the threshold is
|
|
872
|
-
// reclaimed ONLY if that warning was actually delivered during its current parked
|
|
873
|
-
// interval. If it never was — runner downtime, a chat outage, a box that sailed
|
|
874
|
-
// past the window while the sweep was off — the reclaim is DEFERRED and the
|
|
875
|
-
// warning is sent instead. That way "no automated path silently discards work"
|
|
876
|
-
// holds even when the machinery itself misbehaves.
|
|
877
|
-
|
|
878
|
-
async function cmdSweepDormant(pool, boxes, { apply }) {
|
|
879
|
-
const rows = await boxes.listBoxes(pool, { state: 'parked' });
|
|
880
|
-
const now = Date.now();
|
|
881
|
-
const dormant = boxes.selectDormantBoxes(rows, { dormantDays: CONFIG.dormantDays, nowMs: now });
|
|
882
|
-
const warnAt = Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0);
|
|
883
|
-
const warning = boxes.selectDormantWarnBoxes(rows, {
|
|
884
|
-
dormantDays: CONFIG.dormantDays, warnLeadDays: CONFIG.dormantWarnLeadDays, nowMs: now,
|
|
885
|
-
});
|
|
886
|
-
console.log(`dormant sweep (reclaim >${CONFIG.dormantDays}d, warn >${warnAt}d${apply ? '' : ', dry-run'}): ${dormant.length} dormant, ${warning.length} entering the warning window, of ${rows.length} parked`);
|
|
887
|
-
if (dormant.length === 0 && warning.length === 0) { console.log(' nothing dormant — exit clean'); return { reclaimed: 0, warned: 0, deferred: 0 }; }
|
|
888
|
-
// Built lazily: a sweep that only WARNS must not require a DO token.
|
|
889
|
-
let doApi = null;
|
|
890
|
-
const needDo = () => (doApi || (doApi = makeDo(apply)));
|
|
891
|
-
let reclaimed = 0, errors = 0, pingFailed = 0, warned = 0, deferred = 0;
|
|
892
|
-
|
|
893
|
-
const sendWarning = async (box, days) => {
|
|
894
|
-
const left = Math.max(CONFIG.dormantDays - days, 0);
|
|
895
|
-
const ping = postReclaimPing(`@${box.github_login} — your dev box has been parked ${days} days. Its saved disk image will be reclaimed in ${left} day(s) to save cost, and anything on it you haven't pushed will be gone with it. Wake it any time with POST /api/bongos/box/ensure to keep it.`);
|
|
896
|
-
if (!ping.ok) { pingFailed++; return false; }
|
|
897
|
-
await boxes.recordEvent(pool, {
|
|
898
|
-
boxId: box.id, builderId: box.builder_id, event: 'dormant_warning',
|
|
899
|
-
detail: `parked ${days}d; reclaim in ${left}d`, actor: 'sweep:dormant',
|
|
900
|
-
});
|
|
901
|
-
warned++;
|
|
902
|
-
return true;
|
|
903
|
-
};
|
|
904
|
-
|
|
905
|
-
for (const box of warning) {
|
|
906
|
-
const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
|
|
907
|
-
if (!apply) { console.log(` would warn @${box.github_login} (parked ${days}d)`); continue; }
|
|
908
|
-
if (await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at })) continue;
|
|
909
|
-
const ok = await sendWarning(box, days);
|
|
910
|
-
console.log(` ${ok ? '✓ warned' : '✖ warning FAILED for'} @${box.github_login} (parked ${days}d)`);
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
for (const box of dormant) {
|
|
914
|
-
const days = Math.round((now - Date.parse(box.parked_at)) / 86400000);
|
|
915
|
-
if (!apply) { console.log(` would reclaim @${box.github_login}'s box (parked ${days}d, snapshot ${box.snapshot_id})`); continue; }
|
|
916
|
-
// The gate: no receipt, no reclaim. Warn now, let a later sweep take it.
|
|
917
|
-
if (!(await boxes.hasDormantWarning(pool, { boxId: box.id, parkedAt: box.parked_at }))) {
|
|
918
|
-
deferred++;
|
|
919
|
-
const ok = await sendWarning(box, days);
|
|
920
|
-
console.log(` ⏸ deferred @${box.github_login} (parked ${days}d) — never warned; ${ok ? 'warning sent, reclaim next sweep' : 'warning FAILED, will retry'}`);
|
|
921
|
-
continue;
|
|
922
|
-
}
|
|
923
|
-
try {
|
|
924
|
-
await deprovisionOne(pool, boxes, needDo(), box, { actor: 'sweep:dormant' });
|
|
925
|
-
reclaimed++;
|
|
926
|
-
const ping = postReclaimPing(`@${box.github_login} — your dev box was parked for ${days} days and has now been reclaimed to save cost, as warned. Queue a fresh one anytime with POST /api/bongos/box/ensure (no operator needed); your memory is server-side and unaffected.`);
|
|
927
|
-
if (!ping.ok) pingFailed++;
|
|
928
|
-
console.log(` ✓ reclaimed @${box.github_login} (parked ${days}d)${ping.ok ? '' : ' [chat ping failed]'}`);
|
|
929
|
-
} catch (e) { errors++; await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message); console.log(` ✖ @${box.github_login}: ${e.message}`); }
|
|
930
|
-
}
|
|
931
|
-
console.log(`done — reclaimed=${reclaimed} warned=${warned} deferred=${deferred} ping-failed=${pingFailed} errors=${errors} dry-run=${!apply}`);
|
|
932
|
-
return { reclaimed, warned, deferred, errors, pingFailed };
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
// ---- reconcile (source-access clawback) ------------------------------------
|
|
936
|
-
|
|
937
|
-
// The control-plane half of rank-gated source access (ADR 0031 §6, #600). The
|
|
938
|
-
// CREDENTIAL layer is already gated live on every GET /box/source-access fetch, so
|
|
939
|
-
// revocation is instant there; this is the slower droplet-level reconcile that
|
|
940
|
-
// brings each box into line with its builder's LIVE rank+status:
|
|
941
|
-
// - below the floor / builder inactive → deprovision (no box, no source)
|
|
942
|
-
// - 'full' box whose builder fell below Metic+ → deprovision (can't un-see the
|
|
943
|
-
// crown jewels; a fresh starter box can be provisioned)
|
|
944
|
-
// - 'starter' box whose builder graduated to Metic+ → rescope to 'full' (the box
|
|
945
|
-
// re-clones the full surface on its next source-access refresh)
|
|
946
|
-
// Run it on a schedule (alongside the sweeps) for hands-off offboarding. DRY-RUN
|
|
947
|
-
// DEFAULT — pass --apply to actually deprovision / rescope.
|
|
948
|
-
async function cmdReconcile(pool, boxes, boxAccess, { apply }) {
|
|
949
|
-
const rows = await boxAccess.listBoxesForReconcile(pool);
|
|
950
|
-
const actions = rows
|
|
951
|
-
.map((box) => ({
|
|
952
|
-
box,
|
|
953
|
-
decision: boxAccess.reconcileSourceAccess({
|
|
954
|
-
boxScope: box.scope, boxState: box.state,
|
|
955
|
-
rank: box.rank, status: box.status, floor: CONFIG.floor,
|
|
956
|
-
}),
|
|
957
|
-
}))
|
|
958
|
-
.filter((x) => x.decision.action !== 'none');
|
|
959
|
-
|
|
960
|
-
console.log(`source-access reconcile (floor=${CONFIG.floor}${apply ? '' : ', dry-run'}): ${actions.length} of ${rows.length} box(es) need action`);
|
|
961
|
-
if (actions.length === 0) { console.log(' all boxes in sync — exit clean'); return { deprovisioned: 0, rescoped: 0 }; }
|
|
962
|
-
|
|
963
|
-
const doApi = apply ? makeDo(apply) : null;
|
|
964
|
-
let deprovisioned = 0, rescoped = 0, errors = 0;
|
|
965
|
-
for (const { box, decision } of actions) {
|
|
966
|
-
if (!apply) {
|
|
967
|
-
console.log(` would ${decision.action} @${box.github_login}'s box — ${decision.reason} (scope=${box.scope}, rank=${box.rank}, status=${box.status})`);
|
|
968
|
-
continue;
|
|
969
|
-
}
|
|
970
|
-
try {
|
|
971
|
-
if (decision.action === 'deprovision') {
|
|
972
|
-
await deprovisionOne(pool, boxes, doApi, box, { actor: 'reconcile:clawback' });
|
|
973
|
-
await boxAccess.recordSourceEvent(pool, {
|
|
974
|
-
box, builderId: box.builder_id, event: 'source_revoke',
|
|
975
|
-
detail: `deprovisioned: ${decision.reason} (rank=${box.rank}, status=${box.status})`, actor: 'reconcile',
|
|
976
|
-
});
|
|
977
|
-
deprovisioned++;
|
|
978
|
-
console.log(` ✓ deprovisioned @${box.github_login} — ${decision.reason}`);
|
|
979
|
-
} else if (decision.action === 'rescope') {
|
|
980
|
-
await boxes.setBoxState(pool, box.builder_id, box.state, {
|
|
981
|
-
scope: decision.newScope, provisioned_rank: box.rank,
|
|
982
|
-
});
|
|
983
|
-
await boxAccess.recordSourceEvent(pool, {
|
|
984
|
-
box, builderId: box.builder_id, event: 'source_rescope',
|
|
985
|
-
detail: `${box.scope}→${decision.newScope}: ${decision.reason}`, actor: 'reconcile',
|
|
986
|
-
});
|
|
987
|
-
rescoped++;
|
|
988
|
-
console.log(` ✓ rescoped @${box.github_login} ${box.scope}→${decision.newScope} (box re-clones on next refresh)`);
|
|
989
|
-
}
|
|
990
|
-
} catch (e) {
|
|
991
|
-
errors++;
|
|
992
|
-
await markErrorUnlessDestroyed(pool, boxes, box.builder_id, e.message);
|
|
993
|
-
console.log(` ✖ @${box.github_login}: ${e.message}`);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
console.log(`done — deprovisioned=${deprovisioned} rescoped=${rescoped} errors=${errors} dry-run=${!apply}`);
|
|
997
|
-
return { deprovisioned, rescoped, errors };
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
// ---- reconcile-drift (DO droplet list ⇄ GDS box rows, task 1289) -----------
|
|
1001
|
-
|
|
1002
|
-
// Best-effort teardown of a login's edge resources (Cloudflare named tunnel + the
|
|
1003
|
-
// per-builder DNS A record), shared by the zombie + ghost reconcile paths so no
|
|
1004
|
-
// orphan tunnel/DNS is left behind. Never throws — the droplet (the thing that
|
|
1005
|
-
// bills + is SSH-reachable) is the hard part; a leftover tunnel/DNS is cosmetic.
|
|
1006
|
-
async function teardownEdgeForLogin(login, { apply }) {
|
|
1007
|
-
if (!login) return;
|
|
1008
|
-
if (namedTunnelConfigured()) {
|
|
1009
|
-
try {
|
|
1010
|
-
const res = await makeCf(apply).teardownNamedTunnel({ login, zone: CF.zone, terminalZone: CF.terminalZone });
|
|
1011
|
-
if (res.tunnelDeleted || res.dnsDeleted) console.log(` ✓ tunnel torn down (tunnel=${res.tunnelDeleted} dns=${res.dnsDeleted})`);
|
|
1012
|
-
for (const err of res.errors) console.log(` ⚠ tunnel teardown ${err}`);
|
|
1013
|
-
} catch (e) { console.log(` ⚠ tunnel teardown: ${e.message}`); }
|
|
1014
|
-
}
|
|
1015
|
-
if (CONFIG.dnsHook && apply) runDnsHookBlackhole(hostnameFor(login));
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
// The bidirectional drift sweep. Lists the REAL fleet droplets (live read, even in
|
|
1019
|
-
// dry-run) and reconciles against the GDS box rows:
|
|
1020
|
-
// - ZOMBIE (droplet exists, no live row claims it) → force-delete droplet + edge.
|
|
1021
|
-
// - GHOST (live row, its droplet vanished from DO) → mark 'destroyed' + edge.
|
|
1022
|
-
// This is the backstop for any teardown that half-failed (now far less likely with
|
|
1023
|
-
// confirm-before-destroyed in deprovisionOne) and for out-of-band DO changes.
|
|
1024
|
-
// DRY-RUN DEFAULT — pass --apply to actually delete / mark.
|
|
1025
|
-
async function cmdReconcileDrift(pool, boxes, { apply }) {
|
|
1026
|
-
const readApi = makeDoRead();
|
|
1027
|
-
if (!readApi) {
|
|
1028
|
-
return fail('reconcile-drift needs DO_API_TOKEN to enumerate droplets — run on the control plane (prod droplet)');
|
|
1029
|
-
}
|
|
1030
|
-
let droplets;
|
|
1031
|
-
try {
|
|
1032
|
-
({ droplets } = await readApi.listDroplets({ tag: CONFIG.tag }));
|
|
1033
|
-
} catch (e) {
|
|
1034
|
-
return fail(`could not list DigitalOcean droplets: ${e.message}`);
|
|
1035
|
-
}
|
|
1036
|
-
const boxRows = await boxes.listBoxes(pool, {});
|
|
1037
|
-
const { zombies, ghosts, skippedYoung } = boxes.planDropletDrift({
|
|
1038
|
-
droplets: droplets || [], boxRows, nowMs: Date.now(),
|
|
1039
|
-
});
|
|
1040
|
-
|
|
1041
|
-
console.log(`drift reconcile (fleet tag=${CONFIG.tag}${apply ? '' : ', dry-run'}): ${(droplets || []).length} droplet(s) live, ${boxRows.length} box row(s)`);
|
|
1042
|
-
console.log(` zombies=${zombies.length} ghosts=${ghosts.length} skipped-young=${skippedYoung.length}`);
|
|
1043
|
-
for (const z of skippedYoung) console.log(` · skip young droplet ${z.dropletId} (${z.name || '?'}) — likely a provision in flight`);
|
|
1044
|
-
if (zombies.length === 0 && ghosts.length === 0) { console.log(' DO ⇄ DB consistent — exit clean'); return { zombies: 0, ghosts: 0 }; }
|
|
1045
|
-
|
|
1046
|
-
const mutateDo = makeDo(apply); // dry-run logs the DELETEs; --apply executes them
|
|
1047
|
-
let killed = 0, marked = 0, errors = 0;
|
|
1048
|
-
|
|
1049
|
-
// ZOMBIES: a droplet no live row owns. Force-delete it (idempotent + retried),
|
|
1050
|
-
// then tear down its edge. The box row (if any) is already 'destroyed'/'error' —
|
|
1051
|
-
// we don't change DB state, we reclaim the leaked DO resource + record an audit row.
|
|
1052
|
-
for (const z of zombies) {
|
|
1053
|
-
const who = z.login ? `@${z.login}` : `droplet ${z.dropletId}`;
|
|
1054
|
-
if (!apply) { console.log(` would FORCE-DELETE zombie droplet ${z.dropletId} (${z.name || '?'}, ${who})`); continue; }
|
|
1055
|
-
try {
|
|
1056
|
-
const r = await deleteWithRetry(() => mutateDo.deleteDroplet(z.dropletId));
|
|
1057
|
-
if (!r.ok) throw r.error || new Error('droplet delete failed');
|
|
1058
|
-
await teardownEdgeForLogin(z.login, { apply });
|
|
1059
|
-
const row = boxRows.find((b) => b.builder_id === z.builderId);
|
|
1060
|
-
if (row) {
|
|
1061
|
-
await boxes.recordEvent(pool, {
|
|
1062
|
-
boxId: row.id, builderId: z.builderId, event: 'deprovision',
|
|
1063
|
-
detail: `drift: zombie droplet ${z.dropletId} force-deleted (row was '${row.state}')`,
|
|
1064
|
-
actor: 'reconcile:drift',
|
|
1065
|
-
});
|
|
1066
|
-
}
|
|
1067
|
-
killed++;
|
|
1068
|
-
console.log(` ✓ killed zombie droplet ${z.dropletId} (${who})`);
|
|
1069
|
-
} catch (e) {
|
|
1070
|
-
errors++;
|
|
1071
|
-
console.log(` ✖ zombie droplet ${z.dropletId} (${who}): ${e.message}`);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// GHOSTS: a live row whose droplet is gone from DO. Accrue final compute, tear
|
|
1076
|
-
// down any leftover edge, then mark 'destroyed' (clearing the dead droplet_id +
|
|
1077
|
-
// SSH host keys, idea 310) so the DB stops claiming a running box.
|
|
1078
|
-
for (const g of ghosts) {
|
|
1079
|
-
if (!apply) { console.log(` would MARK DESTROYED @${g.login || g.builderId}'s box (state '${g.state}', droplet ${g.dropletId} gone from DO)`); continue; }
|
|
1080
|
-
try {
|
|
1081
|
-
const row = boxRows.find((b) => b.builder_id === g.builderId);
|
|
1082
|
-
const nowMs = Date.now();
|
|
1083
|
-
if (row && row.state === 'active' && row.droplet_id) {
|
|
1084
|
-
await boxes.recordComputeCost(pool, { box: row, untilMs: nowMs, actor: 'reconcile:drift' });
|
|
1085
|
-
}
|
|
1086
|
-
await teardownEdgeForLogin(g.login, { apply });
|
|
1087
|
-
await boxes.setBoxState(pool, g.builderId, 'destroyed', {
|
|
1088
|
-
destroyed_at: new Date(nowMs), droplet_id: null, ip: null, snapshot_id: null,
|
|
1089
|
-
active_since: null, parked_at: null, claude_active: false, claude_active_since: null,
|
|
1090
|
-
host_keys: null, host_keys_at: null,
|
|
1091
|
-
});
|
|
1092
|
-
if (row) {
|
|
1093
|
-
await boxes.recordEvent(pool, {
|
|
1094
|
-
boxId: row.id, builderId: g.builderId, event: 'deprovision',
|
|
1095
|
-
detail: `drift: droplet ${g.dropletId} vanished from DO — row '${g.state}'→'destroyed'`,
|
|
1096
|
-
actor: 'reconcile:drift',
|
|
1097
|
-
});
|
|
1098
|
-
}
|
|
1099
|
-
marked++;
|
|
1100
|
-
console.log(` ✓ marked @${g.login || g.builderId}'s box destroyed (ghost droplet ${g.dropletId})`);
|
|
1101
|
-
} catch (e) {
|
|
1102
|
-
errors++;
|
|
1103
|
-
console.log(` ✖ ghost @${g.login || g.builderId}: ${e.message}`);
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
console.log(`done — zombies-killed=${killed} ghosts-marked=${marked} errors=${errors} dry-run=${!apply}`);
|
|
1108
|
-
return { zombies: killed, ghosts: marked, errors };
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// ---- run-intents (#701: drain the auto-provision / wake-on-connect queue) --
|
|
1112
|
-
|
|
1113
|
-
// The control-plane half of the #701 onboarding middleware. The web process can
|
|
1114
|
-
// only ENQUEUE a provision/wake intent (POST /box/ensure) — it never holds the DO
|
|
1115
|
-
// token. This subcommand, run on a short systemd timer (box-intent-runner.timer,
|
|
1116
|
-
// every minute), drains box_intents by calling the SAME cmdProvision/cmdWake the
|
|
1117
|
-
// operator uses. DRY-RUN DEFAULT: without --apply it only lists the pending queue
|
|
1118
|
-
// (and does NOT claim rows). With --apply it claims + executes each pending intent.
|
|
1119
|
-
//
|
|
1120
|
-
// A small attempt cap (BOX_INTENT_MAX_ATTEMPTS, default 3) retires a poison intent
|
|
1121
|
-
// to 'error' so a doomed provision can't loop forever burning the DO API.
|
|
1122
|
-
const MAX_INTENT_ATTEMPTS = numEnv('BOX_INTENT_MAX_ATTEMPTS', 3);
|
|
1123
|
-
|
|
1124
|
-
async function cmdRunIntents(pool, boxes, boxOnboard, { apply }) {
|
|
1125
|
-
const instanceRoot = process.env.BONGOS_INSTANCE_ROOT || process.cwd();
|
|
1126
|
-
console.log(runnerBanner({ runnerVersion: coreVersionSafe(), runnerRoot: REPO_ROOT, instanceVersion: installedCoreVersion(instanceRoot), instanceRoot }));
|
|
1127
|
-
if (!apply) {
|
|
1128
|
-
const { rows } = await pool.query(
|
|
1129
|
-
`SELECT bi.*, b.github_login FROM box_intents bi
|
|
1130
|
-
JOIN builders b ON b.id = bi.builder_id
|
|
1131
|
-
WHERE bi.state = 'pending' ORDER BY bi.created_at`
|
|
1132
|
-
);
|
|
1133
|
-
console.log(`intent runner (dry-run): ${rows.length} pending intent(s)`);
|
|
1134
|
-
for (const r of rows) console.log(` would ${r.action} @${r.github_login} (intent #${r.id}, attempts=${r.attempts})`);
|
|
1135
|
-
if (rows.length === 0) console.log(' queue empty — exit clean');
|
|
1136
|
-
return { ran: 0 };
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
let ran = 0, ok = 0, errors = 0;
|
|
1140
|
-
// Drain the queue one claimed intent at a time (FOR UPDATE SKIP LOCKED), so two
|
|
1141
|
-
// overlapping runner ticks never execute the same intent.
|
|
1142
|
-
for (;;) {
|
|
1143
|
-
const intent = await boxes.claimNextIntent(pool);
|
|
1144
|
-
if (!intent) break;
|
|
1145
|
-
ran++;
|
|
1146
|
-
const builder = await resolveBuilder(pool, String(intent.builder_id));
|
|
1147
|
-
const who = builder ? `@${builder.github_login}` : `builder#${intent.builder_id}`;
|
|
1148
|
-
// Poison guard: a claimed intent already past the cap is retired, not retried.
|
|
1149
|
-
if (intent.attempts > MAX_INTENT_ATTEMPTS) {
|
|
1150
|
-
await boxes.resolveIntent(pool, intent.id, 'error', `exceeded ${MAX_INTENT_ATTEMPTS} attempts`);
|
|
1151
|
-
errors++;
|
|
1152
|
-
console.log(` ✖ ${who}: intent #${intent.id} retired (too many attempts)`);
|
|
1153
|
-
continue;
|
|
1154
|
-
}
|
|
1155
|
-
console.log(` ${intent.action} ${who} (intent #${intent.id}, attempt ${intent.attempts})`);
|
|
1156
|
-
try {
|
|
1157
|
-
const ref = String(intent.builder_id);
|
|
1158
|
-
const result = intent.action === 'provision'
|
|
1159
|
-
? await cmdProvision(pool, boxes, boxOnboard, ref, { apply: true })
|
|
1160
|
-
: intent.action === 'deprovision'
|
|
1161
|
-
? await cmdDeprovision(pool, boxes, ref, { apply: true })
|
|
1162
|
-
: await cmdWake(pool, boxes, ref, { apply: true });
|
|
1163
|
-
if (result && result.ok === false) {
|
|
1164
|
-
// Leave it 'pending' for a retry UNLESS we've hit the cap (next claim
|
|
1165
|
-
// bumps attempts; the guard above retires it once over the cap).
|
|
1166
|
-
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
1167
|
-
await boxes.resolveIntent(pool, intent.id, 'error', result.error || 'lifecycle op failed');
|
|
1168
|
-
} else {
|
|
1169
|
-
await pool.query(`UPDATE box_intents SET state = 'pending', last_error = $2, updated_at = now() WHERE id = $1`,
|
|
1170
|
-
[intent.id, result.error || 'lifecycle op failed']);
|
|
1171
|
-
}
|
|
1172
|
-
errors++;
|
|
1173
|
-
console.log(` ✖ ${who}: ${result.error || 'failed'}`);
|
|
1174
|
-
} else {
|
|
1175
|
-
await boxes.resolveIntent(pool, intent.id, 'done', null);
|
|
1176
|
-
ok++;
|
|
1177
|
-
console.log(` ✓ ${who}: ${intent.action} done`);
|
|
1178
|
-
}
|
|
1179
|
-
} catch (e) {
|
|
1180
|
-
await boxes.resolveIntent(pool, intent.id, 'error', e.message).catch(() => {});
|
|
1181
|
-
errors++;
|
|
1182
|
-
console.log(` ✖ ${who}: ${e.message}`);
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
console.log(`done — ran=${ran} ok=${ok} errors=${errors}`);
|
|
1186
|
-
return { ran, ok, errors };
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
// ---- status ----------------------------------------------------------------
|
|
1190
|
-
|
|
1191
|
-
async function cmdStatus(pool, boxes) {
|
|
1192
|
-
const rows = await boxes.listBoxes(pool, {});
|
|
1193
|
-
if (rows.length === 0) { console.log('no builder boxes yet.'); return; }
|
|
1194
|
-
let monthly = 0, spent = 0;
|
|
1195
|
-
console.log('builder boxes:');
|
|
1196
|
-
for (const b of rows) {
|
|
1197
|
-
const sz = boxes.SIZE_CATALOG[b.size_slug];
|
|
1198
|
-
const liveMo = b.state === 'active' && sz ? sz.monthlyUsd : 0;
|
|
1199
|
-
monthly += liveMo;
|
|
1200
|
-
spent += Number(b.compute_cost_usd || 0);
|
|
1201
|
-
const where = b.state === 'active' ? `${b.ip || '?'} (${b.size_slug})` : b.state === 'parked' ? `snapshot ${b.snapshot_id || '?'}` : '';
|
|
1202
|
-
console.log(` @${b.github_login.padEnd(16)} ${b.state.padEnd(12)} scope=${b.scope.padEnd(8)} $${Number(b.compute_cost_usd || 0).toFixed(2)} spent ${where}`);
|
|
1203
|
-
}
|
|
1204
|
-
console.log(`\n active monthly run-rate: ~$${monthly.toFixed(2)}/mo total compute spent: $${spent.toFixed(2)}`);
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// ---- shared ----------------------------------------------------------------
|
|
1208
|
-
|
|
1209
|
-
let APPLY = false;
|
|
1210
|
-
function actorTag() {
|
|
1211
|
-
const who = instanceConfig.resolveEnv('ACTOR') || process.env.USER || 'cli';
|
|
1212
|
-
return `cli:${who}`;
|
|
1213
|
-
}
|
|
1214
|
-
function fail(msg) { console.error(`✖ ${msg}`); return { ok: false, error: msg }; }
|
|
1215
|
-
|
|
1216
|
-
function usage() {
|
|
1217
|
-
console.log(`box.js — per-builder dev box lifecycle (ADR 0031 §2/§5, #598)
|
|
1218
|
-
|
|
1219
|
-
Usage:
|
|
1220
|
-
node scripts/gds/box.js <command> [args] [--apply]
|
|
1221
|
-
|
|
1222
|
-
Commands:
|
|
1223
|
-
provision <builder> create + boot a box (rank-gated, floor=${CONFIG.floor})
|
|
1224
|
-
onboard <builder> provision + print the builder welcome packet (#685);
|
|
1225
|
-
requires a registered SSH key unless --allow-no-keys
|
|
1226
|
-
park <builder> snapshot + destroy droplet (stop compute billing)
|
|
1227
|
-
wake <builder> recreate droplet from its snapshot
|
|
1228
|
-
deprovision <builder> destroy droplet AND snapshot (full reclaim)
|
|
1229
|
-
run-intents drain the auto-provision/wake-on-connect queue (#701):
|
|
1230
|
-
execute each pending box_intents row (provision/wake)
|
|
1231
|
-
sweep-idle park active boxes idle > ${CONFIG.idleMinutes}m, or unattended > ${CONFIG.unattendedMaxHours}h
|
|
1232
|
-
sweep-dormant warn at ${Math.max(CONFIG.dormantDays - CONFIG.dormantWarnLeadDays, 0)}d, then deprovision boxes parked > ${CONFIG.dormantDays}d
|
|
1233
|
-
(never reclaims a box that was not warned first)
|
|
1234
|
-
reconcile source-access clawback: deprovision below-floor/inactive
|
|
1235
|
-
boxes, rescope graduated ones (ADR 0031 §6, #600)
|
|
1236
|
-
reconcile-drift repair DO⇄DB drift BOTH ways (task 1289): force-delete
|
|
1237
|
-
zombie droplets (running but no live box row) + mark
|
|
1238
|
-
ghost rows destroyed (droplet gone from DO)
|
|
1239
|
-
status print the box roster + cost
|
|
1240
|
-
|
|
1241
|
-
DRY-RUN IS DEFAULT. Pass --apply to actually touch DigitalOcean + the DB.
|
|
1242
|
-
<builder> = GitHub login or numeric builder id.
|
|
1243
|
-
|
|
1244
|
-
Named browser-terminal tunnel (#771, ADR 0038 — OPT-IN, default OFF):
|
|
1245
|
-
BOX_NAMED_TUNNEL=1 turn on per-builder named Cloudflare tunnels (provision
|
|
1246
|
-
creates term-<login>.${CF.terminalZone} + bakes the
|
|
1247
|
-
connector token; deprovision tears it down). Off → the
|
|
1248
|
-
zero-secret quick tunnel (trycloudflare.com).
|
|
1249
|
-
CLOUDFLARE_API_TOKEN token with Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit
|
|
1250
|
-
+ Zone:Transform Rules:Edit (the ADR 0053 cookie-strip —
|
|
1251
|
-
without it platform cookies reach the box subdomains)
|
|
1252
|
-
+ Zone:Cache Rules:Edit (the sandbox cache bypass, #1065).
|
|
1253
|
-
provision read-probes the ruleset scope before spending.
|
|
1254
|
-
CLOUDFLARE_ACCOUNT_ID the Cloudflare account the tunnels live in
|
|
1255
|
-
CLOUDFLARE_ZONE DNS zone (default ${CF.zone})
|
|
1256
|
-
BOX_TERMINAL_ZONE hostname suffix (default ${CF.terminalZone})`);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
// box.js is a CLI: run directly it dispatches; require()d (only by tests — prod
|
|
1260
|
-
// never imports it, it is always spawned) it exposes the orchestration fns so the
|
|
1261
|
-
// dry-run/live contract can be exercised with injected fakes (#1034). The guard
|
|
1262
|
-
// keeps a require() from running the CLI + calling process.exit.
|
|
1263
|
-
// provenance() — WHICH core is making this fleet decision, and from which root.
|
|
1264
|
-
//
|
|
1265
|
-
// Task 1002738: the four cloudbongos-box-* units ran with WorkingDirectory pointed at
|
|
1266
|
-
// a root whose vendored @bongos/core was 1.19.2 while the web app had long since moved
|
|
1267
|
-
// to 1.19.146 — so every park/destroy/provision decision was made by a core ~145
|
|
1268
|
-
// patches behind, including a cmdSweepIdle that still DESTROYED an idle box. Nothing
|
|
1269
|
-
// in the journal said so: the sweep banner reports the THRESHOLD but never the code.
|
|
1270
|
-
// Diagnosing it took a filesystem dig across two instance roots.
|
|
1271
|
-
//
|
|
1272
|
-
// One line, printed by every invocation, makes that failure self-announcing: a stale
|
|
1273
|
-
// plane now says its own version in `journalctl -u cloudbongos-box-idle-suspend`, next
|
|
1274
|
-
// to the instance root that supplied it. Cheap, unconditional, and it can only be
|
|
1275
|
-
// wrong if the package it reads is the package it ran.
|
|
1276
|
-
// Names the package REPO_ROOT resolved to, not just its version: installed, that is
|
|
1277
|
-
// `@bongos/core@1.19.148`; in a monolith checkout it is the instance's own package, and
|
|
1278
|
-
// saying so is the point — the line must never let a monolith masquerade as a core pin.
|
|
1279
|
-
function provenance() {
|
|
1280
|
-
let pkg = 'unknown';
|
|
1281
|
-
try {
|
|
1282
|
-
const p = require(path.join(REPO_ROOT, 'package.json'));
|
|
1283
|
-
pkg = `${p.name || 'unknown'}@${p.version || 'unknown'}`;
|
|
1284
|
-
} catch { /* a core with no readable package.json still runs — just say unknown */ }
|
|
1285
|
-
const inst = instanceConfig.resolveInstanceRootExplicit() || process.cwd();
|
|
1286
|
-
return `box.js · running from ${pkg} · instance ${inst}`;
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
async function main() {
|
|
1290
|
-
const args = process.argv.slice(2);
|
|
1291
|
-
const cmd = args[0];
|
|
1292
|
-
APPLY = hasFlag(args, '--apply');
|
|
1293
|
-
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') { usage(); process.exit(0); }
|
|
1294
|
-
console.log(provenance());
|
|
1295
|
-
|
|
1296
|
-
const known = ['provision', 'onboard', 'park', 'wake', 'deprovision', 'run-intents', 'sweep-idle', 'sweep-dormant', 'reconcile', 'reconcile-drift', 'status'];
|
|
1297
|
-
if (!known.includes(cmd)) { console.error(`unknown command: ${cmd}\n`); usage(); process.exit(2); }
|
|
1298
|
-
|
|
1299
|
-
const ref = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1300
|
-
const needsRef = ['provision', 'onboard', 'park', 'wake', 'deprovision'].includes(cmd);
|
|
1301
|
-
if (needsRef && !ref) { console.error(`${cmd} needs a <builder> (login or id)`); process.exit(2); }
|
|
1302
|
-
|
|
1303
|
-
let pool, boxes, boxAccess, boxOnboard;
|
|
1304
|
-
try { ({ pool, boxes, boxAccess, boxOnboard } = loadDeps()); }
|
|
1305
|
-
catch (e) { console.error(`cannot load DB layer (run this on the control plane / droplet): ${e.message}`); process.exit(1); }
|
|
1306
|
-
|
|
1307
|
-
let result;
|
|
1308
|
-
try {
|
|
1309
|
-
const opts = { apply: APPLY, allowNoKeys: hasFlag(args, '--allow-no-keys') };
|
|
1310
|
-
if (cmd === 'provision') result = await cmdProvision(pool, boxes, boxOnboard, ref, opts);
|
|
1311
|
-
else if (cmd === 'onboard') result = await cmdOnboard(pool, boxes, boxOnboard, ref, opts);
|
|
1312
|
-
else if (cmd === 'park') result = await cmdPark(pool, boxes, ref, opts);
|
|
1313
|
-
else if (cmd === 'wake') result = await cmdWake(pool, boxes, ref, opts);
|
|
1314
|
-
else if (cmd === 'deprovision') result = await cmdDeprovision(pool, boxes, ref, opts);
|
|
1315
|
-
else if (cmd === 'run-intents') result = await cmdRunIntents(pool, boxes, boxOnboard, opts);
|
|
1316
|
-
else if (cmd === 'sweep-idle') result = await cmdSweepIdle(pool, boxes, opts);
|
|
1317
|
-
else if (cmd === 'sweep-dormant') result = await cmdSweepDormant(pool, boxes, opts);
|
|
1318
|
-
else if (cmd === 'reconcile') result = await cmdReconcile(pool, boxes, boxAccess, opts);
|
|
1319
|
-
else if (cmd === 'reconcile-drift') result = await cmdReconcileDrift(pool, boxes, opts);
|
|
1320
|
-
else if (cmd === 'status') result = await cmdStatus(pool, boxes);
|
|
1321
|
-
} catch (e) {
|
|
1322
|
-
console.error('fatal:', e && e.message ? e.message : e);
|
|
1323
|
-
await pool.end().catch(() => {});
|
|
1324
|
-
process.exit(1);
|
|
1325
|
-
}
|
|
1326
|
-
await pool.end().catch(() => {});
|
|
1327
|
-
process.exit(result && result.ok === false ? 1 : 0);
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
if (require.main === module) main();
|
|
1331
|
-
|
|
1332
|
-
module.exports = { cmdProvision, cmdRunIntents, deprovisionOne, parkOne, cmdSweepIdle, cmdSweepDormant, deleteWithRetry, cmdReconcileDrift, runDnsHook, runDnsHookBlackhole, markErrorUnlessDestroyed, resolveBoxInfraDir, boxInfraCandidates, readCloudInit, buildBoxEnv, BOX_INFRA_TEMPLATE };
|