@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,394 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure classification + reason-string derivation for the dashboard's public-URL
|
|
3
|
+
// reachability probe (vm: Dashboard validates the public ingress URL, not just
|
|
4
|
+
// the tunnel).
|
|
5
|
+
//
|
|
6
|
+
// The fleet dashboard probes each VM's editor through the local SSH tunnel and,
|
|
7
|
+
// in Google-LB mode, the per-VM backend SERVICE health (gcloud get-health) —
|
|
8
|
+
// but never the public `<slug>.editor.<domain>` URL the card advertises. VM5
|
|
9
|
+
// (2026-06-19) sat "healthy" on every one of those signals while the advertised
|
|
10
|
+
// URL returned 503 "no healthy upstream" for hours: a reused-slug Add aborted on
|
|
11
|
+
// a `Duplicate path matcher` collision before binding the host rule, so the
|
|
12
|
+
// hostname fell through to the default backend. A backend-health probe is
|
|
13
|
+
// structurally blind to a missing host rule; only an end-to-end GET of the
|
|
14
|
+
// advertised hostname catches it.
|
|
15
|
+
//
|
|
16
|
+
// This module is the PURE half — parsing + string derivation, no network —
|
|
17
|
+
// mirroring classifyBackendHealth's `absent|unknown|unhealthy|healthy`
|
|
18
|
+
// contract. The HTTPS GET I/O lives in server.js's gatherPublicUrlHealth, the
|
|
19
|
+
// same split gatherBackendHealth / classifyBackendHealth already follow. Unit-
|
|
20
|
+
// tested in npm/fleet-public-url.test.js.
|
|
21
|
+
//
|
|
22
|
+
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
23
|
+
// codeyam-editor itself), GCP / HTTPS-LB-specific by design — the same posture
|
|
24
|
+
// as the scripts it backstops. A project that never joins the fleet never sets
|
|
25
|
+
// FLEET_DOMAIN and never reaches this probe.
|
|
26
|
+
|
|
27
|
+
// The IAP members probe parses the same `value(bindings.members)` gcloud shape
|
|
28
|
+
// the per-VM access sidecar already reads, so it reuses that parser rather than
|
|
29
|
+
// growing a second one. fleet-vm-access requires only utils + vm-urls, so this
|
|
30
|
+
// edge introduces no cycle.
|
|
31
|
+
const { parseProjectMembers } = require('./fleet-vm-access');
|
|
32
|
+
|
|
33
|
+
// The Google HTTPS-LB "no backend answered" body signature: GFE emits this when
|
|
34
|
+
// a host rule routes nowhere (or to a backendless default). A single source so
|
|
35
|
+
// the classifier and any future log line agree on the string.
|
|
36
|
+
const NO_HEALTHY_UPSTREAM_RE = /no healthy upstream/i;
|
|
37
|
+
|
|
38
|
+
/** Classify a public-URL probe result into the ingress-wiring vocabulary,
|
|
39
|
+
* following classifyBackendHealth's contract — absence of evidence is never a
|
|
40
|
+
* problem state:
|
|
41
|
+
*
|
|
42
|
+
* 'unknown' — a network / TLS / timeout / DNS error (no statusCode): the
|
|
43
|
+
* probe couldn't reach a verdict. NEVER a degraded state, exactly
|
|
44
|
+
* like classifyBackendHealth's 'unknown'. Also covers an
|
|
45
|
+
* unexpected non-503 HTTP error (a 404/500 from the editor
|
|
46
|
+
* itself) — that means the host rule DID route, just not to a
|
|
47
|
+
* happy response, so it is neither 'unwired' nor a clean 'wired'.
|
|
48
|
+
* 'unwired' — the LB answered but no backend is bound to the advertised host:
|
|
49
|
+
* a 503, or a recognizable "no healthy upstream" default-backend
|
|
50
|
+
* body. THIS is the missing-host-rule signal a backend-health
|
|
51
|
+
* probe cannot see (the VM5 case).
|
|
52
|
+
* 'wired' — any 2xx / 3xx: the editor answered (a 200, or its login 302
|
|
53
|
+
* redirect — the real VM5-healthy shape). The host rule routes.
|
|
54
|
+
*/
|
|
55
|
+
function classifyPublicUrlHealth({ statusCode, body, error } = {}) {
|
|
56
|
+
if (error || statusCode == null) return 'unknown';
|
|
57
|
+
const code = Number(statusCode);
|
|
58
|
+
if (code === 503) return 'unwired';
|
|
59
|
+
if (NO_HEALTHY_UPSTREAM_RE.test(String(body || ''))) return 'unwired';
|
|
60
|
+
if (code >= 200 && code < 400) return 'wired';
|
|
61
|
+
// Any other 4xx/5xx is the editor answering with an error of its own — the
|
|
62
|
+
// host rule routes, so it is NOT 'unwired'; but it is not a clean success
|
|
63
|
+
// either. Treat as 'unknown' (absence of the unwired signal) so we never raise
|
|
64
|
+
// a false ingress-unwired reason on it.
|
|
65
|
+
return 'unknown';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Classify a public-URL WebSocket-UPGRADE probe result into the same ingress-
|
|
69
|
+
* wiring vocabulary classifyPublicUrlHealth uses (vm: Readiness Verifies Real
|
|
70
|
+
* Usability). The plain GET proves the host rule ROUTES, but says NOTHING about
|
|
71
|
+
* whether the ingress carries a WebSocket UPGRADE — the exact VM5-class
|
|
72
|
+
* blindness: the editor answered HTTP 200 (its `101` even succeeded) while the
|
|
73
|
+
* LB severed the socket every 30s, so the Build tab looped "Reconnecting…" on a
|
|
74
|
+
* VM every readiness signal called healthy. server.js gathers a `101`-asserting
|
|
75
|
+
* upgrade against `<publicUrl>/ws/health` (the auth-free liveness endpoint that
|
|
76
|
+
* completes the upgrade, sends one frame, and closes — no broker, no PTY) and
|
|
77
|
+
* hands the result here. Follows the same absence-of-evidence contract:
|
|
78
|
+
*
|
|
79
|
+
* 'unknown' — a network / TLS / timeout error (no statusCode), OR a 3xx
|
|
80
|
+
* auth/IAP login redirect: the probe couldn't reach a verdict.
|
|
81
|
+
* NEVER a fault, exactly like classifyPublicUrlHealth's 'unknown'
|
|
82
|
+
* — a transient blip, or an IAP that bounced an un-authenticated
|
|
83
|
+
* probe to a login page, must never yank a healthy VM out of the
|
|
84
|
+
* roster. (An IAP-fronted VM whose browser WS works fine returns a
|
|
85
|
+
* 302 to a probe that carries no IAP credential — proof of nothing
|
|
86
|
+
* about the editor, so it cannot be 'unwired'.)
|
|
87
|
+
* 'wired' — the upgrade completed (HTTP 101). The WS path carries frames.
|
|
88
|
+
* 'unwired' — the LB/editor answered PAST auth and REFUSED the upgrade (a
|
|
89
|
+
* non-101, non-3xx code): a host/LB rule that doesn't carry
|
|
90
|
+
* WebSocket at all (the OTHER WS failure mode, distinct from the
|
|
91
|
+
* timeout guillotine below). This is the only definitive fault,
|
|
92
|
+
* so 'ws-dead' fires on it but never on the ambiguous 'unknown'.
|
|
93
|
+
*/
|
|
94
|
+
function classifyPublicWsHealth({ statusCode, error } = {}) {
|
|
95
|
+
if (error || statusCode == null) return 'unknown';
|
|
96
|
+
const code = Number(statusCode);
|
|
97
|
+
if (code === 101) return 'wired';
|
|
98
|
+
// A 3xx is an auth/IAP login redirect — the probe carries no IAP credential,
|
|
99
|
+
// so it can never complete the handshake. Absence of evidence, never a fault
|
|
100
|
+
// (mirrors classifyPublicUrlHealth's 3xx-is-'wired'/never-unwired treatment).
|
|
101
|
+
if (code >= 300 && code < 400) return 'unknown';
|
|
102
|
+
// A non-101, non-redirect response is the editor answering past auth and
|
|
103
|
+
// refusing the upgrade — the genuine WS-unwired signal.
|
|
104
|
+
return 'unwired';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The IAP role that actually authorizes opening a VM's public URL. A policy can
|
|
108
|
+
* carry any number of other bindings; only this one decides the verdict, and
|
|
109
|
+
* only its members are real IAP principals. */
|
|
110
|
+
const IAP_ACCESSOR_ROLE = 'roles/iap.httpsResourceAccessor';
|
|
111
|
+
|
|
112
|
+
/** Classify a gcloud IAP get-iam-policy result for a backend service. PURE —
|
|
113
|
+
* takes `{ error, stdout }` and returns `{ verdict, members }`.
|
|
114
|
+
*
|
|
115
|
+
* `verdict` is the unchanged three-value vocabulary the ingress rendering and
|
|
116
|
+
* fleet-vm-readiness already depend on:
|
|
117
|
+
* 'unknown' — the gcloud read itself errored (absence of evidence, never
|
|
118
|
+
* manufactured as a fault; mirrors the gather probes).
|
|
119
|
+
* 'granted' — at least one binding carries roles/iap.httpsResourceAccessor,
|
|
120
|
+
* so someone can actually open the VM's public URL.
|
|
121
|
+
* 'no-accessor' — the policy read cleanly but names no accessor role, i.e. the
|
|
122
|
+
* VM is wired but nobody is granted (the public URL 51s in the
|
|
123
|
+
* browser).
|
|
124
|
+
*
|
|
125
|
+
* `members` is the list of principals holding the accessor role — the fact the
|
|
126
|
+
* old bare-verdict shape was structurally incapable of reporting. Because
|
|
127
|
+
* `domain:codeyam.com` always holds the role, "is anyone granted?" was always
|
|
128
|
+
* true, so a specific collaborator's MISSING binding could never be detected;
|
|
129
|
+
* that blindness is what let the dashboard render a failed grant as access for
|
|
130
|
+
* hours (VM-10, 2026-07-30). `members` is `[]` on a read error and on
|
|
131
|
+
* 'no-accessor' — an empty list is never evidence of a gap by itself, the verdict
|
|
132
|
+
* carries that.
|
|
133
|
+
*
|
|
134
|
+
* Input is `--format=value(bindings.role,bindings.members)`: one line per
|
|
135
|
+
* binding, fields TAB-separated, members within a binding `;`-separated. Only
|
|
136
|
+
* the accessor binding's members are returned — a roles/owner binding does not
|
|
137
|
+
* grant IAP web access, so listing its members as IAP principals would be a
|
|
138
|
+
* different flavor of the same lie.
|
|
139
|
+
*/
|
|
140
|
+
function classifyIapAccess({ error, stdout } = {}) {
|
|
141
|
+
if (error) return { verdict: 'unknown', members: [] };
|
|
142
|
+
const lines = String(stdout || '')
|
|
143
|
+
.split('\n')
|
|
144
|
+
.map((l) => l.trim())
|
|
145
|
+
.filter(Boolean);
|
|
146
|
+
const accessorLines = lines.filter((l) => l.split('\t')[0].trim() === IAP_ACCESSOR_ROLE);
|
|
147
|
+
if (accessorLines.length === 0) return { verdict: 'no-accessor', members: [] };
|
|
148
|
+
// Reuse the members parser written for exactly this gcloud output shape
|
|
149
|
+
// (`;`/newline split, trim, dedupe preserving first appearance, drop `deleted:`
|
|
150
|
+
// tombstones) rather than growing a second one that would drift from it.
|
|
151
|
+
const members = parseProjectMembers(
|
|
152
|
+
accessorLines.map((l) => l.split('\t').slice(1).join('\t')).join('\n'),
|
|
153
|
+
);
|
|
154
|
+
return { verdict: 'granted', members };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Aggregate the per-surface IAP verdicts (editor + preview) into a single fleet
|
|
158
|
+
* verdict. PURE — takes `{ editor, preview }`, each a `classifyIapAccess`
|
|
159
|
+
* result, and returns:
|
|
160
|
+
* 'unknown' — either surface's read errored (absence of evidence,
|
|
161
|
+
* never manufactured as a fault).
|
|
162
|
+
* 'no-accessor' — the EDITOR backend has no accessor: the URL the
|
|
163
|
+
* operator opens 51s for everyone. The primary gap
|
|
164
|
+
* (dominates a preview gap — the editor is unusable).
|
|
165
|
+
* 'preview-no-accessor' — the editor grants access but the PREVIEW backend has
|
|
166
|
+
* no accessor. The exact shape that hid this bug: the
|
|
167
|
+
* editor loads, but the framed Live Preview (served
|
|
168
|
+
* from the separate preview host) is blank for every
|
|
169
|
+
* authorized user because nobody is granted there.
|
|
170
|
+
* 'granted' — BOTH surfaces authorize someone.
|
|
171
|
+
*
|
|
172
|
+
* The preview verdict is optional: an older dashboard that only gathered the
|
|
173
|
+
* editor surface passes `preview === undefined`, and the result collapses to the
|
|
174
|
+
* single-surface classification (never a phantom preview gap).
|
|
175
|
+
*
|
|
176
|
+
* Accepts each surface as either a bare verdict string or a full
|
|
177
|
+
* `classifyIapAccess` result `{ verdict, members }`, and RETURNS A STRING either
|
|
178
|
+
* way. The string return is load-bearing, not laziness: `deriveVmReadiness`
|
|
179
|
+
* compares `v.iapAccess` against 'no-accessor' / 'preview-no-accessor' directly
|
|
180
|
+
* (npm/fleet-vm-readiness.js), and the card's readiness-badge CSS keys off those
|
|
181
|
+
* same values. Members are aggregated separately by `aggregateIapMembers` so the
|
|
182
|
+
* verdict contract stays byte-identical. */
|
|
183
|
+
function aggregateIapAccess({ editor, preview } = {}) {
|
|
184
|
+
// Unwrap the `{ verdict, members }` shape; a bare string passes through, and
|
|
185
|
+
// undefined/null stay undefined/null so the single-surface fallback below and
|
|
186
|
+
// the 'unknown' guard both keep working.
|
|
187
|
+
const v = (s) => (s && typeof s === 'object' ? s.verdict : s);
|
|
188
|
+
editor = v(editor);
|
|
189
|
+
preview = v(preview);
|
|
190
|
+
// Absence of evidence on either surface is never a fault.
|
|
191
|
+
if (editor === 'unknown' || preview === 'unknown') return 'unknown';
|
|
192
|
+
// The editor is the URL the operator opens; if IT lacks an accessor, that is
|
|
193
|
+
// the primary gap and dominates any preview verdict.
|
|
194
|
+
if (editor !== 'granted') return 'no-accessor';
|
|
195
|
+
// Editor granted. With no preview verdict gathered, fall back to the
|
|
196
|
+
// editor-only 'granted' (backward-compatible with single-surface dashboards).
|
|
197
|
+
if (preview === undefined || preview === null) return 'granted';
|
|
198
|
+
// Editor granted but the preview host authorizes nobody — the framed preview
|
|
199
|
+
// is blank for authorized users even though the editor works.
|
|
200
|
+
if (preview !== 'granted') return 'preview-no-accessor';
|
|
201
|
+
return 'granted';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Aggregate the per-surface IAP MEMBER lists into the card's display truth.
|
|
205
|
+
* PURE — takes the same `{ editor, preview }` map of `classifyIapAccess`
|
|
206
|
+
* results `aggregateIapAccess` consumes, and returns
|
|
207
|
+
* `{ editor, preview, all, partial }`:
|
|
208
|
+
*
|
|
209
|
+
* editor / preview — that surface's accessor members ([] when the surface
|
|
210
|
+
* reported no accessor, or was not gathered).
|
|
211
|
+
* all — the union across surfaces, first-appearance order. This
|
|
212
|
+
* is what the card renders as "who actually has access".
|
|
213
|
+
* partial — true when a principal holds the role on SOME but not all
|
|
214
|
+
* gathered surfaces. That is the editor-works /
|
|
215
|
+
* preview-blank shape, visible per-principal rather than
|
|
216
|
+
* only as the coarse 'preview-no-accessor' verdict.
|
|
217
|
+
*
|
|
218
|
+
* Returns `null` when either gathered surface errored ('unknown'): a partial
|
|
219
|
+
* read would render a live principal as missing, which is the same class of lie
|
|
220
|
+
* in the opposite direction. Absence of evidence is not a member list. */
|
|
221
|
+
function aggregateIapMembers({ editor, preview } = {}) {
|
|
222
|
+
const surfaces = [editor, preview].filter((s) => s !== undefined && s !== null);
|
|
223
|
+
if (surfaces.some((s) => (typeof s === 'object' ? s.verdict : s) === 'unknown')) return null;
|
|
224
|
+
const listOf = (s) => (s && typeof s === 'object' && Array.isArray(s.members) ? s.members : []);
|
|
225
|
+
const e = listOf(editor);
|
|
226
|
+
const p = listOf(preview);
|
|
227
|
+
const all = [];
|
|
228
|
+
for (const m of [...e, ...p]) if (!all.includes(m)) all.push(m);
|
|
229
|
+
// A principal is "partial" when it is absent from at least one surface that was
|
|
230
|
+
// actually gathered. With only one surface gathered nothing can be partial.
|
|
231
|
+
const gathered = [];
|
|
232
|
+
if (editor !== undefined && editor !== null) gathered.push(e);
|
|
233
|
+
if (preview !== undefined && preview !== null) gathered.push(p);
|
|
234
|
+
const partial = gathered.length > 1
|
|
235
|
+
&& all.some((m) => gathered.some((list) => !list.includes(m)));
|
|
236
|
+
return { editor: e, preview: p, all, partial };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The fleet minimum backend-service `timeoutSec` a WebSocket needs to survive
|
|
240
|
+
// behind the External HTTPS LB. The LB severs a backend connection at
|
|
241
|
+
// `timeoutSec` regardless of liveness, so the default 30s guillotines the Build
|
|
242
|
+
// tab's terminal every 30s (the VM5 symptom). scripts/gcp/vm-ingress.sh sets —
|
|
243
|
+
// and always converges existing BES to — 86400 (24h); readiness asserts the
|
|
244
|
+
// same floor so a BES that drifted below it reads `ws-dead` instead of healthy.
|
|
245
|
+
// A single source so the classifier and any future log line agree on the number.
|
|
246
|
+
const FLEET_WS_TIMEOUT_MIN_SEC = 86400;
|
|
247
|
+
|
|
248
|
+
/** Classify a backend-service `timeoutSec` against the fleet WS minimum. PURE —
|
|
249
|
+
* server.js reads the value via `gcloud backend-services describe` and hands
|
|
250
|
+
* the number in, so the threshold logic is unit-testable without gcloud.
|
|
251
|
+
* Absence of evidence is never a fault (mirrors classifyBackendHealth):
|
|
252
|
+
*
|
|
253
|
+
* 'unknown' — not a usable positive number (describe errored /
|
|
254
|
+
* empty): no verdict, never a problem state.
|
|
255
|
+
* 'ws-timeout-too-low' — a real value BELOW the minimum: the LB WILL sever
|
|
256
|
+
* the socket (the VM5 guillotine). The only fault.
|
|
257
|
+
* 'ok' — timeoutSec ≥ the fleet minimum: long enough for WS. */
|
|
258
|
+
function classifyWsTimeout(backendTimeoutSec, minSec = FLEET_WS_TIMEOUT_MIN_SEC) {
|
|
259
|
+
const t = Number(backendTimeoutSec);
|
|
260
|
+
if (!Number.isFinite(t) || t <= 0) return 'unknown';
|
|
261
|
+
return t >= Number(minSec) ? 'ok' : 'ws-timeout-too-low';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The sticky `ingress-unwired` degraded-reason string — the single source for
|
|
265
|
+
* both the dashboard card reason and any log line. Embeds the advertised URL
|
|
266
|
+
* and the EXACT re-wire command an operator runs to fix it, so the amber card
|
|
267
|
+
* is self-documenting (the surface VM5 should have shown for hours instead of
|
|
268
|
+
* reading healthy). */
|
|
269
|
+
function ingressUnwiredReason({ url, domain, slug, n } = {}) {
|
|
270
|
+
const target = url || `(vm-${n} editor URL)`;
|
|
271
|
+
const slugEnv = slug ? ` VM_SLUG=${slug}` : '';
|
|
272
|
+
const rewire = `FLEET_DOMAIN=${domain}${slugEnv} bash scripts/gcp/vm-ingress.sh ${n} up`;
|
|
273
|
+
return `ingress-unwired: ${target} returns 503 (host rule missing). Re-wire: ${rewire}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Decide whether a just-landed public-URL verdict warrants an auto-rewire
|
|
277
|
+
* self-heal. PURE — the dashboard reads the live VM state (I/O at the edge) and
|
|
278
|
+
* passes the facts in; the gate logic itself is unit-testable without network
|
|
279
|
+
* or server state. Fires only for a GENUINE missing-host-rule case:
|
|
280
|
+
* - `verdict` is 'unwired' (the URL 503s), AND
|
|
281
|
+
* - `reachable` (the tunnel is up — a down VM is not a missing-host-rule
|
|
282
|
+
* case; re-wiring it accomplishes nothing), AND
|
|
283
|
+
* - `backendEditorHealth` is 'healthy' (the backend SERVICE is fine, so only
|
|
284
|
+
* the host rule is missing — the VM5 host-rule-drift case) OR 'absent' (the
|
|
285
|
+
* per-VM backend service was NEVER created — the interrupted-add VM-4 case,
|
|
286
|
+
* where a SIGTERM killed `backend-services create` mid-wiring) OR
|
|
287
|
+
* 'unhealthy' *with* `backendEditorBackendless` true (the BES EXISTS but has
|
|
288
|
+
* ZERO NEG backends attached — the concurrent-add editor-band race, where the
|
|
289
|
+
* backend slipped its NEG attachment but the BES object landed). All three
|
|
290
|
+
* are recoverable by a single `vm-ingress.sh up`, which is
|
|
291
|
+
* describe-before-create idempotent and creates/attaches only the missing
|
|
292
|
+
* pieces. A backendless BES classifies as 'unhealthy' (get-health returns
|
|
293
|
+
* empty, indistinguishable from a still-cold backend) yet will NEVER converge
|
|
294
|
+
* on its own — it has no endpoint to become healthy — so it MUST re-wire, not
|
|
295
|
+
* wait. 'unknown' (a transient gcloud/auth error — absence of evidence must
|
|
296
|
+
* never trigger a re-wire storm) and a GENUINELY-converging 'unhealthy' (a
|
|
297
|
+
* backend IS attached, health still flipping — `backendEditorBackendless`
|
|
298
|
+
* false: let it converge, don't churn the LB) stay EXCLUDED, AND
|
|
299
|
+
* - no re-wire is already `inFlight` (one per VM), AND
|
|
300
|
+
* - the debounce window has elapsed since `lastRewireAt`.
|
|
301
|
+
* The debounce is load-bearing: GCP host-rule changes take minutes to roll out
|
|
302
|
+
* to all GFEs (VM5's CORRECT rule 503'd for ~minutes before flipping), so a
|
|
303
|
+
* per-cycle re-fire would storm the LB while a good change is still
|
|
304
|
+
* propagating. A missing/0 `lastRewireAt` means "never re-wired" → the window
|
|
305
|
+
* is satisfied. */
|
|
306
|
+
function shouldAutoRewireIngress({
|
|
307
|
+
verdict,
|
|
308
|
+
reachable,
|
|
309
|
+
backendEditorHealth,
|
|
310
|
+
backendEditorBackendless,
|
|
311
|
+
inFlight,
|
|
312
|
+
lastRewireAt,
|
|
313
|
+
now,
|
|
314
|
+
debounceMs,
|
|
315
|
+
} = {}) {
|
|
316
|
+
if (verdict !== 'unwired') return false;
|
|
317
|
+
if (!reachable) return false;
|
|
318
|
+
// 'healthy' = host-rule drift (VM5); 'absent' = backend never created
|
|
319
|
+
// (interrupted-add VM-4); 'unhealthy' + backendless = BES exists with zero NEG
|
|
320
|
+
// backends (concurrent-add editor-band race) — it can't converge unaided, so it
|
|
321
|
+
// re-wires too. A genuinely-converging 'unhealthy' (backend attached, health
|
|
322
|
+
// flipping → backendless false) and 'unknown' stay excluded.
|
|
323
|
+
const rewirable =
|
|
324
|
+
backendEditorHealth === 'healthy' ||
|
|
325
|
+
backendEditorHealth === 'absent' ||
|
|
326
|
+
(backendEditorHealth === 'unhealthy' && backendEditorBackendless === true);
|
|
327
|
+
if (!rewirable) return false;
|
|
328
|
+
if (inFlight) return false;
|
|
329
|
+
return (Number(now) - (Number(lastRewireAt) || 0)) >= Number(debounceMs);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Classify a `gcloud compute backend-services describe --format=value(backends[].group)`
|
|
333
|
+
* result into the attachment vocabulary — the signal `get-health` cannot give,
|
|
334
|
+
* because an empty health result is ambiguous between "no backend attached" and
|
|
335
|
+
* "a backend attached but still health-pending":
|
|
336
|
+
*
|
|
337
|
+
* 'backendless' — the BES EXISTS but has ZERO NEG backends attached (the
|
|
338
|
+
* describe printed empty / whitespace-only output). This BES
|
|
339
|
+
* will NEVER converge on its own — there is no endpoint to
|
|
340
|
+
* become healthy — so it is re-wirable, feeding
|
|
341
|
+
* shouldAutoRewireIngress's `backendEditorBackendless` branch
|
|
342
|
+
* (the concurrent-add editor-band slip).
|
|
343
|
+
* 'attached' — the describe printed at least one backend group, OR it
|
|
344
|
+
* ERRORED. An error is treated as 'attached' (absence of
|
|
345
|
+
* evidence): a NotFound is the 'absent' case classifyBackendHealth
|
|
346
|
+
* already owns, and a transient gcloud/auth failure must NEVER
|
|
347
|
+
* manufacture a 'backendless' verdict that storms the LB with a
|
|
348
|
+
* re-wire. Pure (no I/O), so the empty-backends classification is
|
|
349
|
+
* unit-testable without gcloud — the I/O lives in server.js's
|
|
350
|
+
* gatherEditorBackendless, the same split classifyPublicUrlHealth
|
|
351
|
+
* follows. */
|
|
352
|
+
function classifyBackendAttachment(err, stdout) {
|
|
353
|
+
if (err) return 'attached';
|
|
354
|
+
return String(stdout == null ? '' : stdout).trim() === '' ? 'backendless' : 'attached';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Collapse a VM's raw wiring signals into the single URL-state vocabulary the
|
|
358
|
+
* card renders (vm--add-time-url-wiring-converges-reliably), so "the link is
|
|
359
|
+
* converging" is DISTINCT from "the link is dead" — the operator is never shown
|
|
360
|
+
* a "ready" card over an unwired URL, nor left to infer state from an absent
|
|
361
|
+
* link. PURE — the dashboard reads the live facts (the public-URL probe verdict,
|
|
362
|
+
* the inflight marker, the failed marker: all I/O at the edge) and passes them
|
|
363
|
+
* in. Precedence:
|
|
364
|
+
* - `publicUrlHealth === 'wired'` → 'wired'. A real 2xx/3xx wins over any
|
|
365
|
+
* stale marker; the URL genuinely serves.
|
|
366
|
+
* - else `inflight` → 'wiring'. The add-tail converge loop is still running;
|
|
367
|
+
* the card reads a calm "wiring public URL…", not a false failure.
|
|
368
|
+
* - else `failed` → 'wiring-failed'. The converge loop exhausted its deadline
|
|
369
|
+
* without reaching `wired` — a real attention reason with a re-run command,
|
|
370
|
+
* never a silent `partial`.
|
|
371
|
+
* - else `null`. No wiring signal (legacy quick-tunnel, or a transient probe
|
|
372
|
+
* miss with no markers) — the caller falls back to its existing
|
|
373
|
+
* unwired/unknown handling rather than inventing a state. */
|
|
374
|
+
function classifyUrlWiringState({ publicUrlHealth, inflight, failed } = {}) {
|
|
375
|
+
if (publicUrlHealth === 'wired') return 'wired';
|
|
376
|
+
if (inflight) return 'wiring';
|
|
377
|
+
if (failed) return 'wiring-failed';
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
module.exports = {
|
|
382
|
+
classifyPublicUrlHealth,
|
|
383
|
+
classifyPublicWsHealth,
|
|
384
|
+
classifyIapAccess,
|
|
385
|
+
aggregateIapAccess,
|
|
386
|
+
aggregateIapMembers,
|
|
387
|
+
IAP_ACCESSOR_ROLE,
|
|
388
|
+
classifyWsTimeout,
|
|
389
|
+
FLEET_WS_TIMEOUT_MIN_SEC,
|
|
390
|
+
ingressUnwiredReason,
|
|
391
|
+
shouldAutoRewireIngress,
|
|
392
|
+
classifyBackendAttachment,
|
|
393
|
+
classifyUrlWiringState,
|
|
394
|
+
};
|
package/npm/fleet-queue.js
CHANGED
|
@@ -37,4 +37,98 @@ function decideAutoReleaseGoneHead({ queue, vms, jobs, orphans }) {
|
|
|
37
37
|
return { release: true, uuid: head.uuid, vm: n, reason: `owning VM-${n} left the fleet` };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
/** Compute the set-priority writes a reorder requires.
|
|
41
|
+
*
|
|
42
|
+
* A reorder POSTs a desired uuid order; the queue's priority convention is
|
|
43
|
+
* first=N … last=1 (higher priority sorts earlier). This maps the desired
|
|
44
|
+
* order onto target priorities, drops the entries already at their target
|
|
45
|
+
* priority (no-ops — no `set-priority` shell-out needed), and sorts the
|
|
46
|
+
* remaining writes intended-HEAD-first so the head lands first even if a
|
|
47
|
+
* later write fails. `current` is the authoritative queue ([{uuid,priority}]);
|
|
48
|
+
* `order` is the desired uuid sequence.
|
|
49
|
+
*
|
|
50
|
+
* Returns one of:
|
|
51
|
+
* { ok: false, reason: 'empty' } — no order supplied
|
|
52
|
+
* { ok: false, reason: 'stale' } — order doesn't match current set
|
|
53
|
+
* { ok: true, targets: [{uuid, prio}] } — writes to apply (possibly [])
|
|
54
|
+
* Pure; no I/O.
|
|
55
|
+
*/
|
|
56
|
+
function computeReorderTargets({ order, current }) {
|
|
57
|
+
const ord = (order || []).map((s) => String(s).trim()).filter(Boolean);
|
|
58
|
+
const cur = current || [];
|
|
59
|
+
const curUuids = cur.map((e) => e.uuid);
|
|
60
|
+
if (!ord.length) return { ok: false, reason: 'empty' };
|
|
61
|
+
// Same membership, same length — a reorder, not an add/remove. Anything else
|
|
62
|
+
// means the queue changed underneath the operator since they last saw it.
|
|
63
|
+
if (ord.length !== curUuids.length || !ord.every((u) => curUuids.includes(u))) {
|
|
64
|
+
return { ok: false, reason: 'stale' };
|
|
65
|
+
}
|
|
66
|
+
const N = ord.length;
|
|
67
|
+
const targets = ord
|
|
68
|
+
.map((uuid, i) => ({ uuid, prio: N - i })) // first=N … last=1
|
|
69
|
+
.filter(({ uuid, prio }) => {
|
|
70
|
+
const e = cur.find((x) => x.uuid === uuid);
|
|
71
|
+
return !e || e.priority !== prio; // skip no-ops
|
|
72
|
+
})
|
|
73
|
+
.sort((a, b) => b.prio - a.prio); // intended HEAD first
|
|
74
|
+
return { ok: true, targets };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Move one uuid within a desired order array.
|
|
78
|
+
*
|
|
79
|
+
* The staged-draft panel shuffles a working uuid order before it is saved.
|
|
80
|
+
* `delta > 0` raises priority (moves the entry TOWARD the head / index 0);
|
|
81
|
+
* `delta < 0` lowers it (toward the tail). The target index is clamped to the
|
|
82
|
+
* array bounds, so a nudge past an end is a no-op and a full-span negative
|
|
83
|
+
* delta lands the entry at the very tail — which is exactly how Demote
|
|
84
|
+
* (head → back of the line) is expressed: `nudgeQueueOrder(order, head, -(N-1))`.
|
|
85
|
+
* Mirrors the editor UI's `useBranchQueueDraft.nudge` (arrayMove(from, from-delta)).
|
|
86
|
+
*
|
|
87
|
+
* `order` is a uuid array; a uuid not present is returned unchanged. Pure — the
|
|
88
|
+
* input array is never mutated.
|
|
89
|
+
*/
|
|
90
|
+
function nudgeQueueOrder(order, uuid, delta) {
|
|
91
|
+
const arr = (order || []).slice();
|
|
92
|
+
const from = arr.findIndex((u) => u === uuid);
|
|
93
|
+
if (from < 0 || !delta) return arr;
|
|
94
|
+
let to = from - delta; // +delta → toward head; -delta → toward tail
|
|
95
|
+
if (to < 0) to = 0;
|
|
96
|
+
if (to > arr.length - 1) to = arr.length - 1;
|
|
97
|
+
if (to === from) return arr;
|
|
98
|
+
const [moved] = arr.splice(from, 1);
|
|
99
|
+
arr.splice(to, 0, moved);
|
|
100
|
+
return arr;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Merge a fresh poll's membership into a staged draft order without discarding
|
|
104
|
+
* the operator's in-progress reorder.
|
|
105
|
+
*
|
|
106
|
+
* - uuids still present live keep their DRAFT position (the working order wins);
|
|
107
|
+
* - draft uuids that vanished upstream are dropped;
|
|
108
|
+
* - live uuids not yet in the draft are appended at the tail so a sibling
|
|
109
|
+
* session's newly-enqueued entry doesn't silently disappear.
|
|
110
|
+
*
|
|
111
|
+
* Pure port of the editor UI's `reconcileDraft` order rule (uuid-only, no meta).
|
|
112
|
+
* Both arguments are uuid arrays; returns the merged uuid array. Never mutates
|
|
113
|
+
* its inputs.
|
|
114
|
+
*/
|
|
115
|
+
function reconcileQueueDraft(draftOrder, liveOrder) {
|
|
116
|
+
const live = liveOrder || [];
|
|
117
|
+
const liveSet = new Set(live);
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const u of draftOrder || []) {
|
|
121
|
+
if (liveSet.has(u) && !seen.has(u)) { out.push(u); seen.add(u); }
|
|
122
|
+
}
|
|
123
|
+
for (const u of live) {
|
|
124
|
+
if (!seen.has(u)) { out.push(u); seen.add(u); }
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
decideAutoReleaseGoneHead,
|
|
131
|
+
computeReorderTargets,
|
|
132
|
+
nudgeQueueOrder,
|
|
133
|
+
reconcileQueueDraft,
|
|
134
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure consecutive-failure debounce for the Fleet Dashboard's per-VM top-level
|
|
3
|
+
// reachability. Mirrors npm/fleet-broker-debounce.js exactly: server.js does the
|
|
4
|
+
// polling and threads the state; this decides the reachability the card renders.
|
|
5
|
+
//
|
|
6
|
+
// The bug this fixes (observed live 2026-07-01, VM6): a healthy VM briefly
|
|
7
|
+
// flashed 'unreachable' and surfaced a reset-client-error even though it was up
|
|
8
|
+
// the whole time. The dashboard polls each VM's editor over a laptop-side
|
|
9
|
+
// SSH/cloudflared forward with NO fast respawner, so a single one-poll hiccup
|
|
10
|
+
// returned reachable:false and the card instantly read 'unreachable' — which
|
|
11
|
+
// then invited the destructive "Reset client" action on a perfectly healthy VM.
|
|
12
|
+
// The single probe is left as-is on the poller side; the right seam for
|
|
13
|
+
// absorbing a one-poll blip is here at the aggregation layer, just like broker.
|
|
14
|
+
//
|
|
15
|
+
// Asymmetric by design: ONLY the flip to 'unreachable' is debounced (it requires
|
|
16
|
+
// `threshold` consecutive failed polls). A reachable poll takes effect
|
|
17
|
+
// immediately — we never DELAY clearing a false alarm, only delay raising one.
|
|
18
|
+
//
|
|
19
|
+
// Three display states the card renders:
|
|
20
|
+
// 'reachable' — the editor answered this poll.
|
|
21
|
+
// 'reconnecting' — a sub-threshold failure streak (1..N-1), OR the GCP side
|
|
22
|
+
// confirms the editor answers over IAP while the local port
|
|
23
|
+
// is dark (a laptop tunnel fault, never a dead VM). Calm; the
|
|
24
|
+
// watchdog respawns the forward. A one-poll hiccup lives here.
|
|
25
|
+
// 'unreachable' — N consecutive failures AND no GCP-side confirmation. The
|
|
26
|
+
// honest sustained-outage state.
|
|
27
|
+
//
|
|
28
|
+
// State carried across polls: { display, failStreak, lastConfirmed }.
|
|
29
|
+
// display — the debounced value the card renders this poll.
|
|
30
|
+
// failStreak — how many consecutive raw failed polls we've seen.
|
|
31
|
+
// lastConfirmed — the last raw 'reachable' we proved (informational; the card
|
|
32
|
+
// holds 'reconnecting' during a pending sub-threshold streak).
|
|
33
|
+
|
|
34
|
+
// ~9s at the dashboard's 3s poll cadence, matching the broker debounce: enough
|
|
35
|
+
// to absorb a transient forward hiccup while still surfacing a real outage
|
|
36
|
+
// quickly. Named so retuning is a one-line edit.
|
|
37
|
+
const UNREACHABLE_DEBOUNCE_THRESHOLD = 3;
|
|
38
|
+
|
|
39
|
+
function debounceReachability(prev, rawReachable, opts) {
|
|
40
|
+
const threshold = (opts && opts.threshold) || UNREACHABLE_DEBOUNCE_THRESHOLD;
|
|
41
|
+
const gcpConfirmedReachable = !!(opts && opts.gcpConfirmedReachable);
|
|
42
|
+
const lastConfirmed = prev ? prev.lastConfirmed : undefined;
|
|
43
|
+
|
|
44
|
+
// Confirmed reachable: the editor answered now. Render immediately, reset the
|
|
45
|
+
// streak, remember this as the last-confirmed value.
|
|
46
|
+
if (rawReachable) {
|
|
47
|
+
return { display: 'reachable', failStreak: 0, lastConfirmed: 'reachable' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Raw failure: count the streak.
|
|
51
|
+
const failStreak = (prev ? prev.failStreak : 0) + 1;
|
|
52
|
+
|
|
53
|
+
// GCP-side confirmation: the editor answers over IAP while the local port is
|
|
54
|
+
// dark. That's a laptop tunnel fault, NOT a dead VM — never escalate to
|
|
55
|
+
// 'unreachable' no matter how long the local forward has been down; the
|
|
56
|
+
// watchdog respawns the forward. Hold the calm 'reconnecting' state.
|
|
57
|
+
if (gcpConfirmedReachable) {
|
|
58
|
+
return { display: 'reconnecting', failStreak, lastConfirmed };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sub-threshold failure: stay calm ('reconnecting'). Only a SUSTAINED failure
|
|
62
|
+
// past the threshold escalates to the honest 'unreachable'.
|
|
63
|
+
if (failStreak >= threshold) {
|
|
64
|
+
return { display: 'unreachable', failStreak, lastConfirmed };
|
|
65
|
+
}
|
|
66
|
+
return { display: 'reconnecting', failStreak, lastConfirmed };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { debounceReachability, UNREACHABLE_DEBOUNCE_THRESHOLD };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The three-state reachability contract for the Fleet Dashboard.
|
|
4
|
+
//
|
|
5
|
+
// A VM's reachability has THREE states, not two:
|
|
6
|
+
// true — a probe reached the VM's editor
|
|
7
|
+
// false — a probe RAN and FAILED (a confirmed outage)
|
|
8
|
+
// null — no verdict at all (never polled, or held inconclusive)
|
|
9
|
+
//
|
|
10
|
+
// `npm/fleet-auth-health.js::classifyReachability` has always modelled this
|
|
11
|
+
// correctly, deliberately returning `{ reachable: null }` for the inconclusive
|
|
12
|
+
// case. Every consumer between it and the browser then coerced that care away
|
|
13
|
+
// with `!!v.reachable` and `{ n, reachable: false }` placeholders, so the null
|
|
14
|
+
// never survived to anyone who could act on it.
|
|
15
|
+
//
|
|
16
|
+
// On 2026-07-30 that collapse served all 13 rostered VMs as `reachable: false`
|
|
17
|
+
// with NO `reachableSource`, while each card's own `backendHealth` read
|
|
18
|
+
// `healthy` and operator auth read clean — a self-contradicting fleet-wide
|
|
19
|
+
// "outage" that had never been probed. It blocked both sanctioned refresh paths
|
|
20
|
+
// (`refresh-client-vm-editor.sh` exits 1, `fleet-advance-to.sh` classifies
|
|
21
|
+
// SKIP-unreachable) and, in a storm, is the signal that has driven reconcile
|
|
22
|
+
// into hard-rebooting healthy VMs.
|
|
23
|
+
//
|
|
24
|
+
// This module is the single home for that vocabulary so the distinction is
|
|
25
|
+
// stated once and grepped, rather than re-derived inline at each consumer. Pure:
|
|
26
|
+
// no I/O, no clock, no state.
|
|
27
|
+
|
|
28
|
+
// The `reachableSource` value for a record carrying no reachability verdict.
|
|
29
|
+
// `reachableSource` otherwise names the signal that DECIDED a verdict ('lb' |
|
|
30
|
+
// 'backend' | 'local' | 'iap', from classifyReachability), which is what makes a
|
|
31
|
+
// card self-diagnosing — a `reachable: false` with a missing source is exactly
|
|
32
|
+
// the fingerprint that identified the 2026-07-30 incident as manufactured by the
|
|
33
|
+
// serializer rather than reported by a probe. 'unpolled' extends that same
|
|
34
|
+
// self-diagnosis to the no-verdict case instead of leaving a silent gap.
|
|
35
|
+
const REACHABILITY_UNPOLLED = 'unpolled';
|
|
36
|
+
|
|
37
|
+
// Does this VM have a DEFINITE reachability verdict — did anyone actually ask?
|
|
38
|
+
// Only a real boolean counts; null/undefined (and any malformed value) read as
|
|
39
|
+
// "no verdict". Consumers use this to tell "never asked" from "asked and got an
|
|
40
|
+
// answer", which is the distinction the whole three-state contract exists for.
|
|
41
|
+
function hasReachabilityVerdict(reachable) {
|
|
42
|
+
return reachable === true || reachable === false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Is this VM CONFIRMED unreachable — probed, and the probe failed?
|
|
46
|
+
//
|
|
47
|
+
// Safety-critical. Reboot, reconcile, and destroy paths must gate on THIS, never
|
|
48
|
+
// on a falsy check: `!reachable` is also true for an unknown, so a `!reachable`
|
|
49
|
+
// guard lets a VM nobody has polled walk into a path that ends in a hard reset.
|
|
50
|
+
// An unknown is not evidence, and evidence is the bar for a destructive action.
|
|
51
|
+
function isConfirmedUnreachable(reachable) {
|
|
52
|
+
return reachable === false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Normalize a raw poll record's reachability into the pair the wire envelope
|
|
56
|
+
// carries: `{ reachable, reachableSource }`.
|
|
57
|
+
//
|
|
58
|
+
// This is the seam where the bug lived. `reachable` is preserved as a genuine
|
|
59
|
+
// tri-state — an ABSENT verdict stays absent rather than being coerced to a
|
|
60
|
+
// definite false — and `reachableSource` falls back to 'unpolled' so a card with
|
|
61
|
+
// no verdict says so out loud instead of looking like an unexplained outage.
|
|
62
|
+
//
|
|
63
|
+
// `v` is the merged poll state (`state.vms[n]`), or a placeholder standing in
|
|
64
|
+
// for a VM with no poll state at all.
|
|
65
|
+
function normalizeReachability(v) {
|
|
66
|
+
const raw = v ? v.reachable : null;
|
|
67
|
+
const reachable = hasReachabilityVerdict(raw) ? raw : null;
|
|
68
|
+
return {
|
|
69
|
+
reachable,
|
|
70
|
+
reachableSource: (v && v.reachableSource)
|
|
71
|
+
|| (reachable === null ? REACHABILITY_UNPOLLED : null),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = {
|
|
76
|
+
REACHABILITY_UNPOLLED,
|
|
77
|
+
hasReachabilityVerdict,
|
|
78
|
+
isConfirmedUnreachable,
|
|
79
|
+
normalizeReachability,
|
|
80
|
+
};
|