@codeyam-editor/codeyam-editor 0.1.4 → 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.
Files changed (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -0,0 +1,770 @@
1
+ 'use strict';
2
+ // Pure builders for the LOCAL shell invocations that wire the fleet's Google
3
+ // HTTPS Load Balancer ingress: the shared, VM-independent infra
4
+ // (scripts/gcp/google-ingress.sh) and one VM's per-surface backends
5
+ // (scripts/gcp/vm-ingress.sh). cloud.js calls these to decide WHEN to wire and
6
+ // to build the exact `{ command, args, env }` it spawns — keeping the decision
7
+ // and the argv/env shape unit-testable without ever spawning a process, exactly
8
+ // like npm/vm-urls.js and npm/google-ingress.js single-source their derivations.
9
+ //
10
+ // Pure logic only — no I/O, no env reads beyond the explicit `env`/`cfg`
11
+ // arguments callers pass in. The fleet domain governing ingress comes from the
12
+ // project's `.codeyam/cloud.json` (`fleetDomain`, persisted by cloud.js) with a
13
+ // `FLEET_DOMAIN` env fallback; when neither is set every builder reports the
14
+ // legacy quick-tunnel mode (resolveIngressDomain → null) and cloud.js leaves the
15
+ // Add/Destroy path byte-for-byte unchanged.
16
+ //
17
+ // Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that
18
+ // runs codeyam-editor itself), GCP-specific by design — the same posture as the
19
+ // scripts it invokes. A project that never joins the fleet never sets
20
+ // fleetDomain and never reaches a builder here.
21
+
22
+ const { resolveFleetDomain } = require('./vm-urls');
23
+
24
+ // Cloud DNS managed-zone name google-ingress.sh defaults to. Mirrored here so a
25
+ // cloud.json without an explicit `dnsZone` resolves to the same zone the script
26
+ // would, rather than passing an empty DNS_ZONE that shadows the script default.
27
+ const DEFAULT_DNS_ZONE = 'fleet-zone';
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
+
36
+ /** Resolve the fleet domain that governs ingress wiring, or null when none is
37
+ * configured (legacy quick-tunnel mode). `cfg.fleetDomain` (persisted in
38
+ * cloud.json) wins; the `FLEET_DOMAIN` env value is the fallback. Both paths go
39
+ * through vm-urls.js::resolveFleetDomain, so a present-but-malformed value
40
+ * throws here (a typo'd domain must fail loud, never bake a dead host into the
41
+ * URL map) and a blank value reads as "unset". */
42
+ function resolveIngressDomain(cfg = {}, env = {}) {
43
+ const fromCfg = cfg.fleetDomain;
44
+ if (fromCfg != null && String(fromCfg).trim() !== '') {
45
+ return resolveFleetDomain({ FLEET_DOMAIN: fromCfg });
46
+ }
47
+ return resolveFleetDomain(env);
48
+ }
49
+
50
+ /** Resolve the Cloud DNS managed-zone name: `cfg.dnsZone`, then a `DNS_ZONE`
51
+ * env override, then the `fleet-zone` default google-ingress.sh itself uses. */
52
+ function resolveDnsZone(cfg = {}, env = {}) {
53
+ const fromCfg = cfg.dnsZone;
54
+ if (fromCfg != null && String(fromCfg).trim() !== '') return String(fromCfg).trim();
55
+ const fromEnv = env.DNS_ZONE;
56
+ if (fromEnv != null && String(fromEnv).trim() !== '') return String(fromEnv).trim();
57
+ return DEFAULT_DNS_ZONE;
58
+ }
59
+
60
+ /** Derive the GCE region from a zone (`us-central1-a` → `us-central1`), or null
61
+ * when the zone is missing/unparseable so the caller omits REGION and lets
62
+ * google-ingress.sh apply its own default. A GCE zone is a region plus a
63
+ * trailing `-<letter>`. */
64
+ function regionFromZone(zone) {
65
+ if (zone == null) return null;
66
+ const z = String(zone).trim();
67
+ if (z === '') return null;
68
+ const m = /^(.+)-[a-z]$/.exec(z);
69
+ return m ? m[1] : null;
70
+ }
71
+
72
+ /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> <up|down>` —
73
+ * one VM's per-surface NEG → IAP backend → URL-map host rule. Returns
74
+ * `{ command, args, env }`; `env` carries only the keys the script reads beyond
75
+ * its own defaults (GCP_PROJECT, ZONE, FLEET_DOMAIN, APP_PORT, INSTANCE_PREFIX,
76
+ * VM_SLUG). `appPort` is the resolved per-project dev-server port the preview
77
+ * NEG targets; omit it (down, or unknown) to let the script's bootstrap default
78
+ * stand. An optional `slug` becomes `VM_SLUG`, which makes the script route the
79
+ * VM's custom `<slug>.<surface>.<domain>` host (the same slug must be passed on
80
+ * `down` so the matching host rule is removed); omit it to keep the numeric
81
+ * `vm-<N>` host. */
82
+ function vmIngressInvocation({
83
+ scriptPath,
84
+ vmNumber,
85
+ action,
86
+ domain,
87
+ project,
88
+ zone,
89
+ appPort,
90
+ instancePrefix,
91
+ slug,
92
+ } = {}) {
93
+ if (!scriptPath) throw new Error('vmIngressInvocation requires scriptPath');
94
+ if (action !== 'up' && action !== 'down') {
95
+ throw new Error(`vm-ingress action must be 'up' or 'down', got: ${action}`);
96
+ }
97
+ const n = Number(vmNumber);
98
+ if (!Number.isInteger(n) || n <= 0) {
99
+ throw new Error(`vm-ingress requires a positive VM number, got: ${vmNumber}`);
100
+ }
101
+ if (!domain) throw new Error('vmIngressInvocation requires a fleet domain');
102
+ const env = { FLEET_DOMAIN: domain };
103
+ if (project) env.GCP_PROJECT = project;
104
+ if (zone) env.ZONE = zone;
105
+ if (appPort != null && String(appPort).trim() !== '') env.APP_PORT = String(appPort);
106
+ if (instancePrefix) env.INSTANCE_PREFIX = instancePrefix;
107
+ if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
108
+ return { command: 'bash', args: [scriptPath, String(n), action], env };
109
+ }
110
+
111
+ /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> status` — the
112
+ * read-only probe that reports which of {NEG, backend service, host rule} exist
113
+ * per surface plus an overall `verdict=wired|partial|unwired`. Returns
114
+ * `{ command, args, env }`. Like `vmIngressInvocation` but the action is fixed
115
+ * to `status` and APP_PORT is omitted — status describes resources by NAME, and
116
+ * names key off the VM number, never the port. An optional `slug` rides through
117
+ * as VM_SLUG so the host-rule presence check matches the VM's custom
118
+ * `<slug>.<surface>.<domain>` host rather than the numeric `vm-<N>` one. */
119
+ function vmIngressStatusInvocation({
120
+ scriptPath,
121
+ vmNumber,
122
+ domain,
123
+ project,
124
+ zone,
125
+ slug,
126
+ } = {}) {
127
+ if (!scriptPath) throw new Error('vmIngressStatusInvocation requires scriptPath');
128
+ const n = Number(vmNumber);
129
+ if (!Number.isInteger(n) || n <= 0) {
130
+ throw new Error(`vm-ingress status requires a positive VM number, got: ${vmNumber}`);
131
+ }
132
+ if (!domain) throw new Error('vmIngressStatusInvocation requires a fleet domain');
133
+ const env = { FLEET_DOMAIN: domain };
134
+ if (project) env.GCP_PROJECT = project;
135
+ if (zone) env.ZONE = zone;
136
+ if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
137
+ return { command: 'bash', args: [scriptPath, String(n), 'status'], env };
138
+ }
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
+
171
+ /** Parse the `verdict=<wired|partial|unwired>` line vm-ingress.sh `status`
172
+ * prints, returning that verdict or null when the output carries none (e.g. the
173
+ * script errored before emitting it). Pure string parsing so the reconcile
174
+ * decision below stays unit-testable without spawning the script. */
175
+ function parseIngressStatusVerdict(stdout) {
176
+ if (typeof stdout !== 'string') return null;
177
+ for (const line of stdout.split('\n')) {
178
+ const m = /^verdict=(wired|partial|unwired)\s*$/.exec(line.trim());
179
+ if (m) return m[1];
180
+ }
181
+ return null;
182
+ }
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
+
199
+ /** Pick which rostered VMs reconcile must re-wire from their status verdicts.
200
+ * `statuses` is `[{ vmNumber, verdict, ... }]`; a VM is selected iff its verdict
201
+ * is `unwired` or `partial` (it has missing LB resources). `wired` VMs are left
202
+ * untouched — no redundant gcloud churn — and an unknown/null verdict (the probe
203
+ * failed) is conservatively SKIPPED rather than blindly re-wired. Returns the
204
+ * selected entries in their input order, preserving any extra fields (slug) the
205
+ * caller threads through. Pure. */
206
+ function selectIngressReconcileTargets(statuses) {
207
+ if (!Array.isArray(statuses)) return [];
208
+ return statuses.filter((s) => s && (s.verdict === 'unwired' || s.verdict === 'partial'));
209
+ }
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
+
399
+ // =============================================================================
400
+ // Async add-time wiring (vm--async-add-time-lb-ingress-wiring)
401
+ // =============================================================================
402
+ // The Add used to BLOCK ~10 min on synchronous LB ingress wiring even after the
403
+ // container was healthy and serving. The fix returns the Add at container-healthy
404
+ // and converges the wiring in a DETACHED background tail (wire → access, the same
405
+ // ordering cmdUp ran inline) that outlives `cloud:up` — so the dashboard's reap of
406
+ // `cloud:up` can never SIGTERM an in-flight `backend-services create` (the add-time
407
+ // 503 strand the old 900s synchronous await was protecting against). The
408
+ // already-existing per-VM auto-rewire gather is the convergence backstop; it must
409
+ // NOT race the in-flight detached tail, so the tail drops an in-flight marker the
410
+ // dashboard reads via the pure `isWiringInflight` below.
411
+
412
+ /** Build the local invocation that re-execs THIS node entry (`scriptPath`, i.e.
413
+ * cloud.js) as a DETACHED `wire-add-tail` child: it runs the slow wiring + access
414
+ * grant in the background so the Add returns at container-healthy. Returns
415
+ * `{ command, args, env }`; the child reads `cfg`/`appPort` back out of the env
416
+ * (`CY_WIRE_ADD_CFG` JSON, `CY_WIRE_ADD_PORT`) rather than re-deriving them, so the
417
+ * background tail wires EXACTLY what the foreground Add would have. `nodePath` is
418
+ * the node executable to re-exec (caller passes `process.execPath`); kept a
419
+ * parameter so this stays pure/testable without reading the ambient env. */
420
+ function detachedAddTailInvocation({ nodePath, scriptPath, cfg, appPort } = {}) {
421
+ if (!nodePath) throw new Error('detachedAddTailInvocation requires nodePath');
422
+ if (!scriptPath) throw new Error('detachedAddTailInvocation requires scriptPath');
423
+ const env = { CY_WIRE_ADD_CFG: JSON.stringify(cfg || {}) };
424
+ if (appPort != null && String(appPort).trim() !== '') {
425
+ env.CY_WIRE_ADD_PORT = String(appPort);
426
+ }
427
+ return { command: nodePath, args: [scriptPath, 'wire-add-tail'], env };
428
+ }
429
+
430
+ /** The per-VM marker file the detached add-tail holds while its wiring is still
431
+ * running, so the dashboard's auto-rewire gather skips a VM whose FIRST wiring
432
+ * attempt is in flight (never firing a competing `vm-ingress.sh up`). Pure path
433
+ * derivation; the create/unlink/stat I/O lives at the call sites (cloud.js writes
434
+ * it, server.js reads it), mirroring how the status/verdict split keeps the
435
+ * decision testable without touching the filesystem. */
436
+ function ingressInflightMarkerPath(dir, vmNumber) {
437
+ const n = Number(vmNumber);
438
+ if (!Number.isInteger(n) || n <= 0) {
439
+ throw new Error(`ingressInflightMarkerPath requires a positive VM number, got: ${vmNumber}`);
440
+ }
441
+ return `${dir.replace(/\/+$/, '')}/cy-ingress-inflight-vm-${n}`;
442
+ }
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
+
461
+ /** Decide whether a freshly-added VM's detached wiring is still in flight, given
462
+ * the marker file's CONTENTS (the unix-ms start timestamp the tail wrote) and the
463
+ * current time. Returns false when the marker is missing/empty (`content` null) —
464
+ * no tail running — or STALE (older than `maxAgeMs`): a tail that crashed without
465
+ * unlinking its marker must not pin auto-rewire off forever, so once the staleness
466
+ * bound (comfortably past the ~10-min worst-case wiring) elapses the backstop takes
467
+ * over. Pure: the caller does the `readFileSync`/`existsSync`. */
468
+ function isWiringInflight(content, now, maxAgeMs) {
469
+ if (content == null) return false;
470
+ const started = Number(String(content).trim());
471
+ if (!Number.isFinite(started) || started <= 0) return false;
472
+ return (Number(now) - started) < Number(maxAgeMs);
473
+ }
474
+
475
+ /** Build the local invocation for `scripts/gcp/iap-access.sh <N> <grant|revoke>`
476
+ * — grant or revoke `roles/iap.httpsResourceAccessor` on one VM's per-surface
477
+ * backend services for a set of principals (the per-VM access whitelist).
478
+ * Returns `{ command, args, env }`; `env.VM_ACCESS` is the comma-joined,
479
+ * trimmed principal list the script reads (omitted when empty, so a revoke with
480
+ * nothing to remove is a clean no-op), plus GCP_PROJECT / ZONE when supplied.
481
+ * Mirrors `vmIngressInvocation` so the spawn shape stays unit-testable without
482
+ * ever invoking gcloud. The principal STRINGS are passed through verbatim —
483
+ * validation/normalization is the sidecar's job (npm/fleet-vm-access.js); this
484
+ * builder only shapes the argv/env. */
485
+ function iapAccessInvocation({
486
+ scriptPath,
487
+ vmNumber,
488
+ action,
489
+ principals,
490
+ project,
491
+ zone,
492
+ } = {}) {
493
+ if (!scriptPath) throw new Error('iapAccessInvocation requires scriptPath');
494
+ if (action !== 'grant' && action !== 'revoke') {
495
+ throw new Error(`iap-access action must be 'grant' or 'revoke', got: ${action}`);
496
+ }
497
+ const n = Number(vmNumber);
498
+ if (!Number.isInteger(n) || n <= 0) {
499
+ throw new Error(`iap-access requires a positive VM number, got: ${vmNumber}`);
500
+ }
501
+ const list = Array.isArray(principals)
502
+ ? principals
503
+ : String(principals == null ? '' : principals).split(',');
504
+ const joined = list.map((p) => String(p).trim()).filter(Boolean).join(',');
505
+ const env = {};
506
+ if (joined) env.VM_ACCESS = joined;
507
+ if (project) env.GCP_PROJECT = project;
508
+ if (zone) env.ZONE = zone;
509
+ return { command: 'bash', args: [scriptPath, String(n), action], env };
510
+ }
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
+
535
+ /** Build the local invocation for `scripts/gcp/google-ingress.sh` — the shared,
536
+ * VM-independent LB infra (static IP, wildcard cert, Cloud DNS, URL map, IAP
537
+ * brand). Returns `{ command, args, env }`; `env` carries only the keys the
538
+ * script reads beyond its defaults (GCP_PROJECT, REGION, ZONE, FLEET_DOMAIN,
539
+ * DNS_ZONE). The script is idempotent, so re-invoking against existing infra is
540
+ * a safe no-op. */
541
+ function sharedIngressInvocation({
542
+ scriptPath,
543
+ domain,
544
+ project,
545
+ zone,
546
+ dnsZone,
547
+ region,
548
+ } = {}) {
549
+ if (!scriptPath) throw new Error('sharedIngressInvocation requires scriptPath');
550
+ if (!domain) throw new Error('sharedIngressInvocation requires a fleet domain');
551
+ const env = { FLEET_DOMAIN: domain };
552
+ if (project) env.GCP_PROJECT = project;
553
+ if (region) env.REGION = region;
554
+ if (zone) env.ZONE = zone;
555
+ if (dnsZone) env.DNS_ZONE = dnsZone;
556
+ return { command: 'bash', args: [scriptPath], env };
557
+ }
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
+
733
+ module.exports = {
734
+ BES_WEBSOCKET_TIMEOUT_SEC,
735
+ backendServiceTimeoutInvocation,
736
+ DEFAULT_DNS_ZONE,
737
+ resolveIngressDomain,
738
+ resolveDnsZone,
739
+ regionFromZone,
740
+ vmIngressInvocation,
741
+ vmIngressStatusInvocation,
742
+ vmIngressGrantAccessInvocation,
743
+ parseIngressStatusVerdict,
744
+ parseIngressTeardownVerdict,
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,
763
+ detachedAddTailInvocation,
764
+ ingressInflightMarkerPath,
765
+ ingressFailedMarkerPath,
766
+ isWiringInflight,
767
+ iapAccessInvocation,
768
+ extractRecoveryLines,
769
+ sharedIngressInvocation,
770
+ };