@codeyam-editor/codeyam-editor 0.1.5 → 0.1.6
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/npm/cli.js +5 -1
- package/npm/cloud.js +1884 -210
- package/npm/codesign-plan.js +68 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +114 -9
- package/npm/fleet-destroy-safety.js +230 -13
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-live-status.js +40 -8
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +98 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +87 -11
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +234 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reset.js +32 -0
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +19 -12
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-stuck-status.js +13 -7
- package/npm/fleet-vm-access.js +170 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +136 -3
- package/npm/google-ingress.js +15 -0
- package/npm/ingress-invoke.js +478 -0
- package/npm/launch-config.js +182 -6
- package/npm/scenario-check.js +282 -10
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +286 -12
- package/npm/script-drift.js +72 -1
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-DgM0pTAx.js} +1 -1
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BLqRCxQX.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-DCHqRrwO.js} +4 -4
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
- package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
- package/ui/dist/assets/index-CIXZkwfB.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
package/npm/google-ingress.js
CHANGED
|
@@ -194,6 +194,20 @@ function dashboardHostRule(domain) {
|
|
|
194
194
|
};
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/** Extract the VM number from an LB-ingress resource name this module mints
|
|
198
|
+
* (`vm-<N>-<surface>-neg` or `vm-<N>-<surface>-bes`, surface ∈ SURFACES).
|
|
199
|
+
* Returns the number, or null for any name that isn't a per-VM ingress resource
|
|
200
|
+
* (the shared `codeyam-dashboard-*` backend, a foreign resource, junk). The
|
|
201
|
+
* inverse of negName / backendServiceName — the orphan sweep uses it to map the
|
|
202
|
+
* live LB backends back to the VM numbers that own them. The surface alternation
|
|
203
|
+
* is built from SURFACES so it stays in lockstep with the name derivations.
|
|
204
|
+
* Pure. */
|
|
205
|
+
function vmNumberFromResourceName(name) {
|
|
206
|
+
const re = new RegExp(`^vm-(\\d+)-(?:${SURFACES.join('|')})-(?:neg|bes)$`);
|
|
207
|
+
const m = re.exec(String(name == null ? '' : name).trim());
|
|
208
|
+
return m ? Number(m[1]) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
197
211
|
module.exports = {
|
|
198
212
|
SURFACES,
|
|
199
213
|
EDITOR_PORT,
|
|
@@ -201,6 +215,7 @@ module.exports = {
|
|
|
201
215
|
normalizeSurface,
|
|
202
216
|
negName,
|
|
203
217
|
backendServiceName,
|
|
218
|
+
vmNumberFromResourceName,
|
|
204
219
|
urlMapHostRules,
|
|
205
220
|
hostRuleOperation,
|
|
206
221
|
backendNegAttachOperation,
|
package/npm/ingress-invoke.js
CHANGED
|
@@ -26,6 +26,13 @@ const { resolveFleetDomain } = require('./vm-urls');
|
|
|
26
26
|
// would, rather than passing an empty DNS_ZONE that shadows the script default.
|
|
27
27
|
const DEFAULT_DNS_ZONE = 'fleet-zone';
|
|
28
28
|
|
|
29
|
+
// The backend-service timeout (seconds) the editor's WebSocket needs to survive a
|
|
30
|
+
// normal Build session. On an External HTTPS LB this value is the MAX lifetime a
|
|
31
|
+
// WebSocket may stay open; 86400 (24h) clears the longest plausible session while
|
|
32
|
+
// GCP still tears the socket down on client disconnect. Mirrors the literal
|
|
33
|
+
// vm-ingress.sh creates BES with. See plan vm--lb-backend-service-websocket-timeout.
|
|
34
|
+
const BES_WEBSOCKET_TIMEOUT_SEC = 86400;
|
|
35
|
+
|
|
29
36
|
/** Resolve the fleet domain that governs ingress wiring, or null when none is
|
|
30
37
|
* configured (legacy quick-tunnel mode). `cfg.fleetDomain` (persisted in
|
|
31
38
|
* cloud.json) wins; the `FLEET_DOMAIN` env value is the fallback. Both paths go
|
|
@@ -130,6 +137,37 @@ function vmIngressStatusInvocation({
|
|
|
130
137
|
return { command: 'bash', args: [scriptPath, String(n), 'status'], env };
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
/** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> grant-access` —
|
|
141
|
+
* the grant-only pass that (re-)applies one VM's default IAP access grant
|
|
142
|
+
* (persisted collaborators + standing operator + automation SA) WITHOUT touching
|
|
143
|
+
* any NEG/BES/host-rule resource. Returns `{ command, args, env }`. Like
|
|
144
|
+
* `vmIngressStatusInvocation` but the action is fixed to `grant-access` and
|
|
145
|
+
* APP_PORT is omitted — the grant targets backend services by NAME (keyed off the
|
|
146
|
+
* VM number), never the port. An optional `slug` rides through as VM_SLUG for
|
|
147
|
+
* symmetry with the other actions, though the grant itself is slug-independent.
|
|
148
|
+
* Reconcile spawns this for already-`wired` VMs its re-wire loop skips, so a
|
|
149
|
+
* healed VM whose backends already exist still (re-)grants the SA accessor. */
|
|
150
|
+
function vmIngressGrantAccessInvocation({
|
|
151
|
+
scriptPath,
|
|
152
|
+
vmNumber,
|
|
153
|
+
domain,
|
|
154
|
+
project,
|
|
155
|
+
zone,
|
|
156
|
+
slug,
|
|
157
|
+
} = {}) {
|
|
158
|
+
if (!scriptPath) throw new Error('vmIngressGrantAccessInvocation requires scriptPath');
|
|
159
|
+
const n = Number(vmNumber);
|
|
160
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
161
|
+
throw new Error(`vm-ingress grant-access requires a positive VM number, got: ${vmNumber}`);
|
|
162
|
+
}
|
|
163
|
+
if (!domain) throw new Error('vmIngressGrantAccessInvocation requires a fleet domain');
|
|
164
|
+
const env = { FLEET_DOMAIN: domain };
|
|
165
|
+
if (project) env.GCP_PROJECT = project;
|
|
166
|
+
if (zone) env.ZONE = zone;
|
|
167
|
+
if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
|
|
168
|
+
return { command: 'bash', args: [scriptPath, String(n), 'grant-access'], env };
|
|
169
|
+
}
|
|
170
|
+
|
|
133
171
|
/** Parse the `verdict=<wired|partial|unwired>` line vm-ingress.sh `status`
|
|
134
172
|
* prints, returning that verdict or null when the output carries none (e.g. the
|
|
135
173
|
* script errored before emitting it). Pure string parsing so the reconcile
|
|
@@ -143,6 +181,21 @@ function parseIngressStatusVerdict(stdout) {
|
|
|
143
181
|
return null;
|
|
144
182
|
}
|
|
145
183
|
|
|
184
|
+
/** Parse the `verdict=<torn-down|orphaned-ingress>` line vm-ingress.sh `down`
|
|
185
|
+
* prints, returning that verdict or null when the output carries none (e.g. the
|
|
186
|
+
* script died before emitting it, or was spawned with an older script). Pure
|
|
187
|
+
* string parsing so the dashboard's destroy-teardown handling — clean vs record
|
|
188
|
+
* an orphaned-ingress attention item — stays unit-testable without spawning the
|
|
189
|
+
* script. Mirrors parseIngressStatusVerdict for the `down` verdict vocabulary. */
|
|
190
|
+
function parseIngressTeardownVerdict(stdout) {
|
|
191
|
+
if (typeof stdout !== 'string') return null;
|
|
192
|
+
for (const line of stdout.split('\n')) {
|
|
193
|
+
const m = /^verdict=(torn-down|orphaned-ingress)\s*$/.exec(line.trim());
|
|
194
|
+
if (m) return m[1];
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
146
199
|
/** Pick which rostered VMs reconcile must re-wire from their status verdicts.
|
|
147
200
|
* `statuses` is `[{ vmNumber, verdict, ... }]`; a VM is selected iff its verdict
|
|
148
201
|
* is `unwired` or `partial` (it has missing LB resources). `wired` VMs are left
|
|
@@ -155,6 +208,194 @@ function selectIngressReconcileTargets(statuses) {
|
|
|
155
208
|
return statuses.filter((s) => s && (s.verdict === 'unwired' || s.verdict === 'partial'));
|
|
156
209
|
}
|
|
157
210
|
|
|
211
|
+
/** Pick which rostered VMs reconcile must apply the grant-only IAP pass to — the
|
|
212
|
+
* complement of the re-wire loop that closes the SA-accessor heal-path gap. A VM
|
|
213
|
+
* is selected iff its verdict is `wired`: its backend services already exist (so
|
|
214
|
+
* they are the grant's target) but the re-wire loop SKIPS it, meaning `up` — the
|
|
215
|
+
* only path that (re-)grants the automation SA the IAP accessor — never runs for
|
|
216
|
+
* it. Re-wire targets (`unwired`/`partial`) are excluded here because their `up`
|
|
217
|
+
* already grants; an unknown/null verdict (the probe failed) is excluded too —
|
|
218
|
+
* the BES may not exist, so a grant would only warn. The result is disjoint from
|
|
219
|
+
* `selectIngressReconcileTargets`, so no VM is both re-wired and grant-passed.
|
|
220
|
+
* Returns the selected entries in input order, preserving extra fields (slug).
|
|
221
|
+
* Pure. */
|
|
222
|
+
function selectIngressGrantOnlyTargets(statuses) {
|
|
223
|
+
if (!Array.isArray(statuses)) return [];
|
|
224
|
+
return statuses.filter((s) => s && s.verdict === 'wired');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Compute the `process.kill` target that reaps a timed-out reconcile's ENTIRE
|
|
228
|
+
* process group. The dashboard spawns `cloud:reconcile-ingress` detached, so the
|
|
229
|
+
* child shell leads its own process group (pgid === child.pid) and a negative-pid
|
|
230
|
+
* signal reaps every descendant — `node cloud.js` → `bash vm-ingress.sh` →
|
|
231
|
+
* `gcloud` — instead of only the immediate `sh -c` child (what an exec/SIGTERM
|
|
232
|
+
* timeout does, orphaning the descendants to init where they pile up and wedge the
|
|
233
|
+
* dashboard). Returns `-pid` for a real detached child, or `null` when the pid is
|
|
234
|
+
* missing / already reaped or would be catastrophic to negate. The `> 1` guard is
|
|
235
|
+
* load-bearing: `process.kill(-1, …)` signals EVERY process the user owns and
|
|
236
|
+
* `process.kill(0, …)`/`-0` targets the caller's OWN group — a missing pid must
|
|
237
|
+
* never be turned into a fleet-wide or self-directed SIGKILL. Pure: pid in, target
|
|
238
|
+
* out, so the timeout-kill decision is unit-testable without spawning a process. */
|
|
239
|
+
function reconcileGroupKillTarget(childPid) {
|
|
240
|
+
if (!Number.isInteger(childPid) || childPid <= 1) return null;
|
|
241
|
+
return -childPid;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Pick the genuine orphans from the VM numbers that currently HAVE LB ingress
|
|
245
|
+
* resources (`ingressVmNumbers`, derived from the live backend-service AND NEG
|
|
246
|
+
* names). A number is an orphan iff it has ingress but is NOT in the roster,
|
|
247
|
+
* has NO in-flight add, AND is NOT a live GCP instance — the false-orphan
|
|
248
|
+
* guards (vm--auto-clean-orphaned-lb-ingress) that stop the auto-clean from
|
|
249
|
+
* ripping ingress out from under a VM that is mid-provision (its `add-<n>.json`
|
|
250
|
+
* sidecar and, moments later, its roster entry land before ingress finishes
|
|
251
|
+
* wiring), transiently unlisted, or genuinely running but not yet re-rostered
|
|
252
|
+
* (a roster drift — tearing down a *live* VM's ingress would knock it offline).
|
|
253
|
+
* Numbers not yet rostered but with an in-flight add, or with a live instance,
|
|
254
|
+
* are treated as live, not orphans. `liveVmNumbers` is optional; omit it (or
|
|
255
|
+
* pass a non-array) to disable the live-instance guard. Returns the orphan
|
|
256
|
+
* numbers deduped and sorted ascending. Pure. */
|
|
257
|
+
function selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers } = {}) {
|
|
258
|
+
const rosterSet = new Set((Array.isArray(roster) ? roster : []).map(Number));
|
|
259
|
+
const inFlightSet = new Set((Array.isArray(inFlightAdds) ? inFlightAdds : []).map(Number));
|
|
260
|
+
const liveSet = new Set((Array.isArray(liveVmNumbers) ? liveVmNumbers : []).map(Number));
|
|
261
|
+
const orphans = new Set();
|
|
262
|
+
for (const raw of Array.isArray(ingressVmNumbers) ? ingressVmNumbers : []) {
|
|
263
|
+
const n = Number(raw);
|
|
264
|
+
if (!Number.isInteger(n) || n <= 0) continue;
|
|
265
|
+
if (rosterSet.has(n) || inFlightSet.has(n) || liveSet.has(n)) continue;
|
|
266
|
+
orphans.add(n);
|
|
267
|
+
}
|
|
268
|
+
return [...orphans].sort((a, b) => a - b);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Render a `vm-ingress.sh down` child's termination honestly for an operator log.
|
|
272
|
+
* A child killed by a SIGNAL has a NULL exit code, so the old
|
|
273
|
+
* `code != null ? code : 0` rendering printed the most misleading string
|
|
274
|
+
* available — `exit=0` — for a teardown the supervising timeout had just
|
|
275
|
+
* SIGTERMed (the destroy-time bug: `verdict=none, exit=0` on a script that never
|
|
276
|
+
* got to finish). Reports the signal when there is one, `spawn-failed` when
|
|
277
|
+
* neither a code nor a signal is available, and the real code otherwise. Pure so
|
|
278
|
+
* the reporting is unit-testable without spawning anything. */
|
|
279
|
+
function describeTeardownExit({ status, signal } = {}) {
|
|
280
|
+
if (signal) return `killed=${signal}`;
|
|
281
|
+
if (status == null) return 'exit=spawn-failed';
|
|
282
|
+
return `exit=${status}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Classify one `vm-ingress.sh <N> down` result into cleaned vs failed. A clean
|
|
286
|
+
* teardown requires BOTH the verified `verdict === 'torn-down'` AND a zero exit
|
|
287
|
+
* — anything else (the `orphaned-ingress` verdict, a null/missing verdict from
|
|
288
|
+
* an older script or a crash, a non-zero exit, or a signal kill) is a failure
|
|
289
|
+
* the caller surfaces with its cause. `signal` is optional: pass it and a
|
|
290
|
+
* timeout-killed child is reported as `killed=SIGTERM` instead of masquerading
|
|
291
|
+
* as a clean `exit=0`. Shared by the orphan sweep (npm/cloud.js
|
|
292
|
+
* sweepOrphanIngress, spawnSync) and the dashboard's destroy-time teardown
|
|
293
|
+
* (server.js tearDownVmIngress, detached spawn) so the two can never drift on
|
|
294
|
+
* what "cleaned" means. Returns `{ cleaned: true }` or
|
|
295
|
+
* `{ cleaned: false, cause }`. Pure so the cleaned-vs-failed decision is
|
|
296
|
+
* unit-testable without spawning the script. */
|
|
297
|
+
function classifyOrphanDownResult({ verdict, status, signal } = {}) {
|
|
298
|
+
if (verdict === 'torn-down' && status === 0 && !signal) return { cleaned: true };
|
|
299
|
+
return { cleaned: false, cause: `down verdict=${verdict || 'none'}, ${describeTeardownExit({ status, signal })}` };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Parse the machine-readable `CY_ORPHAN_SWEEP <json>` summary line the orphan
|
|
303
|
+
* sweep prints into `{ cleaned:[n], failed:[{n, cause}], skipped:[n] }` — plus
|
|
304
|
+
* `stillPresent:[n]` (VM numbers that still own ≥1 LB resource) ONLY when the
|
|
305
|
+
* summary carries that field. Its ABSENCE (an older reconcile binary that never
|
|
306
|
+
* emitted it) is preserved as an absent key, NOT normalized to `[]`, so the
|
|
307
|
+
* dashboard's reconcile can tell "sweep reported no residual" (`[]`) apart from
|
|
308
|
+
* "sweep can't say" (undefined) and leave stale banners untouched in the latter.
|
|
309
|
+
* Returns null when no `CY_ORPHAN_SWEEP` line is present (no sweep ran, or one
|
|
310
|
+
* died before emitting) so the dashboard can distinguish "no sweep ran" from
|
|
311
|
+
* "sweep ran, found nothing". The LAST such line wins (a reconcile emits one).
|
|
312
|
+
* Malformed JSON is skipped, not thrown. Pure so the dashboard's orphan-state
|
|
313
|
+
* reconciliation stays unit-testable without spawning the reconcile. */
|
|
314
|
+
function parseOrphanSweepSummary(stdout) {
|
|
315
|
+
if (typeof stdout !== 'string') return null;
|
|
316
|
+
let found = null;
|
|
317
|
+
for (const line of stdout.split('\n')) {
|
|
318
|
+
const m = /^CY_ORPHAN_SWEEP\s+(\{.*\})\s*$/.exec(line.trim());
|
|
319
|
+
if (!m) continue;
|
|
320
|
+
let o;
|
|
321
|
+
try { o = JSON.parse(m[1]); } catch { continue; }
|
|
322
|
+
const nums = (arr) => (Array.isArray(arr) ? arr.map(Number).filter((n) => Number.isInteger(n) && n > 0) : []);
|
|
323
|
+
found = {
|
|
324
|
+
cleaned: nums(o.cleaned),
|
|
325
|
+
failed: (Array.isArray(o.failed) ? o.failed : [])
|
|
326
|
+
.filter((f) => f && Number.isInteger(Number(f.n)) && Number(f.n) > 0)
|
|
327
|
+
.map((f) => ({ n: Number(f.n), cause: String(f.cause || 'auto-clean did not verify clean') })),
|
|
328
|
+
skipped: nums(o.skipped),
|
|
329
|
+
};
|
|
330
|
+
if (Array.isArray(o.stillPresent)) found.stillPresent = nums(o.stillPresent);
|
|
331
|
+
}
|
|
332
|
+
return found;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The reconcile-direction mirror of {@link selectOrphanIngressTargets}: given
|
|
336
|
+
* the VM numbers that currently carry an orphan-attention banner (`orphanKeys`,
|
|
337
|
+
* the keys of the dashboard's orphanedIngress map), the live `roster`, and the
|
|
338
|
+
* sweep's `stillPresent` set (VM numbers that still own ≥1 LB resource), decide
|
|
339
|
+
* which banners are STALE and must be cleared. A banner is stale iff its VM is
|
|
340
|
+
* (a) back in the roster — a destroy→re-add churn re-rostered it, freezing an
|
|
341
|
+
* old `failed` banner the sweep never revisits (the VM-3 case) — or (b) reported
|
|
342
|
+
* as owning no residual LB resource (absent from `stillPresent`), e.g. a VM
|
|
343
|
+
* cleaned outside a recorded sweep. When `stillPresent` is NOT an array (an older
|
|
344
|
+
* reconcile binary that never emitted the field) this returns `[]` so the caller
|
|
345
|
+
* leaves every banner untouched rather than clearing all of them — the absence of
|
|
346
|
+
* the field means "the sweep can't say," not "nothing is present." Junk keys
|
|
347
|
+
* (non-positive / non-integer) are ignored. Returns the VM numbers to clear,
|
|
348
|
+
* deduped ascending. Pure so the reconcile is unit-testable without the server. */
|
|
349
|
+
function staleOrphanEntries({ orphanKeys, roster, stillPresent } = {}) {
|
|
350
|
+
if (!Array.isArray(stillPresent)) return [];
|
|
351
|
+
const present = new Set(stillPresent.map(Number));
|
|
352
|
+
const rosterSet = new Set((Array.isArray(roster) ? roster : []).map(Number));
|
|
353
|
+
const stale = new Set();
|
|
354
|
+
for (const raw of Array.isArray(orphanKeys) ? orphanKeys : []) {
|
|
355
|
+
const n = Number(raw);
|
|
356
|
+
if (!Number.isInteger(n) || n <= 0) continue;
|
|
357
|
+
if (rosterSet.has(n) || !present.has(n)) stale.add(n);
|
|
358
|
+
}
|
|
359
|
+
return [...stale].sort((a, b) => a - b);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Decide the next step of the add-time ingress CONVERGE loop
|
|
363
|
+
* (vm--add-time-url-wiring-converges-reliably). The detached add-tail no longer
|
|
364
|
+
* fires `vm-ingress.sh up` ONCE and trusts the slow periodic reconcile to catch
|
|
365
|
+
* up — it re-runs `up`+`status` on its own tight cadence until the VM's verdict
|
|
366
|
+
* is `wired` or a bounded deadline elapses. This pure decision keeps that loop
|
|
367
|
+
* unit-testable without spawning the script or sleeping:
|
|
368
|
+
*
|
|
369
|
+
* - `verdict === 'wired'` → `{ done: true, wired: true }`. Converged; stop.
|
|
370
|
+
* - elapsed ≥ `deadlineMs` → `{ done: true, wired: false }`. Give up; the
|
|
371
|
+
* caller surfaces `wiring-failed` (a real attention reason) rather than a
|
|
372
|
+
* silent `partial` left for the sweep.
|
|
373
|
+
* - otherwise → `{ done: false, wired: false, delayMs }` where `delayMs` is an
|
|
374
|
+
* exponential backoff `baseDelayMs * 2**attempt` capped at `maxDelayMs`, so
|
|
375
|
+
* the first retries are quick (seconds) and later ones ease off.
|
|
376
|
+
*
|
|
377
|
+
* `attempt` is 0-based (the delay AFTER the Nth attempt). Pure. */
|
|
378
|
+
function ingressConvergeDecision({
|
|
379
|
+
verdict,
|
|
380
|
+
elapsedMs,
|
|
381
|
+
attempt,
|
|
382
|
+
deadlineMs,
|
|
383
|
+
baseDelayMs,
|
|
384
|
+
maxDelayMs,
|
|
385
|
+
} = {}) {
|
|
386
|
+
if (verdict === 'wired') return { done: true, wired: true, delayMs: 0 };
|
|
387
|
+
const elapsed = Number(elapsedMs) || 0;
|
|
388
|
+
const deadline = Number(deadlineMs);
|
|
389
|
+
if (Number.isFinite(deadline) && elapsed >= deadline) {
|
|
390
|
+
return { done: true, wired: false, delayMs: 0 };
|
|
391
|
+
}
|
|
392
|
+
const base = Number(baseDelayMs) || 0;
|
|
393
|
+
const max = Number.isFinite(Number(maxDelayMs)) ? Number(maxDelayMs) : base;
|
|
394
|
+
const a = Math.max(0, Number(attempt) || 0);
|
|
395
|
+
const delayMs = Math.min(max, base * 2 ** a);
|
|
396
|
+
return { done: false, wired: false, delayMs };
|
|
397
|
+
}
|
|
398
|
+
|
|
158
399
|
// =============================================================================
|
|
159
400
|
// Async add-time wiring (vm--async-add-time-lb-ingress-wiring)
|
|
160
401
|
// =============================================================================
|
|
@@ -200,6 +441,23 @@ function ingressInflightMarkerPath(dir, vmNumber) {
|
|
|
200
441
|
return `${dir.replace(/\/+$/, '')}/cy-ingress-inflight-vm-${n}`;
|
|
201
442
|
}
|
|
202
443
|
|
|
444
|
+
/** The per-VM marker file the detached add-tail drops when its converge loop
|
|
445
|
+
* exhausts the deadline WITHOUT reaching `wired`
|
|
446
|
+
* (vm--add-time-url-wiring-converges-reliably). It is the durable signal that
|
|
447
|
+
* the URL is genuinely `wiring-failed` — a real attention reason the card shows
|
|
448
|
+
* with the re-run command — as opposed to still-`wiring` (the inflight marker
|
|
449
|
+
* above) or a transient probe miss. The tail clears any prior failed marker when
|
|
450
|
+
* it re-enters the loop and unlinks it on success, so a later self-heal doesn't
|
|
451
|
+
* leave a stale failure behind. Pure path derivation; the create/unlink/stat I/O
|
|
452
|
+
* lives at the call sites, mirroring the inflight-marker split. */
|
|
453
|
+
function ingressFailedMarkerPath(dir, vmNumber) {
|
|
454
|
+
const n = Number(vmNumber);
|
|
455
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
456
|
+
throw new Error(`ingressFailedMarkerPath requires a positive VM number, got: ${vmNumber}`);
|
|
457
|
+
}
|
|
458
|
+
return `${dir.replace(/\/+$/, '')}/cy-ingress-failed-vm-${n}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
203
461
|
/** Decide whether a freshly-added VM's detached wiring is still in flight, given
|
|
204
462
|
* the marker file's CONTENTS (the unix-ms start timestamp the tail wrote) and the
|
|
205
463
|
* current time. Returns false when the marker is missing/empty (`content` null) —
|
|
@@ -251,6 +509,29 @@ function iapAccessInvocation({
|
|
|
251
509
|
return { command: 'bash', args: [scriptPath, String(n), action], env };
|
|
252
510
|
}
|
|
253
511
|
|
|
512
|
+
/** Extract the `BLOCKED:` / `Next valid action:` recovery pair out of a failed
|
|
513
|
+
* script's combined stdout+stderr. PURE.
|
|
514
|
+
*
|
|
515
|
+
* The CLI error conventions (CLAUDE.md) require those two markers to be emitted
|
|
516
|
+
* together, and the actionable content is the `Next valid action:` body — an
|
|
517
|
+
* org-policy remedy, a re-run command. Callers previously surfaced
|
|
518
|
+
* `out.slice(-400)`, a blind tail that just as easily lands mid-stack-trace and
|
|
519
|
+
* drops the remedy entirely; that opacity is most of why the 2026-07-30 VM-10
|
|
520
|
+
* lockout took hours to diagnose.
|
|
521
|
+
*
|
|
522
|
+
* Returns the text from the FIRST `BLOCKED:` marker to the end of the output,
|
|
523
|
+
* whitespace-collapsed for a single-line toast, capped at `maxLen`. Returns ''
|
|
524
|
+
* when the output carries no recovery pair, so callers can fall back to their
|
|
525
|
+
* existing tail rather than showing an empty message. */
|
|
526
|
+
function extractRecoveryLines(output, maxLen = 600) {
|
|
527
|
+
const text = String(output == null ? '' : output);
|
|
528
|
+
const start = text.indexOf('BLOCKED:');
|
|
529
|
+
if (start === -1) return '';
|
|
530
|
+
if (!/Next valid action:/.test(text.slice(start))) return '';
|
|
531
|
+
const block = text.slice(start).replace(/\s+/g, ' ').trim();
|
|
532
|
+
return block.length > maxLen ? `${block.slice(0, maxLen - 1)}…` : block;
|
|
533
|
+
}
|
|
534
|
+
|
|
254
535
|
/** Build the local invocation for `scripts/gcp/google-ingress.sh` — the shared,
|
|
255
536
|
* VM-independent LB infra (static IP, wildcard cert, Cloud DNS, URL map, IAP
|
|
256
537
|
* brand). Returns `{ command, args, env }`; `env` carries only the keys the
|
|
@@ -275,18 +556,215 @@ function sharedIngressInvocation({
|
|
|
275
556
|
return { command: 'bash', args: [scriptPath], env };
|
|
276
557
|
}
|
|
277
558
|
|
|
559
|
+
/** Build the `gcloud compute backend-services update <bes> --global
|
|
560
|
+
* --timeout=<sec>` invocation — the periodic ingress reconcile's per-BES
|
|
561
|
+
* WebSocket-timeout converge. On a GCP External HTTPS LB the backend-service
|
|
562
|
+
* timeout is the MAX WebSocket lifetime (not a request/idle timeout), so a BES
|
|
563
|
+
* left at GCP's 30s default guillotines the editor's /ws/terminal socket every
|
|
564
|
+
* 30s (the Build-tab "Reconnecting…" loop). Returns `{ command, args }`;
|
|
565
|
+
* `project` adds `--project <id>` when supplied (omitted so gcloud's active
|
|
566
|
+
* config applies). `timeoutSec` defaults to {@link BES_WEBSOCKET_TIMEOUT_SEC}
|
|
567
|
+
* (86400 = 24h). Pure argv shaping, unit-tested without invoking gcloud — the
|
|
568
|
+
* same single-source posture as the bash always-converge in vm-ingress.sh. See
|
|
569
|
+
* plan vm--lb-backend-service-websocket-timeout. */
|
|
570
|
+
function backendServiceTimeoutInvocation({ bes, project, timeoutSec = BES_WEBSOCKET_TIMEOUT_SEC } = {}) {
|
|
571
|
+
if (bes == null || String(bes).trim() === '') {
|
|
572
|
+
throw new Error('backendServiceTimeoutInvocation requires a backend-service name');
|
|
573
|
+
}
|
|
574
|
+
const sec = Number(timeoutSec);
|
|
575
|
+
if (!Number.isInteger(sec) || sec <= 0) {
|
|
576
|
+
throw new Error(`backendServiceTimeoutInvocation requires a positive timeoutSec, got: ${timeoutSec}`);
|
|
577
|
+
}
|
|
578
|
+
const args = [
|
|
579
|
+
'compute', 'backend-services', 'update', String(bes).trim(),
|
|
580
|
+
'--global', `--timeout=${sec}`,
|
|
581
|
+
];
|
|
582
|
+
if (project != null && String(project).trim() !== '') {
|
|
583
|
+
args.push('--project', String(project).trim());
|
|
584
|
+
}
|
|
585
|
+
return { command: 'gcloud', args };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The shared VPC firewall rule admitting Google's LB + health-check ranges to the
|
|
589
|
+
// fleet. FLEET_FIREWALL_RULE_NAME is the DEPLOYED name — hand-created during the
|
|
590
|
+
// 2026-06-15 LB pilot and adopted as canonical so the converge reaches the live
|
|
591
|
+
// rule with no create/delete against a running fleet. FLEET_FIREWALL_RULE_NAMES
|
|
592
|
+
// additionally carries the drifted name google-ingress.sh managed before the
|
|
593
|
+
// unification (the words reversed), because a converge that describes only one
|
|
594
|
+
// name silently no-ops on a fleet wired under the other. Converging every
|
|
595
|
+
// candidate that exists is safe: `update --rules=tcp` only rewrites allowed
|
|
596
|
+
// ports, never a rule's source ranges or target scope.
|
|
597
|
+
// See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall.
|
|
598
|
+
const FLEET_FIREWALL_RULE_NAME = 'fleet-ingress-allow-lb';
|
|
599
|
+
const FLEET_FIREWALL_RULE_NAMES = [FLEET_FIREWALL_RULE_NAME, 'fleet-ingress-lb-allow'];
|
|
600
|
+
|
|
601
|
+
/** Build the `gcloud compute firewall-rules describe <rule>` invocation that reads
|
|
602
|
+
* the fleet firewall's currently-allowed protocols/ports. The
|
|
603
|
+
* `value(allowed[].map().firewall_rule().list())` projection is the same one
|
|
604
|
+
* google-ingress.sh:350-351 reads, so the bash converge and the reconcile-cadence
|
|
605
|
+
* converge judge the rule from a byte-identical view of it. `project` adds
|
|
606
|
+
* `--project <id>` when supplied (omitted so gcloud's active config applies).
|
|
607
|
+
* Pure argv shaping, unit-tested without invoking gcloud.
|
|
608
|
+
* See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
|
|
609
|
+
function fleetFirewallDescribeInvocation({ ruleName, project } = {}) {
|
|
610
|
+
if (ruleName == null || String(ruleName).trim() === '') {
|
|
611
|
+
throw new Error('fleetFirewallDescribeInvocation requires a firewall rule name');
|
|
612
|
+
}
|
|
613
|
+
const args = [
|
|
614
|
+
'compute', 'firewall-rules', 'describe', String(ruleName).trim(),
|
|
615
|
+
"--format=value(allowed[].map().firewall_rule().list())",
|
|
616
|
+
];
|
|
617
|
+
if (project != null && String(project).trim() !== '') {
|
|
618
|
+
args.push('--project', String(project).trim());
|
|
619
|
+
}
|
|
620
|
+
return { command: 'gcloud', args };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Build the `gcloud compute firewall-rules update <rule> --rules=tcp` invocation
|
|
624
|
+
* that converges the fleet firewall to all-tcp. `update --rules=…` rewrites ONLY
|
|
625
|
+
* the allowed protocols/ports — it leaves the rule's source ranges and target
|
|
626
|
+
* scope untouched — so converging a live rule can never widen or narrow who the
|
|
627
|
+
* rule applies to. That is load-bearing: the deployed rule's scope is the one
|
|
628
|
+
* currently admitting LB traffic to the fleet, and this converge preserves it.
|
|
629
|
+
* `project` adds `--project <id>` when supplied. Pure argv shaping, unit-tested
|
|
630
|
+
* without invoking gcloud.
|
|
631
|
+
* See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
|
|
632
|
+
function fleetFirewallConvergeInvocation({ ruleName, project } = {}) {
|
|
633
|
+
if (ruleName == null || String(ruleName).trim() === '') {
|
|
634
|
+
throw new Error('fleetFirewallConvergeInvocation requires a firewall rule name');
|
|
635
|
+
}
|
|
636
|
+
const args = [
|
|
637
|
+
'compute', 'firewall-rules', 'update', String(ruleName).trim(),
|
|
638
|
+
'--rules=tcp',
|
|
639
|
+
];
|
|
640
|
+
if (project != null && String(project).trim() !== '') {
|
|
641
|
+
args.push('--project', String(project).trim());
|
|
642
|
+
}
|
|
643
|
+
return { command: 'gcloud', args };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Decide whether a described firewall rule still needs the all-tcp converge —
|
|
647
|
+
* the JS twin of the bash guard at google-ingress.sh:352-358. Returns false ONLY
|
|
648
|
+
* for the exact all-tcp value `"tcp"`; every port-pinned list
|
|
649
|
+
* (`"tcp:14199,tcp:5173,tcp:3000"` — the VM-6-on-8080 outage), every
|
|
650
|
+
* protocol-narrowed value, and an empty/absent describe read as "needs converge".
|
|
651
|
+
* Erring toward converging is safe: `update --rules=tcp` is idempotent. */
|
|
652
|
+
function firewallNeedsConverge(currentRulesString) {
|
|
653
|
+
if (currentRulesString == null) return true;
|
|
654
|
+
return String(currentRulesString).trim() !== 'tcp';
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Decide whether a described firewall rule admits TCP traffic to `port` — the
|
|
658
|
+
* tripwire for a masked "HEALTHY but unreachable" preview. The preview BES health
|
|
659
|
+
* check runs on the fixed editor port (14199), so a rule that admits 14199 but not
|
|
660
|
+
* the app's real port reads HEALTHY while every real request is dropped: exactly
|
|
661
|
+
* the VM-6-on-8080 outage. Parses the same `allowed` projection
|
|
662
|
+
* {@link fleetFirewallDescribeInvocation} reads: bare `tcp` admits every port,
|
|
663
|
+
* `tcp:5173,tcp:3000` admits only those, and `tcp:1000-2000` admits an inclusive
|
|
664
|
+
* range. Returns false for an unparseable/empty rule string or a non-numeric port
|
|
665
|
+
* — a gap we cannot disprove is reported, never silently passed. */
|
|
666
|
+
function firewallAdmitsPort(currentRulesString, port) {
|
|
667
|
+
const p = Number(port);
|
|
668
|
+
if (!Number.isInteger(p) || p <= 0) return false;
|
|
669
|
+
if (currentRulesString == null) return false;
|
|
670
|
+
const entries = String(currentRulesString)
|
|
671
|
+
.split(',')
|
|
672
|
+
.map((e) => e.trim())
|
|
673
|
+
.filter(Boolean);
|
|
674
|
+
return entries.some((entry) => {
|
|
675
|
+
const [proto, ports] = entry.split(':');
|
|
676
|
+
if (proto.trim().toLowerCase() !== 'tcp') return false;
|
|
677
|
+
// Bare `tcp` (no port suffix) is the all-tcp converge target — admits everything.
|
|
678
|
+
if (ports == null || ports.trim() === '') return true;
|
|
679
|
+
const [lo, hi] = ports.trim().split('-');
|
|
680
|
+
const low = Number(lo);
|
|
681
|
+
if (!Number.isInteger(low)) return false;
|
|
682
|
+
if (hi == null) return low === p;
|
|
683
|
+
const high = Number(hi);
|
|
684
|
+
if (!Number.isInteger(high)) return false;
|
|
685
|
+
return p >= low && p <= high;
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Build the `gcloud compute backend-services list --global --format=value(name)`
|
|
690
|
+
* invocation the orphan sweep uses to enumerate every LB-ingress backend by
|
|
691
|
+
* name (the sweep maps those names back to VM numbers via
|
|
692
|
+
* google-ingress.vmNumberFromResourceName). `project` adds `--project <id>` when
|
|
693
|
+
* supplied (omitted so gcloud's active config applies). Pure argv shaping,
|
|
694
|
+
* unit-tested without invoking gcloud. */
|
|
695
|
+
function backendServicesListInvocation({ project } = {}) {
|
|
696
|
+
const args = ['compute', 'backend-services', 'list', '--global', '--format=value(name)'];
|
|
697
|
+
if (project != null && String(project).trim() !== '') {
|
|
698
|
+
args.push('--project', String(project).trim());
|
|
699
|
+
}
|
|
700
|
+
return { command: 'gcloud', args };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Build the `gcloud compute network-endpoint-groups list --format=value(name)`
|
|
704
|
+
* invocation the orphan sweep uses to enumerate every zonal NEG by name. A
|
|
705
|
+
* partial teardown can leave a `vm-<N>-<surface>-neg` behind after its backend
|
|
706
|
+
* service is already gone (the VM-5/6 case), so keying orphan candidates off
|
|
707
|
+
* backend services alone misses that residue — the sweep unions these NEG owners
|
|
708
|
+
* in. `project` adds `--project <id>` when supplied (omitted so gcloud's active
|
|
709
|
+
* config applies). Pure argv shaping, unit-tested without invoking gcloud. */
|
|
710
|
+
function negListInvocation({ project } = {}) {
|
|
711
|
+
const args = ['compute', 'network-endpoint-groups', 'list', '--format=value(name)'];
|
|
712
|
+
if (project != null && String(project).trim() !== '') {
|
|
713
|
+
args.push('--project', String(project).trim());
|
|
714
|
+
}
|
|
715
|
+
return { command: 'gcloud', args };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Build the `gcloud compute instances list --format=value(name)` invocation the
|
|
719
|
+
* orphan sweep uses for its defensive live-instance guard: a VM whose GCP
|
|
720
|
+
* instance is actually running must never have its ingress torn down, even if a
|
|
721
|
+
* roster drift left it unrostered (a successful teardown would knock a live VM
|
|
722
|
+
* offline). The names map back to VM numbers via fleet-reconcile.parseRunningFleetVms.
|
|
723
|
+
* `project` adds `--project <id>` when supplied. Pure argv shaping, unit-tested
|
|
724
|
+
* without invoking gcloud. */
|
|
725
|
+
function instancesListInvocation({ project } = {}) {
|
|
726
|
+
const args = ['compute', 'instances', 'list', '--format=value(name)'];
|
|
727
|
+
if (project != null && String(project).trim() !== '') {
|
|
728
|
+
args.push('--project', String(project).trim());
|
|
729
|
+
}
|
|
730
|
+
return { command: 'gcloud', args };
|
|
731
|
+
}
|
|
732
|
+
|
|
278
733
|
module.exports = {
|
|
734
|
+
BES_WEBSOCKET_TIMEOUT_SEC,
|
|
735
|
+
backendServiceTimeoutInvocation,
|
|
279
736
|
DEFAULT_DNS_ZONE,
|
|
280
737
|
resolveIngressDomain,
|
|
281
738
|
resolveDnsZone,
|
|
282
739
|
regionFromZone,
|
|
283
740
|
vmIngressInvocation,
|
|
284
741
|
vmIngressStatusInvocation,
|
|
742
|
+
vmIngressGrantAccessInvocation,
|
|
285
743
|
parseIngressStatusVerdict,
|
|
744
|
+
parseIngressTeardownVerdict,
|
|
286
745
|
selectIngressReconcileTargets,
|
|
746
|
+
selectIngressGrantOnlyTargets,
|
|
747
|
+
reconcileGroupKillTarget,
|
|
748
|
+
selectOrphanIngressTargets,
|
|
749
|
+
describeTeardownExit,
|
|
750
|
+
classifyOrphanDownResult,
|
|
751
|
+
parseOrphanSweepSummary,
|
|
752
|
+
staleOrphanEntries,
|
|
753
|
+
backendServicesListInvocation,
|
|
754
|
+
negListInvocation,
|
|
755
|
+
instancesListInvocation,
|
|
756
|
+
FLEET_FIREWALL_RULE_NAME,
|
|
757
|
+
FLEET_FIREWALL_RULE_NAMES,
|
|
758
|
+
fleetFirewallDescribeInvocation,
|
|
759
|
+
fleetFirewallConvergeInvocation,
|
|
760
|
+
firewallNeedsConverge,
|
|
761
|
+
firewallAdmitsPort,
|
|
762
|
+
ingressConvergeDecision,
|
|
287
763
|
detachedAddTailInvocation,
|
|
288
764
|
ingressInflightMarkerPath,
|
|
765
|
+
ingressFailedMarkerPath,
|
|
289
766
|
isWiringInflight,
|
|
290
767
|
iapAccessInvocation,
|
|
768
|
+
extractRecoveryLines,
|
|
291
769
|
sharedIngressInvocation,
|
|
292
770
|
};
|