@bongos/core 1.19.332 → 1.19.334
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 +173 -43
- package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/bongos/routes/auth.js +54 -10
- package/src/module-api.js +5 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/auth_web_recovery.mjs +63 -0
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/rank_tier_single_source.mjs +16 -3
- package/tests/reward_gate.mjs +3 -2
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.334",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.334",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.334",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// scripts/gds/box-infra.js — the dev-box CONFIG block plus everything that
|
|
2
|
+
// turns it into a bootable droplet: infra/ resolution, naming, the DNS hook,
|
|
3
|
+
// and cloud-init rendering.
|
|
4
|
+
//
|
|
5
|
+
// Carved out of scripts/gds/box.js by task 1003206 (goal 1000079, the R03
|
|
6
|
+
// de-monolith plan) to bring that file under the 1,500-line ratchet. A
|
|
7
|
+
// STRUCTURAL move: the lines below are box.js's originals unchanged, and box.js
|
|
8
|
+
// requires every name straight back, so the operator CLI is untouched.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const zlib = require('node:zlib');
|
|
15
|
+
const { execFileSync } = require('node:child_process');
|
|
16
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
17
|
+
const instanceConfig = require(path.join(REPO_ROOT, 'src/instance-config'));
|
|
18
|
+
const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
|
|
19
|
+
|
|
20
|
+
function boxInfraCandidates({ env = process.env, repoRoot = REPO_ROOT } = {}) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
const add = (d) => { if (d && !seen.has(d)) { seen.add(d); out.push(d); } };
|
|
24
|
+
// 1. explicit override: <PREFIX>_BOX_INFRA_DIR (operator escape hatch).
|
|
25
|
+
const override = instanceConfig.resolveEnv('BOX_INFRA_DIR', { env });
|
|
26
|
+
if (override) add(path.resolve(override));
|
|
27
|
+
// 2. the instance root via an EXPLICIT pointer (<PREFIX>_INSTANCE_ROOT env or a
|
|
28
|
+
// bongos.config.json {"instanceRoot":"."}) — where a vendored instance keeps infra/.
|
|
29
|
+
const inst = instanceConfig.resolveInstanceRootExplicit({ env });
|
|
30
|
+
if (inst) add(path.join(inst, 'infra'));
|
|
31
|
+
// 3. REPO_ROOT/infra — the dev single-checkout (and the ADR 0144 node_modules workaround).
|
|
32
|
+
add(path.join(repoRoot, 'infra'));
|
|
33
|
+
// 4. walk up out of node_modules from the core root, so a vendored
|
|
34
|
+
// …/node_modules/@bongos/core still finds its host instance's infra/ with no pointer set.
|
|
35
|
+
let dir = repoRoot;
|
|
36
|
+
for (let i = 0; i < 6; i += 1) {
|
|
37
|
+
const parent = path.dirname(dir);
|
|
38
|
+
if (parent === dir) break;
|
|
39
|
+
dir = parent;
|
|
40
|
+
add(path.join(dir, 'infra'));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The resolved infra/ dir (first candidate holding the template), or null when the
|
|
46
|
+
// box cloud-init cannot be located anywhere — the caller MUST fail loud on null
|
|
47
|
+
// rather than provision a box with empty user-data (ADR 0150).
|
|
48
|
+
function resolveBoxInfraDir(opts = {}) {
|
|
49
|
+
for (const c of boxInfraCandidates(opts)) {
|
|
50
|
+
try { if (fs.existsSync(path.join(c, BOX_INFRA_TEMPLATE))) return c; }
|
|
51
|
+
catch { /* unreadable candidate → try the next */ }
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// task 1002757 — the instance's own apex; null when unconfigured (callers refuse).
|
|
57
|
+
const { instanceApex } = require('./instance-apex');
|
|
58
|
+
|
|
59
|
+
const CONFIG = {
|
|
60
|
+
floor: process.env.BOX_PROVISION_MIN_RANK || 'xenos',
|
|
61
|
+
region: process.env.BOX_REGION || 'nyc3',
|
|
62
|
+
size: process.env.BOX_SIZE || 's-2vcpu-4gb',
|
|
63
|
+
baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
|
|
64
|
+
idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
|
|
65
|
+
dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
|
|
66
|
+
// task 1002727 — how many days BEFORE the reclaim a builder gets warned.
|
|
67
|
+
dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
|
|
68
|
+
sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
69
|
+
dnsHook: process.env.BOX_DNS_HOOK || null,
|
|
70
|
+
tag: process.env.BOX_TAG || 'otb-builder-box',
|
|
71
|
+
// task 1002757 settled this file's old carve-out: an operator-plane DNS zone is
|
|
72
|
+
// still a FUNCTIONAL DEFAULT, and a founder literal aimed another operator's
|
|
73
|
+
// fleet at the founder's zone. Derived from the instance apex, null when
|
|
74
|
+
// unconfigured — hostnameFor() then refuses instead of minting a dead FQDN.
|
|
75
|
+
dnsZone: process.env.BOX_DNS_ZONE || (instanceApex() ? `dev.${instanceApex()}` : null),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Named-tunnel automation for the browser terminal (#771, ADR 0038 Part B).
|
|
79
|
+
// OPT-IN, default OFF — an unset BOX_NAMED_TUNNEL leaves the box on the tokenless
|
|
80
|
+
// quick tunnel (trycloudflare.com), the zero-secret #760 default. When the operator
|
|
81
|
+
// turns it on AND supplies a Cloudflare token with Tunnel + DNS scope (the same
|
|
82
|
+
// CLOUDFLARE_API_TOKEN box-dns uses) + the account id, every provision creates a
|
|
83
|
+
// stable term-<login>.<terminalZone> tunnel and bakes its connector token into the
|
|
84
|
+
// box; deprovision tears it down.
|
|
85
|
+
const CF = {
|
|
86
|
+
enabled: /^(1|true|yes)$/i.test(process.env.BOX_NAMED_TUNNEL || ''),
|
|
87
|
+
apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
|
|
88
|
+
accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
|
|
89
|
+
// The Cloudflare ZONE the record lives in (the registrable apex) — the terminal
|
|
90
|
+
// host is a record WITHIN it, matching box-dns-cloudflare.sh. Distinct from
|
|
91
|
+
// terminalZone (the host suffix). Both resolve from the instance (task 1002757).
|
|
92
|
+
zone: process.env.CLOUDFLARE_ZONE || instanceApex(),
|
|
93
|
+
// Host suffix for term-<login>.<terminalZone>. DEFAULTS TO THE APEX, NOT dev.*:
|
|
94
|
+
// the terminal rides a PROXIED tunnel, and free Universal SSL covers <apex> +
|
|
95
|
+
// *.<apex> (ONE label) but not a TWO-label host like term-x.dev.<apex>, which
|
|
96
|
+
// fails TLS at the edge (verified live, #771). An operator holding an Advanced
|
|
97
|
+
// Certificate for *.dev.<apex> can override via BOX_TERMINAL_ZONE. (The SSH
|
|
98
|
+
// A-record path stays on dev.* — proxied=false, so it needs no edge cert.)
|
|
99
|
+
terminalZone: process.env.BOX_TERMINAL_ZONE || instanceApex(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Named-tunnel automation is live only when the operator opted in AND the
|
|
103
|
+
// credentials are present. Missing creds while enabled is a loud config error at
|
|
104
|
+
// provision time (see cmdProvision), not a silent fall-back, so an operator who
|
|
105
|
+
// meant to use named tunnels learns immediately.
|
|
106
|
+
function numEnv(name, dflt) {
|
|
107
|
+
const v = process.env[name];
|
|
108
|
+
if (v == null || v === '') return dflt;
|
|
109
|
+
const n = Number(v);
|
|
110
|
+
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasFlag(args, name) { return args.includes(name); }
|
|
114
|
+
|
|
115
|
+
// Lazy + memoized DB + lib load so `--help` and arg errors don't require Postgres
|
|
116
|
+
// (this CLI runs on a non-droplet machine for dry-run planning of pure logic too):
|
|
117
|
+
// nothing is require()d until the first makeDo/makeCf/IIFE call, and repeat calls
|
|
118
|
+
// reuse the first resolution instead of re-running the six requires.
|
|
119
|
+
let _deps;
|
|
120
|
+
function shortStamp() {
|
|
121
|
+
return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(2, 14);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function dropletName(login) { return `otb-box-${login}`; }
|
|
125
|
+
// Refuses on an unresolved zone (task 1002757) — `box-x.undefined` would mint a
|
|
126
|
+
// DNS record and a box that can never be reached.
|
|
127
|
+
function hostnameFor(login) {
|
|
128
|
+
if (!CONFIG.dnsZone) throw new Error('box: no DNS zone configured — set BOX_DNS_ZONE, or give this instance a domains.publicOrigin in config/branding.json (task 1002757).');
|
|
129
|
+
return `box-${login}.${CONFIG.dnsZone}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Run the optional DNS hook so the per-builder hostname tracks the new IP; with no
|
|
133
|
+
// hook configured, print the reminder — the stable-hostname CUTOVER is #599's, #598
|
|
134
|
+
// only records IP + hostname and offers the hook so an operator can wire DNS without
|
|
135
|
+
// editing this file. → { ok, error?, skipped? }. task 1180: callers MUST act on
|
|
136
|
+
// ok:false (box_events row + flag the box), never continue silently — that swallow
|
|
137
|
+
// left @kingaonline's A record pinned at a dead IP while provision reported success.
|
|
138
|
+
// The hook retries transient Cloudflare blips, so ok:false is a real, exhausted
|
|
139
|
+
// failure. `hook` is injectable so the contract is unit-testable (#1034).
|
|
140
|
+
function runDnsHook(hostname, ip, hook = CONFIG.dnsHook) {
|
|
141
|
+
if (!ip) return { ok: true, skipped: true };
|
|
142
|
+
if (!hook) {
|
|
143
|
+
console.log(` ↳ DNS: point ${hostname} → ${ip} (no BOX_DNS_HOOK set; #599 owns the cutover)`);
|
|
144
|
+
return { ok: true, skipped: true };
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
execFileSync('bash', ['-c', hook], {
|
|
148
|
+
cwd: REPO_ROOT, stdio: 'pipe',
|
|
149
|
+
env: { ...process.env, BOX_HOSTNAME: hostname, BOX_IP: ip },
|
|
150
|
+
});
|
|
151
|
+
console.log(` ↳ DNS hook updated ${hostname} → ${ip}`);
|
|
152
|
+
return { ok: true };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
const msg = (e.stderr && e.stderr.toString().trim()) || e.message;
|
|
155
|
+
console.log(` ⚠ DNS hook failed: ${msg} (point ${hostname} → ${ip} manually)`);
|
|
156
|
+
return { ok: false, error: msg };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Blackhole sentinel for a torn-down box's A record (idea 309). 192.0.2.1 is in
|
|
161
|
+
// TEST-NET-1 (RFC 5737) — reserved for documentation and GUARANTEED never to be a
|
|
162
|
+
// routable host, so no recycled DigitalOcean IP can ever land here. A perfect
|
|
163
|
+
// dead end to park a record at.
|
|
164
|
+
const DNS_BLACKHOLE_IP = '192.0.2.1';
|
|
165
|
+
|
|
166
|
+
// Park the per-builder A record at the blackhole sentinel on deprovision (idea 309,
|
|
167
|
+
// refining task 1180). 1180 DELETED the record so it couldn't dangle at a recycled
|
|
168
|
+
// DO IP, but delete-then-recreate across a reap→rebuild left an NXDOMAIN window that
|
|
169
|
+
// resolvers negative-cache for the zone's SOA neg-TTL (~30 min, not builder-editable)
|
|
170
|
+
// — a client querying then kept getting ENOTFOUND long after the box was back.
|
|
171
|
+
// REPOINTING to a non-routable sentinel keeps the name always-resolving (nothing to
|
|
172
|
+
// negative-cache) while preserving 1180's guarantee that a torn-down box's name can
|
|
173
|
+
// never dial a stranger's recycled IP; the next provision/wake PUTs the live IP
|
|
174
|
+
// in-place at the 60s TTL, no gap. Best-effort like the prior delete. Reuses
|
|
175
|
+
// runDnsHook's UPSERT path; `hook` injectable for tests. → { ok, error?, skipped? }
|
|
176
|
+
function runDnsHookBlackhole(hostname, hook = CONFIG.dnsHook) {
|
|
177
|
+
if (!hook || !hostname) return { ok: true, skipped: true };
|
|
178
|
+
return runDnsHook(hostname, DNS_BLACKHOLE_IP, hook);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Read the cloud-init template and substitute the repo URL from BOX_REPO_URL
|
|
182
|
+
// (kept out of the repo file so no GitHub identity is hardcoded — V3.R12 rule).
|
|
183
|
+
// An unset URL leaves the placeholder; the cloud-init then logs guidance rather
|
|
184
|
+
// than cloning, and #600 wires the scoped credential.
|
|
185
|
+
// Read + render the cloud-init. `authorizedKeysBlock` is the otb-managed block
|
|
186
|
+
// for the builder being provisioned (#685) — base64'd here and baked in so the
|
|
187
|
+
// FIRST SSH login works before the box has a GDS token. An empty/absent block
|
|
188
|
+
// renders an empty managed block (a keyless box; the operator is warned upstream).
|
|
189
|
+
function readCloudInit({ authorizedKeysBlock = '', boxEnv = '', gdsSession = '', infraDir = resolveBoxInfraDir() } = {}) {
|
|
190
|
+
// Fail loud, never provision a BARE box (ADR 0150). No infra/ located → there is
|
|
191
|
+
// no cloud-init to render, so THROW here rather than the old silent `return null`
|
|
192
|
+
// that let cmdProvision ship empty user-data (a box that boots as bare Ubuntu).
|
|
193
|
+
if (!infraDir) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`box cloud-init not found: no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
|
|
196
|
+
+ `(set <PREFIX>_BOX_INFRA_DIR or <PREFIX>_INSTANCE_ROOT, or ship infra/). A box must `
|
|
197
|
+
+ `never be provisioned without user-data — it would boot bare (ADR 0150).`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const p = path.join(infraDir, BOX_INFRA_TEMPLATE);
|
|
201
|
+
// gzip+base64 a repo file into the cloud-init write_files (`encoding: gz+b64`).
|
|
202
|
+
// EVERY inlined infra file goes through this: cloud-init gunzips it back to the
|
|
203
|
+
// exact bytes on the box, so it is byte-identical to plain b64 but ~2-3x smaller,
|
|
204
|
+
// which keeps the rendered user-data well under DO's 64 KiB cap (#903; the source
|
|
205
|
+
// -fetch growth in task 1314 had pushed the all-b64-but-source-fetch render to
|
|
206
|
+
// 67 KB — gz+b64-ing the rest brings it back to ~51 KB, task 1321). Only the
|
|
207
|
+
// dynamic runtime values (authorized_keys, box.env, gds-session) stay plain b64
|
|
208
|
+
// below — they are minted per-provision, not read from infra/. A missing inlined
|
|
209
|
+
// file is COLLECTED and fails loud after the render (ADR 0150) — a half-wired box
|
|
210
|
+
// (that component silently never boots) is a bug, not something to warn past.
|
|
211
|
+
const missingInfra = [];
|
|
212
|
+
const fileGzB64 = (name) => {
|
|
213
|
+
try {
|
|
214
|
+
return zlib.gzipSync(fs.readFileSync(path.join(infraDir, name))).toString('base64');
|
|
215
|
+
} catch {
|
|
216
|
+
missingInfra.push(name);
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
221
|
+
// Inject the rank-gated source-fetch (#600) + authorized-keys-fetch (#685)
|
|
222
|
+
// scripts as gz+b64 (a single YAML-safe token — no indentation/escaping
|
|
223
|
+
// pitfalls; cloud-init gunzips encoding: gz+b64 back to the exact file), plus
|
|
224
|
+
// the baked authorized_keys block. ONE canonical copy of each lives in infra/.
|
|
225
|
+
const rendered = raw
|
|
226
|
+
.replace(/__REPO_URL__/g, process.env.BOX_REPO_URL || '')
|
|
227
|
+
.replace(/__BOX_SOURCE_FETCH_B64__/g, fileGzB64('box-source-fetch.sh'))
|
|
228
|
+
.replace(/__BOX_AUTHKEYS_FETCH_B64__/g, fileGzB64('box-authorized-keys-fetch.sh'))
|
|
229
|
+
.replace(/__BUILDER_AUTHORIZED_KEYS_B64__/g, Buffer.from(authorizedKeysBlock || '', 'utf8').toString('base64'))
|
|
230
|
+
// /etc/otb/box.env — empty by default (quick tunnel); the named-tunnel
|
|
231
|
+
// connector token + stable hostname when BOX_NAMED_TUNNEL is on (#771).
|
|
232
|
+
.replace(/__BOX_ENV_B64__/g, Buffer.from(boxEnv || '', 'utf8').toString('base64'))
|
|
233
|
+
// /root/.config/otb/gds-session.json — the builder's GDS session, minted at
|
|
234
|
+
// provision so box-source-fetch (#600) can authenticate on FIRST boot and
|
|
235
|
+
// clone the rank-scoped repo. Without it the box never clones (the #818
|
|
236
|
+
// chicken-and-egg: the session normally comes from /builder-setup, which
|
|
237
|
+
// lives in the un-cloned repo).
|
|
238
|
+
.replace(/__GDS_SESSION_B64__/g, Buffer.from(gdsSession || '', 'utf8').toString('base64'))
|
|
239
|
+
// #774: the browser-terminal units, inlined so they start at FIRST BOOT
|
|
240
|
+
// (independent of the repo clone). ONE canonical copy lives in infra/.
|
|
241
|
+
.replace(/__BOX_TERMINAL_SERVICE_B64__/g, fileGzB64('box-terminal.service'))
|
|
242
|
+
// #917: credential-drift watcher pair — restarts ttyd when the credential
|
|
243
|
+
// file lands/changes after the service started (the wake/first-boot race).
|
|
244
|
+
.replace(/__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/g, fileGzB64('box-terminal-credsync.service'))
|
|
245
|
+
.replace(/__BOX_TERMINAL_CREDENTIAL_PATH_B64__/g, fileGzB64('box-terminal-credential.path'))
|
|
246
|
+
.replace(/__CLOUDFLARED_SERVICE_B64__/g, fileGzB64('cloudflared.service'))
|
|
247
|
+
.replace(/__CLOUDFLARED_RUN_B64__/g, fileGzB64('cloudflared-run.sh'))
|
|
248
|
+
// #893: the `close-box` teardown command, present at first boot so a bare
|
|
249
|
+
// browser-only box has it before any repo clone (box-source-fetch refreshes
|
|
250
|
+
// it on existing boxes). ONE canonical copy lives in infra/close-box.sh.
|
|
251
|
+
.replace(/__CLOSE_BOX_B64__/g, fileGzB64('close-box.sh'))
|
|
252
|
+
// task 986: the browser-terminal auto-launcher — runs `claude` on first
|
|
253
|
+
// connect so the builder skips the "type claude" step. box-source-fetch
|
|
254
|
+
// refreshes it on existing boxes. ONE canonical copy in infra/otb-box-launch.sh.
|
|
255
|
+
.replace(/__OTB_BOX_LAUNCH_B64__/g, fileGzB64('otb-box-launch.sh'));
|
|
256
|
+
if (missingInfra.length) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`box cloud-init is incomplete under ${infraDir}: missing ${missingInfra.join(', ')} `
|
|
259
|
+
+ `— a box would boot half-wired (ADR 0150). Fix the instance infra/ before provisioning.`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return rendered;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
module.exports = {
|
|
267
|
+
CF,
|
|
268
|
+
CONFIG,
|
|
269
|
+
boxInfraCandidates,
|
|
270
|
+
dropletName,
|
|
271
|
+
hasFlag,
|
|
272
|
+
hostnameFor,
|
|
273
|
+
instanceApex,
|
|
274
|
+
numEnv,
|
|
275
|
+
readCloudInit,
|
|
276
|
+
resolveBoxInfraDir,
|
|
277
|
+
runDnsHook,
|
|
278
|
+
runDnsHookBlackhole,
|
|
279
|
+
shortStamp,
|
|
280
|
+
};
|
package/scripts/gds/box.js
CHANGED
|
@@ -58,92 +58,7 @@ const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
|
|
|
58
58
|
// Ordered candidate infra/ dirs — first that actually holds the cloud-init
|
|
59
59
|
// template wins. Pure (env + repoRoot injected) so it is unit-testable without
|
|
60
60
|
// touching the real filesystem layout.
|
|
61
|
-
|
|
62
|
-
const out = [];
|
|
63
|
-
const seen = new Set();
|
|
64
|
-
const add = (d) => { if (d && !seen.has(d)) { seen.add(d); out.push(d); } };
|
|
65
|
-
// 1. explicit override: <PREFIX>_BOX_INFRA_DIR (operator escape hatch).
|
|
66
|
-
const override = instanceConfig.resolveEnv('BOX_INFRA_DIR', { env });
|
|
67
|
-
if (override) add(path.resolve(override));
|
|
68
|
-
// 2. the instance root via an EXPLICIT pointer (<PREFIX>_INSTANCE_ROOT env or a
|
|
69
|
-
// bongos.config.json {"instanceRoot":"."}) — where a vendored instance keeps infra/.
|
|
70
|
-
const inst = instanceConfig.resolveInstanceRootExplicit({ env });
|
|
71
|
-
if (inst) add(path.join(inst, 'infra'));
|
|
72
|
-
// 3. REPO_ROOT/infra — the dev single-checkout (and the ADR 0144 node_modules workaround).
|
|
73
|
-
add(path.join(repoRoot, 'infra'));
|
|
74
|
-
// 4. walk up out of node_modules from the core root, so a vendored
|
|
75
|
-
// …/node_modules/@bongos/core still finds its host instance's infra/ with no pointer set.
|
|
76
|
-
let dir = repoRoot;
|
|
77
|
-
for (let i = 0; i < 6; i += 1) {
|
|
78
|
-
const parent = path.dirname(dir);
|
|
79
|
-
if (parent === dir) break;
|
|
80
|
-
dir = parent;
|
|
81
|
-
add(path.join(dir, 'infra'));
|
|
82
|
-
}
|
|
83
|
-
return out;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// The resolved infra/ dir (first candidate holding the template), or null when the
|
|
87
|
-
// box cloud-init cannot be located anywhere — the caller MUST fail loud on null
|
|
88
|
-
// rather than provision a box with empty user-data (ADR 0150).
|
|
89
|
-
function resolveBoxInfraDir(opts = {}) {
|
|
90
|
-
for (const c of boxInfraCandidates(opts)) {
|
|
91
|
-
try { if (fs.existsSync(path.join(c, BOX_INFRA_TEMPLATE))) return c; }
|
|
92
|
-
catch { /* unreadable candidate → try the next */ }
|
|
93
|
-
}
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// task 1002757 — the instance's own apex; null when unconfigured (callers refuse).
|
|
98
|
-
const { instanceApex } = require('./instance-apex');
|
|
99
|
-
|
|
100
|
-
const CONFIG = {
|
|
101
|
-
floor: process.env.BOX_PROVISION_MIN_RANK || 'xenos',
|
|
102
|
-
region: process.env.BOX_REGION || 'nyc3',
|
|
103
|
-
size: process.env.BOX_SIZE || 's-2vcpu-4gb',
|
|
104
|
-
baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
|
|
105
|
-
idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
|
|
106
|
-
dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
|
|
107
|
-
// task 1002727 — how many days BEFORE the reclaim a builder gets warned.
|
|
108
|
-
dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
|
|
109
|
-
sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
110
|
-
dnsHook: process.env.BOX_DNS_HOOK || null,
|
|
111
|
-
tag: process.env.BOX_TAG || 'otb-builder-box',
|
|
112
|
-
// task 1002757 settled this file's old carve-out: an operator-plane DNS zone is
|
|
113
|
-
// still a FUNCTIONAL DEFAULT, and a founder literal aimed another operator's
|
|
114
|
-
// fleet at the founder's zone. Derived from the instance apex, null when
|
|
115
|
-
// unconfigured — hostnameFor() then refuses instead of minting a dead FQDN.
|
|
116
|
-
dnsZone: process.env.BOX_DNS_ZONE || (instanceApex() ? `dev.${instanceApex()}` : null),
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
// Named-tunnel automation for the browser terminal (#771, ADR 0038 Part B).
|
|
120
|
-
// OPT-IN, default OFF — an unset BOX_NAMED_TUNNEL leaves the box on the tokenless
|
|
121
|
-
// quick tunnel (trycloudflare.com), the zero-secret #760 default. When the operator
|
|
122
|
-
// turns it on AND supplies a Cloudflare token with Tunnel + DNS scope (the same
|
|
123
|
-
// CLOUDFLARE_API_TOKEN box-dns uses) + the account id, every provision creates a
|
|
124
|
-
// stable term-<login>.<terminalZone> tunnel and bakes its connector token into the
|
|
125
|
-
// box; deprovision tears it down.
|
|
126
|
-
const CF = {
|
|
127
|
-
enabled: /^(1|true|yes)$/i.test(process.env.BOX_NAMED_TUNNEL || ''),
|
|
128
|
-
apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
|
|
129
|
-
accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
|
|
130
|
-
// The Cloudflare ZONE the record lives in (the registrable apex) — the terminal
|
|
131
|
-
// host is a record WITHIN it, matching box-dns-cloudflare.sh. Distinct from
|
|
132
|
-
// terminalZone (the host suffix). Both resolve from the instance (task 1002757).
|
|
133
|
-
zone: process.env.CLOUDFLARE_ZONE || instanceApex(),
|
|
134
|
-
// Host suffix for term-<login>.<terminalZone>. DEFAULTS TO THE APEX, NOT dev.*:
|
|
135
|
-
// the terminal rides a PROXIED tunnel, and free Universal SSL covers <apex> +
|
|
136
|
-
// *.<apex> (ONE label) but not a TWO-label host like term-x.dev.<apex>, which
|
|
137
|
-
// fails TLS at the edge (verified live, #771). An operator holding an Advanced
|
|
138
|
-
// Certificate for *.dev.<apex> can override via BOX_TERMINAL_ZONE. (The SSH
|
|
139
|
-
// A-record path stays on dev.* — proxied=false, so it needs no edge cert.)
|
|
140
|
-
terminalZone: process.env.BOX_TERMINAL_ZONE || instanceApex(),
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
// Named-tunnel automation is live only when the operator opted in AND the
|
|
144
|
-
// credentials are present. Missing creds while enabled is a loud config error at
|
|
145
|
-
// provision time (see cmdProvision), not a silent fall-back, so an operator who
|
|
146
|
-
// meant to use named tunnels learns immediately.
|
|
61
|
+
const { CF, CONFIG, boxInfraCandidates, dropletName, hasFlag, hostnameFor, numEnv, readCloudInit, resolveBoxInfraDir, runDnsHook, runDnsHookBlackhole, shortStamp } = require('./box-infra.js');
|
|
147
62
|
function namedTunnelEnabled() { return CF.enabled; }
|
|
148
63
|
function namedTunnelConfigured() { return CF.enabled && CF.apiToken && CF.accountId; }
|
|
149
64
|
|
|
@@ -194,20 +109,6 @@ async function preflightCookieStripScope() {
|
|
|
194
109
|
return scope;
|
|
195
110
|
}
|
|
196
111
|
|
|
197
|
-
function numEnv(name, dflt) {
|
|
198
|
-
const v = process.env[name];
|
|
199
|
-
if (v == null || v === '') return dflt;
|
|
200
|
-
const n = Number(v);
|
|
201
|
-
return Number.isFinite(n) && n > 0 ? n : dflt;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function hasFlag(args, name) { return args.includes(name); }
|
|
205
|
-
|
|
206
|
-
// Lazy + memoized DB + lib load so `--help` and arg errors don't require Postgres
|
|
207
|
-
// (this CLI runs on a non-droplet machine for dry-run planning of pure logic too):
|
|
208
|
-
// nothing is require()d until the first makeDo/makeCf/IIFE call, and repeat calls
|
|
209
|
-
// reuse the first resolution instead of re-running the six requires.
|
|
210
|
-
let _deps;
|
|
211
112
|
function loadDeps() {
|
|
212
113
|
if (_deps) return _deps;
|
|
213
114
|
// eslint-disable-next-line global-require
|
|
@@ -274,151 +175,6 @@ async function resolveBuilder(pool, ref) {
|
|
|
274
175
|
}
|
|
275
176
|
|
|
276
177
|
// A short, DNS-safe-ish suffix from the current time (CLI context — Date is fine).
|
|
277
|
-
function shortStamp() {
|
|
278
|
-
return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(2, 14);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function dropletName(login) { return `otb-box-${login}`; }
|
|
282
|
-
// Refuses on an unresolved zone (task 1002757) — `box-x.undefined` would mint a
|
|
283
|
-
// DNS record and a box that can never be reached.
|
|
284
|
-
function hostnameFor(login) {
|
|
285
|
-
if (!CONFIG.dnsZone) throw new Error('box: no DNS zone configured — set BOX_DNS_ZONE, or give this instance a domains.publicOrigin in config/branding.json (task 1002757).');
|
|
286
|
-
return `box-${login}.${CONFIG.dnsZone}`;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// Run the optional DNS hook so the per-builder hostname tracks the new IP; with no
|
|
290
|
-
// hook configured, print the reminder — the stable-hostname CUTOVER is #599's, #598
|
|
291
|
-
// only records IP + hostname and offers the hook so an operator can wire DNS without
|
|
292
|
-
// editing this file. → { ok, error?, skipped? }. task 1180: callers MUST act on
|
|
293
|
-
// ok:false (box_events row + flag the box), never continue silently — that swallow
|
|
294
|
-
// left @kingaonline's A record pinned at a dead IP while provision reported success.
|
|
295
|
-
// The hook retries transient Cloudflare blips, so ok:false is a real, exhausted
|
|
296
|
-
// failure. `hook` is injectable so the contract is unit-testable (#1034).
|
|
297
|
-
function runDnsHook(hostname, ip, hook = CONFIG.dnsHook) {
|
|
298
|
-
if (!ip) return { ok: true, skipped: true };
|
|
299
|
-
if (!hook) {
|
|
300
|
-
console.log(` ↳ DNS: point ${hostname} → ${ip} (no BOX_DNS_HOOK set; #599 owns the cutover)`);
|
|
301
|
-
return { ok: true, skipped: true };
|
|
302
|
-
}
|
|
303
|
-
try {
|
|
304
|
-
execFileSync('bash', ['-c', hook], {
|
|
305
|
-
cwd: REPO_ROOT, stdio: 'pipe',
|
|
306
|
-
env: { ...process.env, BOX_HOSTNAME: hostname, BOX_IP: ip },
|
|
307
|
-
});
|
|
308
|
-
console.log(` ↳ DNS hook updated ${hostname} → ${ip}`);
|
|
309
|
-
return { ok: true };
|
|
310
|
-
} catch (e) {
|
|
311
|
-
const msg = (e.stderr && e.stderr.toString().trim()) || e.message;
|
|
312
|
-
console.log(` ⚠ DNS hook failed: ${msg} (point ${hostname} → ${ip} manually)`);
|
|
313
|
-
return { ok: false, error: msg };
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Blackhole sentinel for a torn-down box's A record (idea 309). 192.0.2.1 is in
|
|
318
|
-
// TEST-NET-1 (RFC 5737) — reserved for documentation and GUARANTEED never to be a
|
|
319
|
-
// routable host, so no recycled DigitalOcean IP can ever land here. A perfect
|
|
320
|
-
// dead end to park a record at.
|
|
321
|
-
const DNS_BLACKHOLE_IP = '192.0.2.1';
|
|
322
|
-
|
|
323
|
-
// Park the per-builder A record at the blackhole sentinel on deprovision (idea 309,
|
|
324
|
-
// refining task 1180). 1180 DELETED the record so it couldn't dangle at a recycled
|
|
325
|
-
// DO IP, but delete-then-recreate across a reap→rebuild left an NXDOMAIN window that
|
|
326
|
-
// resolvers negative-cache for the zone's SOA neg-TTL (~30 min, not builder-editable)
|
|
327
|
-
// — a client querying then kept getting ENOTFOUND long after the box was back.
|
|
328
|
-
// REPOINTING to a non-routable sentinel keeps the name always-resolving (nothing to
|
|
329
|
-
// negative-cache) while preserving 1180's guarantee that a torn-down box's name can
|
|
330
|
-
// never dial a stranger's recycled IP; the next provision/wake PUTs the live IP
|
|
331
|
-
// in-place at the 60s TTL, no gap. Best-effort like the prior delete. Reuses
|
|
332
|
-
// runDnsHook's UPSERT path; `hook` injectable for tests. → { ok, error?, skipped? }
|
|
333
|
-
function runDnsHookBlackhole(hostname, hook = CONFIG.dnsHook) {
|
|
334
|
-
if (!hook || !hostname) return { ok: true, skipped: true };
|
|
335
|
-
return runDnsHook(hostname, DNS_BLACKHOLE_IP, hook);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Read the cloud-init template and substitute the repo URL from BOX_REPO_URL
|
|
339
|
-
// (kept out of the repo file so no GitHub identity is hardcoded — V3.R12 rule).
|
|
340
|
-
// An unset URL leaves the placeholder; the cloud-init then logs guidance rather
|
|
341
|
-
// than cloning, and #600 wires the scoped credential.
|
|
342
|
-
// Read + render the cloud-init. `authorizedKeysBlock` is the otb-managed block
|
|
343
|
-
// for the builder being provisioned (#685) — base64'd here and baked in so the
|
|
344
|
-
// FIRST SSH login works before the box has a GDS token. An empty/absent block
|
|
345
|
-
// renders an empty managed block (a keyless box; the operator is warned upstream).
|
|
346
|
-
function readCloudInit({ authorizedKeysBlock = '', boxEnv = '', gdsSession = '', infraDir = resolveBoxInfraDir() } = {}) {
|
|
347
|
-
// Fail loud, never provision a BARE box (ADR 0150). No infra/ located → there is
|
|
348
|
-
// no cloud-init to render, so THROW here rather than the old silent `return null`
|
|
349
|
-
// that let cmdProvision ship empty user-data (a box that boots as bare Ubuntu).
|
|
350
|
-
if (!infraDir) {
|
|
351
|
-
throw new Error(
|
|
352
|
-
`box cloud-init not found: no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
|
|
353
|
-
+ `(set <PREFIX>_BOX_INFRA_DIR or <PREFIX>_INSTANCE_ROOT, or ship infra/). A box must `
|
|
354
|
-
+ `never be provisioned without user-data — it would boot bare (ADR 0150).`,
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
const p = path.join(infraDir, BOX_INFRA_TEMPLATE);
|
|
358
|
-
// gzip+base64 a repo file into the cloud-init write_files (`encoding: gz+b64`).
|
|
359
|
-
// EVERY inlined infra file goes through this: cloud-init gunzips it back to the
|
|
360
|
-
// exact bytes on the box, so it is byte-identical to plain b64 but ~2-3x smaller,
|
|
361
|
-
// which keeps the rendered user-data well under DO's 64 KiB cap (#903; the source
|
|
362
|
-
// -fetch growth in task 1314 had pushed the all-b64-but-source-fetch render to
|
|
363
|
-
// 67 KB — gz+b64-ing the rest brings it back to ~51 KB, task 1321). Only the
|
|
364
|
-
// dynamic runtime values (authorized_keys, box.env, gds-session) stay plain b64
|
|
365
|
-
// below — they are minted per-provision, not read from infra/. A missing inlined
|
|
366
|
-
// file is COLLECTED and fails loud after the render (ADR 0150) — a half-wired box
|
|
367
|
-
// (that component silently never boots) is a bug, not something to warn past.
|
|
368
|
-
const missingInfra = [];
|
|
369
|
-
const fileGzB64 = (name) => {
|
|
370
|
-
try {
|
|
371
|
-
return zlib.gzipSync(fs.readFileSync(path.join(infraDir, name))).toString('base64');
|
|
372
|
-
} catch {
|
|
373
|
-
missingInfra.push(name);
|
|
374
|
-
return '';
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
const raw = fs.readFileSync(p, 'utf8');
|
|
378
|
-
// Inject the rank-gated source-fetch (#600) + authorized-keys-fetch (#685)
|
|
379
|
-
// scripts as gz+b64 (a single YAML-safe token — no indentation/escaping
|
|
380
|
-
// pitfalls; cloud-init gunzips encoding: gz+b64 back to the exact file), plus
|
|
381
|
-
// the baked authorized_keys block. ONE canonical copy of each lives in infra/.
|
|
382
|
-
const rendered = raw
|
|
383
|
-
.replace(/__REPO_URL__/g, process.env.BOX_REPO_URL || '')
|
|
384
|
-
.replace(/__BOX_SOURCE_FETCH_B64__/g, fileGzB64('box-source-fetch.sh'))
|
|
385
|
-
.replace(/__BOX_AUTHKEYS_FETCH_B64__/g, fileGzB64('box-authorized-keys-fetch.sh'))
|
|
386
|
-
.replace(/__BUILDER_AUTHORIZED_KEYS_B64__/g, Buffer.from(authorizedKeysBlock || '', 'utf8').toString('base64'))
|
|
387
|
-
// /etc/otb/box.env — empty by default (quick tunnel); the named-tunnel
|
|
388
|
-
// connector token + stable hostname when BOX_NAMED_TUNNEL is on (#771).
|
|
389
|
-
.replace(/__BOX_ENV_B64__/g, Buffer.from(boxEnv || '', 'utf8').toString('base64'))
|
|
390
|
-
// /root/.config/otb/gds-session.json — the builder's GDS session, minted at
|
|
391
|
-
// provision so box-source-fetch (#600) can authenticate on FIRST boot and
|
|
392
|
-
// clone the rank-scoped repo. Without it the box never clones (the #818
|
|
393
|
-
// chicken-and-egg: the session normally comes from /builder-setup, which
|
|
394
|
-
// lives in the un-cloned repo).
|
|
395
|
-
.replace(/__GDS_SESSION_B64__/g, Buffer.from(gdsSession || '', 'utf8').toString('base64'))
|
|
396
|
-
// #774: the browser-terminal units, inlined so they start at FIRST BOOT
|
|
397
|
-
// (independent of the repo clone). ONE canonical copy lives in infra/.
|
|
398
|
-
.replace(/__BOX_TERMINAL_SERVICE_B64__/g, fileGzB64('box-terminal.service'))
|
|
399
|
-
// #917: credential-drift watcher pair — restarts ttyd when the credential
|
|
400
|
-
// file lands/changes after the service started (the wake/first-boot race).
|
|
401
|
-
.replace(/__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/g, fileGzB64('box-terminal-credsync.service'))
|
|
402
|
-
.replace(/__BOX_TERMINAL_CREDENTIAL_PATH_B64__/g, fileGzB64('box-terminal-credential.path'))
|
|
403
|
-
.replace(/__CLOUDFLARED_SERVICE_B64__/g, fileGzB64('cloudflared.service'))
|
|
404
|
-
.replace(/__CLOUDFLARED_RUN_B64__/g, fileGzB64('cloudflared-run.sh'))
|
|
405
|
-
// #893: the `close-box` teardown command, present at first boot so a bare
|
|
406
|
-
// browser-only box has it before any repo clone (box-source-fetch refreshes
|
|
407
|
-
// it on existing boxes). ONE canonical copy lives in infra/close-box.sh.
|
|
408
|
-
.replace(/__CLOSE_BOX_B64__/g, fileGzB64('close-box.sh'))
|
|
409
|
-
// task 986: the browser-terminal auto-launcher — runs `claude` on first
|
|
410
|
-
// connect so the builder skips the "type claude" step. box-source-fetch
|
|
411
|
-
// refreshes it on existing boxes. ONE canonical copy in infra/otb-box-launch.sh.
|
|
412
|
-
.replace(/__OTB_BOX_LAUNCH_B64__/g, fileGzB64('otb-box-launch.sh'));
|
|
413
|
-
if (missingInfra.length) {
|
|
414
|
-
throw new Error(
|
|
415
|
-
`box cloud-init is incomplete under ${infraDir}: missing ${missingInfra.join(', ')} `
|
|
416
|
-
+ `— a box would boot half-wired (ADR 0150). Fix the instance infra/ before provisioning.`,
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
return rendered;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
178
|
// ---- provision -------------------------------------------------------------
|
|
423
179
|
|
|
424
180
|
async function cmdProvision(pool, boxes, boxOnboard, ref, { apply }) {
|