@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.
- package/npm/cli.js +5 -1
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- 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 +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- 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-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.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-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- 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-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -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/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
- 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
|
+
};
|