@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,252 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fc = require('./fleet-cleanup');
|
|
4
|
+
const { parseRosterMarkers } = require('./fleet-early-roster');
|
|
5
|
+
|
|
6
|
+
// npm/fleet-add-failure.js — pure classifier for a FAILED fleet-dashboard `add`
|
|
7
|
+
// provision. Before this, every failed add rendered an identical red "add error"
|
|
8
|
+
// badge whose only action was "Clear badge", and a restart mid-add collapsed the
|
|
9
|
+
// message to a content-free `(re-attached after restart — detached provision
|
|
10
|
+
// exited 1)`. The real cause — captured to the provision transcript on disk — was
|
|
11
|
+
// thrown away. This happened live with VM9/VM10: both aborted at the streamed-
|
|
12
|
+
// script drift guard because a bootstrap script had uncommitted local edits, but
|
|
13
|
+
// the operator saw only "exited 1" and had to dig through /tmp/dash-add-vm-N.log.
|
|
14
|
+
//
|
|
15
|
+
// classifyAddFailure turns a transcript tail + exit code into a differentiated,
|
|
16
|
+
// ACTIONABLE verdict: a plain-language headline, a concrete recovery instruction,
|
|
17
|
+
// and — crucially — a `retryable` flag that is true ONLY where a one-click retry
|
|
18
|
+
// provably cannot orphan or duplicate a GCP box: either nothing was provisioned,
|
|
19
|
+
// or the remaining work is re-entrant so the retry converges on the existing VM.
|
|
20
|
+
// Both grounds are spelled out on ADD_FAILURE_RETRYABLE below. The decision logic
|
|
21
|
+
// lives here as a pure module (like fleet-job-badge.js::canClearBadge) so the
|
|
22
|
+
// whole failure matrix is unit-tested without a live VM; server.js is the thin
|
|
23
|
+
// I/O shell that gathers the transcript and turns the verdict into a card + a
|
|
24
|
+
// server-re-guarded retry action.
|
|
25
|
+
|
|
26
|
+
// The SINGLE source of truth for which categories are safe to auto-retry.
|
|
27
|
+
// `retryable` on every returned verdict is stamped from this Set, so the
|
|
28
|
+
// invariant `retryable === has(category)` holds by construction and the server
|
|
29
|
+
// can re-guard a retry against the same Set.
|
|
30
|
+
//
|
|
31
|
+
// A category earns membership on EITHER of two grounds — both of which
|
|
32
|
+
// guarantee a one-click retry cannot silently orphan or duplicate a GCP box:
|
|
33
|
+
//
|
|
34
|
+
// 1. NOTHING WAS PROVISIONED. The classifier can prove no cloud resource was
|
|
35
|
+
// created (`preflight-drift` aborts before the first gcloud call;
|
|
36
|
+
// `instance-vanished` confirms the instance is gone). Retry starts clean.
|
|
37
|
+
//
|
|
38
|
+
// 2. THE REMAINING WORK IS RE-ENTRANT. A real VM exists, but every step the
|
|
39
|
+
// retry would re-run is skip-on-exists or re-attaching, so the retry
|
|
40
|
+
// CONVERGES on the existing box instead of building a second one.
|
|
41
|
+
// `project-bootstrap-timeout` is this shape: the VM is up and its detached
|
|
42
|
+
// bootstrap is still running, `ensureInstance` reuses the live instance,
|
|
43
|
+
// `ensureBootstrapped` skips on the present image, and
|
|
44
|
+
// `ensureProjectBootstrapped` re-attaches to the in-flight install rather
|
|
45
|
+
// than starting a second `npm install` over the first.
|
|
46
|
+
//
|
|
47
|
+
// Ground 2 is deliberately narrow — it is NOT "the VM probably still works".
|
|
48
|
+
// `container-stuck` and `incomplete-workspace` also leave a live VM, but their
|
|
49
|
+
// remaining work is NOT provably re-entrant, so they stay non-retryable.
|
|
50
|
+
const ADD_FAILURE_RETRYABLE = new Set([
|
|
51
|
+
'preflight-drift',
|
|
52
|
+
'instance-vanished',
|
|
53
|
+
'project-bootstrap-timeout',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
// Collect the ` - <path>` bullets the drift guard prints AFTER the "following
|
|
57
|
+
// files differ" header (see npm/cloud.js), stopping at the first non-bullet line
|
|
58
|
+
// so an unrelated "- " elsewhere in the transcript can't leak in. Returns [] when
|
|
59
|
+
// no drift block is present.
|
|
60
|
+
function extractDriftFiles(logTail) {
|
|
61
|
+
const lines = String(logTail || '').split('\n');
|
|
62
|
+
const files = [];
|
|
63
|
+
let inBlock = false;
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (!inBlock) {
|
|
66
|
+
if (/following files differ/i.test(line)) inBlock = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const m = line.match(/^\s+-\s+(.+?)\s*$/);
|
|
70
|
+
if (m) { files.push(m[1]); continue; }
|
|
71
|
+
break; // first non-bullet after the header ends the block
|
|
72
|
+
}
|
|
73
|
+
return files;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Pure transform: the last `maxLines` non-empty lines of a transcript, each
|
|
77
|
+
// right-trimmed. Extracted from readProvisionTail (server.js) so the aggregation
|
|
78
|
+
// is unit-tested and that function stays a thin filesystem-boundary call. Returns
|
|
79
|
+
// '' for empty/whitespace-only input.
|
|
80
|
+
function lastNonEmptyLines(text, maxLines = 50) {
|
|
81
|
+
const lines = String(text || '')
|
|
82
|
+
.split('\n')
|
|
83
|
+
.map((l) => l.replace(/\s+$/, ''))
|
|
84
|
+
.filter((l) => l.length > 0)
|
|
85
|
+
.slice(-maxLines);
|
|
86
|
+
return lines.join('\n');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// True iff the provision transcript reached the early-roster marker (`CY_ROSTER
|
|
90
|
+
// <n>`) — i.e. the instance exists and the container gate passed, so the VM is
|
|
91
|
+
// rostered. Reuses parseRosterMarkers (npm/fleet-early-roster.js) so the
|
|
92
|
+
// `CY_ROSTER` grammar has a single owner rather than a second inlined regex. Any
|
|
93
|
+
// valid marker means "rostered"; the specific VM number is irrelevant here — a
|
|
94
|
+
// provision transcript only ever carries its own VM's marker. Never throws.
|
|
95
|
+
function transcriptRostered(logTail) {
|
|
96
|
+
return parseRosterMarkers(String(logTail || '')).length > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
|
|
100
|
+
// can never drift from the category.
|
|
101
|
+
function buildFailureVerdict(category, headline, recovery, extra) {
|
|
102
|
+
const out = { category, headline, recovery, retryable: ADD_FAILURE_RETRYABLE.has(category) };
|
|
103
|
+
if (extra && extra.files) out.files = extra.files;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Classify a failed add from its transcript tail + exit code.
|
|
108
|
+
//
|
|
109
|
+
// logTail the last lines of the provision transcript (string; '' when the
|
|
110
|
+
// log was unreadable — must never throw)
|
|
111
|
+
// exitCode the provision's exit code (number; used for signal-kill detection)
|
|
112
|
+
//
|
|
113
|
+
// Returns { category, headline, recovery, retryable, files? }.
|
|
114
|
+
//
|
|
115
|
+
// Detection is ORDER-SENSITIVE. The provision shell echoes cloud:up's tail, so a
|
|
116
|
+
// drift / instance-vanished / container-stuck transcript ALSO contains the
|
|
117
|
+
// generic `cloud:up FAILED (exit N)` line. Each specific (and every retryable)
|
|
118
|
+
// signature is therefore tested BEFORE the generic cloud-up-failed signature, or
|
|
119
|
+
// a retryable drift would misclassify as a non-retryable cloud:up failure and the
|
|
120
|
+
// operator would lose the safe one-click recovery.
|
|
121
|
+
function classifyAddFailure({ logTail, exitCode } = {}) {
|
|
122
|
+
const log = String(logTail || '');
|
|
123
|
+
|
|
124
|
+
// 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
|
|
125
|
+
// gcloud call, so nothing was provisioned. Retryable once the file is fixed.
|
|
126
|
+
// We match the unambiguous abort header / verdict label, NOT the bare
|
|
127
|
+
// `CODEYAM_SKIP_SCRIPT_DRIFT_CHECK` token: that token ALSO appears in the
|
|
128
|
+
// "bypass active — skipping drift check" warning, so keying on it would
|
|
129
|
+
// misclassify a bypass-active add that later failed for a real reason as a
|
|
130
|
+
// (retryable!) drift — inviting an unsafe retry. The header below is printed
|
|
131
|
+
// only on a genuine drift abort.
|
|
132
|
+
if (/Streamed scripts carry unreviewed local content/i.test(log)
|
|
133
|
+
|| /UNCOMMITTED LOCAL EDITS/.test(log)) {
|
|
134
|
+
const files = extractDriftFiles(log);
|
|
135
|
+
const fileList = files.length ? files.join(', ') : 'the streamed scripts';
|
|
136
|
+
const fix = files.length
|
|
137
|
+
? `Commit & push them, or discard with \`git checkout -- ${files.join(' ')}\`, then Retry add.`
|
|
138
|
+
: 'Commit & push them, or discard the local edits, then Retry add.';
|
|
139
|
+
return buildFailureVerdict('preflight-drift', 'Blocked by local edits',
|
|
140
|
+
`Uncommitted local edits in ${fileList} tripped the streamed-script drift guard, which aborts before any cloud resource is created. ${fix}`,
|
|
141
|
+
{ files });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. instance-vanished — the GCP instance is confirmed gone; nothing to orphan.
|
|
145
|
+
if (/instance .* is gone/i.test(log)) {
|
|
146
|
+
return buildFailureVerdict('instance-vanished', 'Instance never came up',
|
|
147
|
+
'The GCP instance is gone — provisioning failed before the container started, so nothing was left behind. Retrying the add is safe.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 3. project-bootstrap-timeout — the VM came up and its DETACHED project
|
|
151
|
+
// bootstrap is still running; we merely stopped waiting on it. This is the
|
|
152
|
+
// VM-8 / six-self-host-VM incident: a heavy `npm install` (a ~293 MB
|
|
153
|
+
// playwright postinstall, a large `git clone`) outran a local budget, the
|
|
154
|
+
// SIGTERM'd gcloud child returned `status: null`, and the abort path read
|
|
155
|
+
// that as a broken bootstrap script and DELETED A COMPLETELY HEALTHY VM.
|
|
156
|
+
//
|
|
157
|
+
// Retryable on re-entrancy grounds (see ADD_FAILURE_RETRYABLE): the retry
|
|
158
|
+
// re-attaches to the in-flight install rather than starting a second one.
|
|
159
|
+
// Tested BEFORE cloud-up-failed because the provision shell echoes cloud:up's
|
|
160
|
+
// tail, so this transcript ALSO carries the generic `cloud:up FAILED (exit N)`
|
|
161
|
+
// line — matching that first would strip the safe recovery and steer the
|
|
162
|
+
// operator to destroy a healthy, still-provisioning VM. The signature is the
|
|
163
|
+
// wording ensureProjectBootstrapped (npm/cloud.js) throws for both its
|
|
164
|
+
// overall-deadline and its launch-budget timeouts.
|
|
165
|
+
if (/project bootstrap exceeded the local/i.test(log)) {
|
|
166
|
+
return buildFailureVerdict('project-bootstrap-timeout', 'Still installing — we stopped waiting',
|
|
167
|
+
'The VM provisioned fine and its project bootstrap is still running on it; only the local wait budget elapsed. Nothing is broken and nothing was left half-built. Retry the add to re-attach to the running install — each bootstrap step is skip-on-exists. Do NOT destroy this VM.');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 4. container-stuck — the instance exists but the editor container never
|
|
171
|
+
// became healthy, so a half-provisioned box may exist. Not retryable.
|
|
172
|
+
if (/container never came up/i.test(log)) {
|
|
173
|
+
return buildFailureVerdict('container-stuck', 'Container never became healthy',
|
|
174
|
+
'The instance came up but the editor container never became healthy — it may be half-provisioned. Reconcile or destroy this VM before re-adding.');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 5. incomplete-workspace — the container came up but the project was never
|
|
178
|
+
// scaffolded: no `.codeyam/editor.json` exists under ~/projects on the VM,
|
|
179
|
+
// so the greenfield `init` did not complete onboarding. This is specific
|
|
180
|
+
// and more actionable than the generic cloud-up-failed below, so it is
|
|
181
|
+
// tested first. The VM exists and is half-provisioned, so it is NOT
|
|
182
|
+
// auto-retryable — reconcile finishes the scaffold, then re-add.
|
|
183
|
+
if (/no \.codeyam\/editor\.json found under ~\/projects/i.test(log)) {
|
|
184
|
+
return buildFailureVerdict('incomplete-workspace', 'Workspace never scaffolded',
|
|
185
|
+
'The container came up but the project was never scaffolded (no .codeyam/editor.json on the VM), so provisioning did not finish onboarding. Reconcile this VM to complete the scaffold, then re-add.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 6. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
|
|
189
|
+
// vanished / stuck signatures above did not match. A partial instance may
|
|
190
|
+
// exist, so not retryable.
|
|
191
|
+
if (/cloud:up FAILED \(exit/i.test(log)) {
|
|
192
|
+
return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
|
|
193
|
+
'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 7. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
|
|
197
|
+
// Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
|
|
198
|
+
if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
|
|
199
|
+
return buildFailureVerdict('interrupted', 'Provisioning interrupted',
|
|
200
|
+
'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 8. converging — the add reached the early-roster marker (`CY_ROSTER`): the
|
|
204
|
+
// container gate passed, the instance is real, and the VM is rostered, yet it
|
|
205
|
+
// exited non-zero WITHOUT matching any specific hard-failure signature above.
|
|
206
|
+
// By construction of the provision script, every genuine hard failure (drift,
|
|
207
|
+
// instance-vanished, container-stuck, incomplete-workspace, cloud-up-failed,
|
|
208
|
+
// interrupted) is printed BEFORE `CY_ROSTER`, so a rostered transcript that
|
|
209
|
+
// fell through to here is a still-wiring box owned by the detached
|
|
210
|
+
// wire-add-tail / reconcile-ingress — NOT an add failure. Surface it as an
|
|
211
|
+
// honest in-flight state instead of the scary red `unknown`. Not retryable:
|
|
212
|
+
// the VM exists and its detached wiring owns finishing it, so a one-click
|
|
213
|
+
// retry could duplicate/orphan a live box. Tested LAST so a hard-failure
|
|
214
|
+
// signature (all printed pre-roster) always wins — `converging` can never
|
|
215
|
+
// shadow a proven failure.
|
|
216
|
+
if (transcriptRostered(log)) {
|
|
217
|
+
return buildFailureVerdict('converging', 'Add still converging',
|
|
218
|
+
'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 9. unknown — fallback. Never invite an unproven retry.
|
|
222
|
+
return buildFailureVerdict('unknown', 'Add failed',
|
|
223
|
+
'The add failed for an unclassified reason — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Pure guard for the operator "Retry add" action: given a VM number and its
|
|
227
|
+
// carried job, decide whether a one-click retry is SAFE. Retry is allowed ONLY
|
|
228
|
+
// for a terminal add-error whose classifier verdict is retryable (a category
|
|
229
|
+
// where nothing was provisioned). Returns { ok, reason } — reason is a stable
|
|
230
|
+
// code the server shell (actionRetryAdd) maps to an operator message. Mirrors
|
|
231
|
+
// fleet-job-badge.js::canClearBadge so the handler stays a thin I/O shell and the
|
|
232
|
+
// safety decision is unit-tested without a live dashboard.
|
|
233
|
+
function canRetryAdd({ n, job } = {}) {
|
|
234
|
+
if (!Number.isInteger(n) || n < 1) return { ok: false, reason: 'invalid-vm' };
|
|
235
|
+
if (!job || job.action !== 'add' || job.state !== 'error') return { ok: false, reason: 'no-failed-add' };
|
|
236
|
+
if (!job.retryable) return { ok: false, reason: 'not-retryable' };
|
|
237
|
+
return { ok: true, reason: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Pure badge-hold predicate for deriveStatus (server.js): hold the calm in-flight
|
|
241
|
+
// `add-converging` card — instead of the red add-failed — ONLY when the add was
|
|
242
|
+
// classified `converging` AND its detached first-wiring is still in flight. Once
|
|
243
|
+
// the wire-add-tail fails/deadlines (`addTailFailed`) or is gone (`addTailInflight`
|
|
244
|
+
// false), or the category is anything else, return false so the caller falls
|
|
245
|
+
// through to the red add-failed status — the operator-chosen escalation. Kept pure
|
|
246
|
+
// (like canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
|
|
247
|
+
// live dashboard.
|
|
248
|
+
function holdConvergingBadge({ category, addTailInflight, addTailFailed } = {}) {
|
|
249
|
+
return category === 'converging' && !!addTailInflight && !addTailFailed;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, ADD_FAILURE_RETRYABLE };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure content-hash UI versioning for the fleet dashboard's cache-busting
|
|
3
|
+
// (vm: Honest VM Card Display + Dashboard Cache-Busting).
|
|
4
|
+
//
|
|
5
|
+
// The dashboard serves its single-file UI (public/index.html) with `no-store`,
|
|
6
|
+
// so a hard-refresh always fetches the new code — but an already-OPEN tab never
|
|
7
|
+
// refetches the HTML and silently runs the pre-restart UI until the operator
|
|
8
|
+
// manually reloads. To fix that, the server stamps a short content hash of the
|
|
9
|
+
// served HTML into every /data response; the client records the version it
|
|
10
|
+
// booted with and `location.reload()`s on a mismatch, so a dashboard restart
|
|
11
|
+
// with new UI reaches an open tab automatically.
|
|
12
|
+
//
|
|
13
|
+
// This module is the PURE half — a deterministic hash of the HTML string, no
|
|
14
|
+
// I/O — mirroring the pure/impure split the other fleet-* modules follow
|
|
15
|
+
// (server.js owns reading the file; this owns turning bytes into a version).
|
|
16
|
+
// Unit-tested in npm/fleet-asset-version.test.js.
|
|
17
|
+
//
|
|
18
|
+
// Stack note: this is fleet-operator dashboard infrastructure (the GCE fleet
|
|
19
|
+
// that runs codeyam-editor itself). A project that never runs the dashboard
|
|
20
|
+
// never reaches it.
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
|
|
23
|
+
// computeAssetVersion returns a stable, short (12 hex chars) fingerprint of the
|
|
24
|
+
// served UI. Same HTML in ⇒ same version out (so an unchanged deploy never
|
|
25
|
+
// spuriously reloads open tabs); any byte change ⇒ a different version (so a
|
|
26
|
+
// real UI change always reloads them). 12 hex chars of SHA-1 is ample to make
|
|
27
|
+
// an accidental collision between two distinct UI builds negligible while
|
|
28
|
+
// keeping the badge short.
|
|
29
|
+
function computeAssetVersion(html) {
|
|
30
|
+
return crypto
|
|
31
|
+
.createHash('sha1')
|
|
32
|
+
.update(String(html == null ? '' : html))
|
|
33
|
+
.digest('hex')
|
|
34
|
+
.slice(0, 12);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { computeAssetVersion };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure decision helpers for the fleet dashboard's false-unreachable reboot
|
|
3
|
+
// safety (2026-06-25 incident). The dashboard's server.js execs gcloud and reads
|
|
4
|
+
// its in-memory state; the DECISIONS those I/O results drive live here so they
|
|
5
|
+
// are unit-testable in isolation (npm/fleet-auth-health.test.js), the same
|
|
6
|
+
// split npm/fleet-cleanup.js (reconcileRosteredVm) and npm/fleet-host.js
|
|
7
|
+
// (gcloudCommand) follow.
|
|
8
|
+
//
|
|
9
|
+
// Pure logic only — no I/O, no env reads, no clock.
|
|
10
|
+
|
|
11
|
+
const fr = require('./fleet-reachability');
|
|
12
|
+
|
|
13
|
+
// Classify operator gcloud auth health from the facts server.js gathers via the
|
|
14
|
+
// ${GCLOUD} chokepoint. Returns { degraded, kind, reason }: degraded true means
|
|
15
|
+
// the operator's own side is faulted (which makes EVERY VM read unreachable), so
|
|
16
|
+
// the reset gate must suppress all reboots and the UI must surface a banner.
|
|
17
|
+
// account trimmed `gcloud config list account` output (may be empty)
|
|
18
|
+
// accountErrMsg non-null when the account-resolve exec itself errored
|
|
19
|
+
// tokenOk true/false when `auth print-access-token` was run; null when
|
|
20
|
+
// it was not (account-only phase) — a valid SA account with
|
|
21
|
+
// tokenOk null is "account looks fine, token not yet proven",
|
|
22
|
+
// which is NOT degraded, so the caller can proceed to the mint.
|
|
23
|
+
//
|
|
24
|
+
// `kind` DISCRIMINATES the two failures that used to collapse into one banner
|
|
25
|
+
// (2026-07-29): 'auth' means the CREDENTIAL is broken (re-bind the SA), while
|
|
26
|
+
// 'probe-failed' means the INVOCATION never produced an answer (restart the
|
|
27
|
+
// throttled process). Empty output with NO error message is the 'probe-failed'
|
|
28
|
+
// signature — a subprocess starved under cgroup throttling exits without writing
|
|
29
|
+
// anything, whereas a genuine credential failure always produces a message. Both
|
|
30
|
+
// still latch degraded: this adds a discriminator for the operator-facing
|
|
31
|
+
// RECOVERY, it does not weaken the guard. A 'probe-failed' verdict is
|
|
32
|
+
// inconclusive about credentials, so the caller must corroborate with local
|
|
33
|
+
// pressure signals before naming resource exhaustion — the mapping lives in
|
|
34
|
+
// fleet-destroy-safety.classifyDestroyBlock.
|
|
35
|
+
function classifyOperatorAuth({ account, accountErrMsg, tokenOk } = {}) {
|
|
36
|
+
const acct = typeof account === 'string' ? account.trim() : '';
|
|
37
|
+
if (accountErrMsg || !acct) {
|
|
38
|
+
return {
|
|
39
|
+
degraded: true,
|
|
40
|
+
kind: accountErrMsg ? 'auth' : 'probe-failed',
|
|
41
|
+
reason: `operator gcloud could not resolve an account (${accountErrMsg || 'empty output'})`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (!/\.gserviceaccount\.com$/.test(acct)) {
|
|
45
|
+
return {
|
|
46
|
+
degraded: true,
|
|
47
|
+
kind: 'auth',
|
|
48
|
+
reason: `operator gcloud is on a non-service-account '${acct}' — config drift; run scripts/operator/setup-service-account.sh`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (tokenOk === false) {
|
|
52
|
+
return {
|
|
53
|
+
degraded: true,
|
|
54
|
+
kind: 'auth',
|
|
55
|
+
reason: `operator gcloud cannot mint a token for ${acct} (expired/absent credentials); run scripts/operator/setup-service-account.sh`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { degraded: false, kind: null, reason: null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Consecutive-failure debounce for the FLEET-WIDE operator-auth-degraded BANNER
|
|
62
|
+
// (vm--flap-tolerant-reachability). Splits the two behaviors of the degraded
|
|
63
|
+
// verdict: the auto-reset SUPPRESSION stays conservative (server.js suppresses a
|
|
64
|
+
// reboot on even ONE failed probe — a broken laptop must never reboot a healthy
|
|
65
|
+
// VM), but a single transient gcloud-resolve hiccup must NOT blank the whole
|
|
66
|
+
// fleet with the scary "every VM reads unreachable" banner (observed 2026-07-01:
|
|
67
|
+
// the exact command succeeded 5/5 right after; the degraded flag self-cleared).
|
|
68
|
+
// The BANNER / all-VMs-unreachable DISPLAY requires `threshold` CONSECUTIVE
|
|
69
|
+
// degraded probes; a single healthy probe resets the streak to 0.
|
|
70
|
+
// prevStreak the consecutive degraded-probe count from the previous check
|
|
71
|
+
// degraded this probe's raw degraded verdict (classifyOperatorAuth.degraded)
|
|
72
|
+
// threshold consecutive degraded probes required before the banner fires
|
|
73
|
+
// Returns { streak, display }: display true only once the streak crosses.
|
|
74
|
+
const OPERATOR_AUTH_DISPLAY_THRESHOLD = 3;
|
|
75
|
+
function debounceOperatorAuthDisplay(prevStreak, degraded, threshold = OPERATOR_AUTH_DISPLAY_THRESHOLD) {
|
|
76
|
+
if (!degraded) return { streak: 0, display: false };
|
|
77
|
+
const streak = (prevStreak || 0) + 1;
|
|
78
|
+
return { streak, display: streak >= threshold };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// True when at least `fleetWideThreshold` VMs are simultaneously past the
|
|
82
|
+
// reset-unreachable `threshold` — the signature of an operator-side fault
|
|
83
|
+
// (auth/tunnel/network on the dashboard host) rather than independent VM
|
|
84
|
+
// failures. A real VM failure is isolated; a simultaneous N-VM "outage" is the
|
|
85
|
+
// operator, and the reset gate uses this to refuse rebooting the whole fleet.
|
|
86
|
+
// `vms` is an array of per-VM state objects (missing/null entries are ignored).
|
|
87
|
+
function isFleetWideUnreachable(vms, threshold, fleetWideThreshold) {
|
|
88
|
+
let count = 0;
|
|
89
|
+
for (const vm of Array.isArray(vms) ? vms : []) {
|
|
90
|
+
// Only a DEFINITE unreachable verdict counts. A VM whose reachability is
|
|
91
|
+
// unknown (never polled, or held inconclusive) has established nothing, and
|
|
92
|
+
// an unknown must never be evidence in a decision that gates a reboot — the
|
|
93
|
+
// shared invariant in npm/fleet-reachability.js.
|
|
94
|
+
if (vm && fr.isConfirmedUnreachable(vm.reachable) && (vm.unreachableStreak || 0) >= threshold) count++;
|
|
95
|
+
}
|
|
96
|
+
return count >= fleetWideThreshold;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Classify a one-shot GCP-side IAP editor probe (server.js execs the SSH+curl).
|
|
100
|
+
// true → editor answered 200 from GCP's vantage (VM healthy; the dark local
|
|
101
|
+
// port is a laptop tunnel fault → respawn, never reset);
|
|
102
|
+
// false → the VM is up but the editor does not answer (genuinely unreachable);
|
|
103
|
+
// null → the IAP probe itself failed (inconclusive — never escalate a reset).
|
|
104
|
+
// The HTTP code is read from the LAST three chars of stdout because gcloud/ssh
|
|
105
|
+
// can prepend banner noise before curl's `%{http_code}` output.
|
|
106
|
+
function classifyIapEditorProbe({ err, stdout } = {}) {
|
|
107
|
+
if (err) return null;
|
|
108
|
+
const code = String(stdout || '').trim().slice(-3);
|
|
109
|
+
return code === '200';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Choose WHICH signal decides a VM's per-poll reachability, and what it says.
|
|
113
|
+
//
|
|
114
|
+
// The laptop and the GCE operator host see a VM through different eyes. A laptop
|
|
115
|
+
// holds SSH `-L` forwards, so the cheap local-port probe (127.0.0.1:PORT_BASE+n)
|
|
116
|
+
// is a true reachability signal there. The GCE operator host has NO such
|
|
117
|
+
// forwards, so that probe fails for EVERY VM on every poll — which climbed each
|
|
118
|
+
// VM's unreachable streak into the vanish-reconcile path and fired a ~15-60s
|
|
119
|
+
// IAP-SSH tunnel probe per VM per cycle. With ~10 VMs those piled up faster than
|
|
120
|
+
// they drained and drove the host past load 25 (diagnosed live 2026-07-19).
|
|
121
|
+
//
|
|
122
|
+
// On GCE the fix is to read the LB editor backend-service health the dashboard
|
|
123
|
+
// ALREADY gathers (one cheap `gcloud ... backend-services get-health` per slow
|
|
124
|
+
// cycle) as the primary verdict: O(1 API call), no tunnel, fleet-scalable.
|
|
125
|
+
//
|
|
126
|
+
// onGce true on the GCE operator host (FLEET_ON_GCE)
|
|
127
|
+
// lbReachable the LB+IAP probe (probeVM) actually reached the VM's
|
|
128
|
+
// editor. Only supplied when PROBE_VIA_LB is on; a
|
|
129
|
+
// null/undefined means "no LB probe on this deployment"
|
|
130
|
+
// (the laptop) and this axis stays silent.
|
|
131
|
+
// backendEditorHealth classifyBackendHealth's verbatim verdict for the editor
|
|
132
|
+
// backend: 'healthy'|'unhealthy'|'unknown'|'absent'
|
|
133
|
+
// (null/undefined before the first slow gather lands)
|
|
134
|
+
// localProbeOk the 127.0.0.1:PORT_BASE+n editor probe answered
|
|
135
|
+
// iapVerdict classifyIapEditorProbe's result, when one is on hand
|
|
136
|
+
//
|
|
137
|
+
// Returns { reachable, source }:
|
|
138
|
+
// reachable true | false | null, where null is INCONCLUSIVE — hold. It must
|
|
139
|
+
// never read as reachable and must never advance an unreachable
|
|
140
|
+
// streak toward an auto-reset. A genuinely-down VM must not read
|
|
141
|
+
// healthy, and a not-yet-known VM must not read dead.
|
|
142
|
+
// source 'lb' | 'backend' | 'local' | 'iap' — which signal decided, for the log.
|
|
143
|
+
//
|
|
144
|
+
// Only 'absent' falls through to the local/IAP path on GCE: a VM with no
|
|
145
|
+
// vm-<N>-editor-bes has no LB signal at all (mid-add / un-wired), and there are
|
|
146
|
+
// few of those at any time, so the occasional IAP probe there is affordable.
|
|
147
|
+
// 'unknown' and the pre-first-gather null deliberately HOLD rather than fall
|
|
148
|
+
// through, because on GCE falling through means a guaranteed-false local probe.
|
|
149
|
+
function classifyReachability({ onGce, backendEditorHealth, localProbeOk, iapVerdict, lbReachable } = {}) {
|
|
150
|
+
if (onGce) {
|
|
151
|
+
// The LB+IAP probe outranks backend health (vm--gce-reachable-flag-from-lb-probe).
|
|
152
|
+
// It is the only signal on the GCE host that actually REACHES the VM's editor:
|
|
153
|
+
// the local-port probe has no SSH `-L` forward there so it always fails, and
|
|
154
|
+
// backendEditorHealth is filled by a slow gather that early-returns for
|
|
155
|
+
// not-yet-reachable VMs — the reachable⇄backend-health deadlock that pinned the
|
|
156
|
+
// dashboard at 0/10 while every probe was in fact succeeding. Only a definite
|
|
157
|
+
// boolean speaks; null/undefined (no LB probe configured — the laptop) falls
|
|
158
|
+
// through to the pre-existing backend/local/IAP ladder unchanged.
|
|
159
|
+
if (lbReachable === true) return { reachable: true, source: 'lb' };
|
|
160
|
+
if (lbReachable === false) return { reachable: false, source: 'lb' };
|
|
161
|
+
if (backendEditorHealth === 'healthy') return { reachable: true, source: 'backend' };
|
|
162
|
+
if (backendEditorHealth === 'unhealthy') return { reachable: false, source: 'backend' };
|
|
163
|
+
// 'unknown', or no gather yet (null/undefined): inconclusive, hold.
|
|
164
|
+
if (backendEditorHealth !== 'absent') return { reachable: null, source: 'backend' };
|
|
165
|
+
// 'absent' falls through to the laptop-shaped local/IAP path below.
|
|
166
|
+
}
|
|
167
|
+
// Laptop path (and GCE's backend-less VMs): the local port probe is primary,
|
|
168
|
+
// exactly as before. The IAP verdict only speaks when the local port is dark.
|
|
169
|
+
if (localProbeOk) return { reachable: true, source: 'local' };
|
|
170
|
+
if (iapVerdict === true) return { reachable: true, source: 'iap' };
|
|
171
|
+
if (iapVerdict === false) return { reachable: false, source: 'iap' };
|
|
172
|
+
return { reachable: false, source: 'local' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Resolve ONE poll's reachability record from a (possibly inconclusive) verdict
|
|
176
|
+
// plus the prior poll's state. Where classifyReachability answers "which signal
|
|
177
|
+
// decides, and what does it say", this answers "so what does this poll record".
|
|
178
|
+
//
|
|
179
|
+
// This is the safety-critical half of vm--lb-health-reachability-on-gce: an
|
|
180
|
+
// INCONCLUSIVE verdict (reachable null — on GCE, backend health 'unknown' or not
|
|
181
|
+
// yet gathered) must neither invent a reachability nor advance the unreachable
|
|
182
|
+
// streak. The streak is what pollVanishedInstances gates on before it spends an
|
|
183
|
+
// IAP probe and ultimately reboots a VM, so letting an unknown signal increment
|
|
184
|
+
// it would march a VM toward a destructive reset on evidence we do not have.
|
|
185
|
+
// Instead: hold the last-known reachability, and FREEZE the streak.
|
|
186
|
+
//
|
|
187
|
+
// verdict classifyReachability's { reachable, source }
|
|
188
|
+
// prevReachable the previous poll's resolved reachability
|
|
189
|
+
// prevUnreachableStreak the previous poll's consecutive-unreachable count
|
|
190
|
+
//
|
|
191
|
+
// Returns { reachable, unreachableStreak }. A reachable poll zeroes the streak
|
|
192
|
+
// immediately (we never delay clearing a false alarm, only delay raising one).
|
|
193
|
+
function resolveReachabilityPoll({ verdict, prevReachable, prevUnreachableStreak } = {}) {
|
|
194
|
+
const prevStreak = prevUnreachableStreak || 0;
|
|
195
|
+
const raw = verdict ? verdict.reachable : null;
|
|
196
|
+
if (raw === null || raw === undefined) {
|
|
197
|
+
return { reachable: !!prevReachable, unreachableStreak: prevStreak };
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
reachable: !!raw,
|
|
201
|
+
unreachableStreak: raw ? 0 : prevStreak + 1,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
module.exports = {
|
|
206
|
+
classifyOperatorAuth,
|
|
207
|
+
debounceOperatorAuthDisplay,
|
|
208
|
+
OPERATOR_AUTH_DISPLAY_THRESHOLD,
|
|
209
|
+
isFleetWideUnreachable,
|
|
210
|
+
classifyIapEditorProbe,
|
|
211
|
+
classifyReachability,
|
|
212
|
+
resolveReachabilityPoll,
|
|
213
|
+
};
|
package/npm/fleet-auth.js
CHANGED
|
@@ -108,4 +108,9 @@ function deriveDefaultAuthAccount(vms, lastAuthAt) {
|
|
|
108
108
|
return best ? best.account : null;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
111
|
+
// Return true if this one VM is eligible for an auth-start action now.
|
|
112
|
+
function isAuthStartable(v, job) {
|
|
113
|
+
return classifyAuthVm(v, job).eligible;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { classifyAuthVm, selectAuthTargets, summarizeAuthBatch, deriveDefaultAuthAccount, isAuthStartable };
|