@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ // Pure status helpers for the Sleep / Wake feature on the Fleet Dashboard
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Mirrors the
4
+ // pure-module-plus-colocated-vitest-test shape of npm/fleet-effect-status.js:
5
+ // server.js does the impure fact-gathering (the gcloud running-VM list, the
6
+ // per-VM reachability probe); these predicates turn those facts into the two
7
+ // new card statuses without any I/O, so the whole decision is unit-testable.
8
+
9
+ // Map a RUNNING stop/start job to its in-flight status badge. A Sleep click
10
+ // runs `gcloud compute instances stop` as a 'stop' job → 'stopping'; a Wake
11
+ // click runs `...start` as a 'start' job → 'starting'. Returns null for every
12
+ // other action so deriveStatus's existing per-action chain keeps owning it.
13
+ function sleepJobStatus(action) {
14
+ if (action === 'stop') return 'stopping';
15
+ if (action === 'start') return 'starting';
16
+ return null;
17
+ }
18
+
19
+ // Classify an UNREACHABLE rostered VM. The dashboard already polls GCP for the
20
+ // set of RUNNING fleet instances (runningFleetVms) on its slow 60s cycle, so we
21
+ // reuse it at zero extra API cost: a VM GCP still lists as running but whose
22
+ // control API we can't reach is genuinely 'unreachable' (booting, wedged, or
23
+ // the cloudflared tunnel is down); one absent from the running list has been
24
+ // stopped to pause billing — 'asleep'. A null/absent list is treated as "not
25
+ // running" so a cold dashboard with no poll yet defaults to the safe 'asleep'
26
+ // rather than masking a stopped VM as a red unreachable.
27
+ function unreachableStatus(n, runningFleetVms) {
28
+ return Array.isArray(runningFleetVms) && runningFleetVms.includes(n)
29
+ ? 'unreachable'
30
+ : 'asleep';
31
+ }
32
+
33
+ module.exports = { sleepJobStatus, unreachableStatus };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+ // Pure classification + self-heal predicates for the fleet dashboard's
3
+ // launcher-strand detection (vm: Fleet converges onto the launcher-strand fix
4
+ // and self-heals the strand regardless of trigger).
5
+ //
6
+ // A cloud VM can strand off its control port `:14199` in bare Project Launcher
7
+ // mode: the editor re-execs into the launcher on an EPHEMERAL port instead of
8
+ // serving its one /workspace project on the contract port, so the dashboard's
9
+ // local-forward probe gets connection-refused and reports a generic
10
+ // "unreachable" — indistinguishable from a real outage. Diagnosed on VM-4
11
+ // (2026-06-25): an add-time binary that predated the `serve_cwd_directly`
12
+ // launcher-strand guard fell through `decide_launch` to the launcher because a
13
+ // stale `/tmp` test-fixture project padded `registry_count` to 2.
14
+ //
15
+ // The completed predecessor plan fixed the editor CODE PATH (serve the cwd
16
+ // project on :14199); this module is the dashboard-side SAFETY NET that detects
17
+ // the strand CONTINUOUSLY — by its trigger-independent SIGNAL SHAPE, not by what
18
+ // caused it — so a running fleet can't silently sit on the landmine. This is the
19
+ // PURE half (classification + decision), mirroring npm/fleet-public-url.js: the
20
+ // docker-exec / curl I/O lives in scripts/operator/fleet-dashboard/server.js's
21
+ // gatherLauncherStrand, the same gather-edge / pure-half split
22
+ // classifyPublicUrlHealth follows. Unit-tested in npm/fleet-strand.test.js.
23
+ //
24
+ // Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
25
+ // codeyam-editor itself). A project that never joins the fleet never reaches it.
26
+
27
+ // The boot guard whose presence makes a binary safe from the launcher-mode
28
+ // strand. Single source so the classifier, the capability field the editor
29
+ // advertises, and any log line agree on the string.
30
+ const SERVE_CWD_GUARD = 'serve_cwd_directly';
31
+
32
+ /** Classify a VM's in-container probe into the launcher-strand vocabulary. The
33
+ * DECISIVE, trigger-independent signal is observable in-VM regardless of what
34
+ * caused the strand (a stale add-time binary, a wiped open-project pointer, any
35
+ * future cause):
36
+ *
37
+ * 'serving' — `editorHealthOk`: the control port answers /api/health,
38
+ * so the editor is serving its project. Nothing to heal.
39
+ * 'launcher-strand'— container up, control port has NO listener, AND a
40
+ * codeyam-editor process is serving the Project Launcher on
41
+ * an ephemeral port. THIS is the strand: :14199 is dark but
42
+ * the editor is alive on the wrong port.
43
+ * 'down' — container up, control port dark, and NO launcher process
44
+ * either: a genuine outage (crashed editor / dead tunnel).
45
+ * Left to the existing unreachable handling — must NOT
46
+ * auto-rebuild, a rebuild is the wrong hammer for a crash.
47
+ * 'unknown' — anything else (container not up, probe failed): absence
48
+ * of evidence, never auto-acts (mirrors
49
+ * classifyPublicUrlHealth's 'unknown' contract).
50
+ *
51
+ * `editorHealthOk` wins first: once the control port answers, the VM is serving
52
+ * no matter what else a slow/racy probe reported. */
53
+ function classifyLauncherStrand({
54
+ controlPortListening,
55
+ containerUp,
56
+ launcherServing,
57
+ editorHealthOk,
58
+ } = {}) {
59
+ if (editorHealthOk) return 'serving';
60
+ if (containerUp && !controlPortListening && launcherServing) return 'launcher-strand';
61
+ if (containerUp && !controlPortListening && !launcherServing) return 'down';
62
+ return 'unknown';
63
+ }
64
+
65
+ /** Decide whether a just-landed strand verdict warrants an auto-recovery, the
66
+ * PURE gate the standing self-heal fires through — debounced exactly like
67
+ * shouldAutoRewireIngress so a recovery that doesn't immediately clear can't
68
+ * loop and a single transient poll during a legitimate restart can't act:
69
+ * - `verdict` is 'launcher-strand' (only the strand self-heals; 'down' is a
70
+ * genuine outage the unreachable path owns, 'serving'/'unknown' need nothing),
71
+ * - `strandStreak` has reached `minStreak` consecutive strand polls (a single
72
+ * transient dark-port poll during a restart must not trigger a heavy rebuild),
73
+ * - no recovery is `alreadyRecovering` (one heavy job per VM), AND
74
+ * - the `cooldownMs` window has elapsed since `lastRecoverAt` so a recovery
75
+ * still settling can't re-fire every slow cycle.
76
+ * `hasSession` deliberately does NOT gate: a stranded editor has no live project
77
+ * session to protect (same rationale as the post-reset force=true fallback). A
78
+ * missing/0 `lastRecoverAt` means "never recovered" → the cooldown is satisfied. */
79
+ function shouldAutoRecoverStrand({
80
+ verdict,
81
+ strandStreak,
82
+ alreadyRecovering,
83
+ lastRecoverAt,
84
+ now,
85
+ cooldownMs,
86
+ minStreak = 2,
87
+ } = {}) {
88
+ if (verdict !== 'launcher-strand') return false;
89
+ if (alreadyRecovering) return false;
90
+ if (Number(strandStreak) < Number(minStreak)) return false;
91
+ return (Number(now) - (Number(lastRecoverAt) || 0)) >= Number(cooldownMs);
92
+ }
93
+
94
+ /** Choose the recovery SHAPE for a launcher-strand by binary currency. A plain
95
+ * reconfigure KEEPS the editor image, so recreating the container with the SAME
96
+ * pre-guard binary would immediately re-strand — the wrong hammer when a stale
97
+ * binary caused the strand. Returns:
98
+ * 'rebuild' — advance source to origin tip → rebuild-self → restart (the
99
+ * update-reset-everything shape) when the binary is stale OR
100
+ * its currency is UNKNOWN. The dark-control-port strand can't
101
+ * advertise its capability live, and re-stranding on a kept
102
+ * image is the worse failure, so unknown defaults to rebuild.
103
+ * 'reconfigure' — image-kept container recreate, correct ONLY when the running
104
+ * binary is already CURRENT (e.g. a wiped-pointer strand on a
105
+ * fresh binary), where a rebuild would be a needless ~8-min build.
106
+ * `binaryCurrent === true` is the only input that yields 'reconfigure'; null /
107
+ * undefined / false all yield 'rebuild'. */
108
+ function strandRecoveryAction({ binaryCurrent } = {}) {
109
+ return binaryCurrent === true ? 'reconfigure' : 'rebuild';
110
+ }
111
+
112
+ /** Classify whether a STILL-SERVING VM's running binary is at risk of the
113
+ * launcher-strand on its next re-exec — the PROACTIVE convergence signal (step
114
+ * 5), distinct from the strand verdict above (which fires only once a VM is
115
+ * ALREADY dark). Whether a binary is guard-capable is answered most robustly by
116
+ * the editor advertising the capability (the `bootGuards` array on the
117
+ * health/session-info payload it already serves) rather than the dashboard
118
+ * guessing from commit ancestry:
119
+ * - `bootGuards` IS an array → authoritative: stale iff it lacks the guard.
120
+ * - `bootGuards` ABSENT (an older binary that predates the field — which are
121
+ * exactly the at-risk ones) → fall back to the build-commit comparison the
122
+ * dashboard already computes: flag stale only when the editor source is
123
+ * `editorBehind > 0` commits behind origin, so a current binary that merely
124
+ * predates the FIELD isn't falsely flagged while a genuinely-behind one is.
125
+ * Returns `{ stale, reason }`; `reason` is null when not stale. */
126
+ function classifyBinaryGuard({ bootGuards, editorBehind, requiredGuard = SERVE_CWD_GUARD } = {}) {
127
+ if (Array.isArray(bootGuards)) {
128
+ if (bootGuards.includes(requiredGuard)) return { stale: false, reason: null };
129
+ return {
130
+ stale: true,
131
+ reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${requiredGuard}'; rebuild to converge`,
132
+ };
133
+ }
134
+ const behind = Number(editorBehind);
135
+ if (Number.isFinite(behind) && behind > 0) {
136
+ return {
137
+ stale: true,
138
+ reason: `running binary predates the boot-guard capability and editor source is ${behind} commit(s) behind origin; rebuild to converge`,
139
+ };
140
+ }
141
+ return { stale: false, reason: null };
142
+ }
143
+
144
+ module.exports = {
145
+ SERVE_CWD_GUARD,
146
+ classifyLauncherStrand,
147
+ shouldAutoRecoverStrand,
148
+ strandRecoveryAction,
149
+ classifyBinaryGuard,
150
+ };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ // Pure stuck / needs-you attention policy for the Fleet Dashboard's per-VM card
4
+ // (scripts/operator/fleet-dashboard/server.js requires this). It answers a
5
+ // single question the existing status label deliberately does NOT: given how
6
+ // long a VM has been parked on its CURRENT workflow step, is it progressing
7
+ // normally, slowing down, or likely STUCK — and if it is merely waiting on a
8
+ // human, has that wait dragged on long enough to flag "needs you"?
9
+ //
10
+ // Why the STEP timer, not the feature timer: the workflow cursor advances
11
+ // (resetting `started_at` in crates/types/src/step.rs) every time a signature
12
+ // command runs, so step-elapsed is the tightest "is it making progress?" proxy
13
+ // we have. A long feature is normal; a long single step is the smell.
14
+ //
15
+ // This is an ADDITIVE overlay — it never changes the building/waiting/idle
16
+ // status label (that stays in fleet-live-status.js). It only decorates a card
17
+ // with a soft 'slow' note, a hard 'stuck' alarm, or a distinct 'needs you'
18
+ // badge. The split mirrors the established dashboard pure-module pattern
19
+ // (fleet-live-status.js, fleet-broker-status.js, fleet-vm-readiness.js):
20
+ // server.js does the Date.now() I/O and passes the verdict to buildVmEnvelope;
21
+ // the browser renders it.
22
+
23
+ const DEFAULT_SLOW_MINUTES = 15;
24
+ const DEFAULT_STUCK_MINUTES = 30;
25
+ const DEFAULT_NEEDS_YOU_MINUTES = 10;
26
+
27
+ // Parse an env var that should hold a positive number of minutes, falling back
28
+ // to `fallback` when it is unset, empty, non-numeric, or non-positive. Keeping
29
+ // this tolerant means a fat-fingered override degrades to the documented
30
+ // default rather than producing a NaN threshold that no elapsed time can cross.
31
+ function minutesFromEnv(raw, fallback) {
32
+ if (raw == null || raw === '') return fallback;
33
+ const n = Number(raw);
34
+ if (!Number.isFinite(n) || n <= 0) return fallback;
35
+ return n;
36
+ }
37
+
38
+ // Resolve the three thresholds (in milliseconds) from a process-env-shaped
39
+ // object. Defaults live here so they are documented and unit-testable in one
40
+ // place: slow 15m, stuck 30m, needs-you 10m. Env overrides:
41
+ // FLEET_STUCK_STEP_SLOW_MINUTES, FLEET_STUCK_STEP_STUCK_MINUTES,
42
+ // FLEET_NEEDS_YOU_MINUTES.
43
+ function resolveThresholds(env) {
44
+ const e = env || {};
45
+ return {
46
+ slowMs: minutesFromEnv(e.FLEET_STUCK_STEP_SLOW_MINUTES, DEFAULT_SLOW_MINUTES) * 60_000,
47
+ stuckMs: minutesFromEnv(e.FLEET_STUCK_STEP_STUCK_MINUTES, DEFAULT_STUCK_MINUTES) * 60_000,
48
+ needsYouMs: minutesFromEnv(e.FLEET_NEEDS_YOU_MINUTES, DEFAULT_NEEDS_YOU_MINUTES) * 60_000,
49
+ };
50
+ }
51
+
52
+ // Compute elapsed ms from an ISO timestamp to nowMs, or null when the timestamp
53
+ // is missing / unparseable. A null elapsed is the graceful-degrade path for an
54
+ // older editor whose /api/session-info predates startedAt/featureStartedAt.
55
+ function elapsedMs(iso, nowMs) {
56
+ if (!iso) return null;
57
+ const started = Date.parse(iso);
58
+ if (!Number.isFinite(started)) return null;
59
+ const delta = nowMs - started;
60
+ return delta >= 0 ? delta : 0;
61
+ }
62
+
63
+ // Derive the attention verdict for one VM.
64
+ //
65
+ // deriveAttention({ status, stepStartedAt, awaitingInput, nowMs, thresholds })
66
+ // -> { stepElapsedMs, level, needsYou, needsYouElapsedMs }
67
+ //
68
+ // Rules:
69
+ // - `level` ('ok' | 'slow' | 'stuck') is computed ONLY for an actively-
70
+ // building VM (status === 'building'). Thresholds are inclusive:
71
+ // >= stuckMs -> 'stuck', else >= slowMs -> 'slow', else 'ok'.
72
+ // - A VM awaiting input (status === 'waiting' or awaitingInput true) never
73
+ // reads 'slow'/'stuck' — it is legitimately blocked on a human. Instead
74
+ // `needsYou` flips true once its elapsed >= needsYouMs.
75
+ // - Missing/unparseable stepStartedAt degrades to stepElapsedMs:null,
76
+ // level:'ok', needsYou:false (older editor, no crash).
77
+ function deriveAttention(opts) {
78
+ const o = opts || {};
79
+ const thresholds = o.thresholds || resolveThresholds({});
80
+ const nowMs = o.nowMs;
81
+ const stepElapsed = elapsedMs(o.stepStartedAt, nowMs);
82
+ const awaiting = o.status === 'waiting' || !!o.awaitingInput;
83
+
84
+ let level = 'ok';
85
+ let needsYou = false;
86
+ let needsYouElapsedMs = null;
87
+
88
+ if (awaiting) {
89
+ // Waiting on a human — never "stuck"; flag "needs you" past its threshold.
90
+ needsYouElapsedMs = stepElapsed;
91
+ needsYou = stepElapsed != null && stepElapsed >= thresholds.needsYouMs;
92
+ } else if (o.status === 'building' && stepElapsed != null) {
93
+ if (stepElapsed >= thresholds.stuckMs) level = 'stuck';
94
+ else if (stepElapsed >= thresholds.slowMs) level = 'slow';
95
+ }
96
+
97
+ return {
98
+ stepElapsedMs: stepElapsed,
99
+ level,
100
+ needsYou,
101
+ needsYouElapsedMs,
102
+ };
103
+ }
104
+
105
+ module.exports = {
106
+ deriveAttention,
107
+ resolveThresholds,
108
+ DEFAULT_SLOW_MINUTES,
109
+ DEFAULT_STUCK_MINUTES,
110
+ DEFAULT_NEEDS_YOU_MINUTES,
111
+ };
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-vm-access.js — the per-VM IAP access whitelist sidecar
4
+ // (`.codeyam/logs/fleet-vm-access.json`). It records which principals may reach
5
+ // a specific fleet VM's IAP-secured surfaces, keyed by VM number:
6
+ // `{ "<N>": ["user:a@x.com", "group:team@x.com", "domain:partner.com"], ... }`.
7
+ //
8
+ // Why a sidecar and not the roster: the roster (`npm/fleet-roster.js`) is a flat
9
+ // integer array with a hard "sorted, de-duped set of integers" invariant bash
10
+ // consumers depend on. Access is a per-VM list, a different shape, so it lives in
11
+ // a SEPARATE map fronted by a read+write seam modeled exactly on
12
+ // npm/fleet-vm-slugs.js (atomic temp-file + rename, tolerant of a missing or
13
+ // malformed file). The slug sidecar maps a VM number to one hostname label; this
14
+ // one maps a VM number to an access list — same persistence contract, sibling
15
+ // concern.
16
+ //
17
+ // IAP access is granted at the resource level on a VM's per-surface backend
18
+ // services (`vm-<N>-<surface>-bes`); this file is only the source-of-truth list,
19
+ // the gcloud binding is applied by scripts/gcp/iap-access.sh. The owner's
20
+ // project-wide grant (scripts/gcp/google-ingress.sh) is independent — these lists
21
+ // only ADD collaborators to specific VMs.
22
+ //
23
+ // Env override: `VM_ACCESS_FILE` relocates the file (mirrors fleet-vm-slugs.js's
24
+ // `VM_SLUGS_FILE`); resolved once at module load.
25
+
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+
29
+ const { rootDir } = require('./utils');
30
+ const { normalizeVmNumber } = require('./vm-urls');
31
+
32
+ const VM_ACCESS_FILE = process.env.VM_ACCESS_FILE
33
+ || path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-access.json');
34
+
35
+ // The IAM principal types IAP accepts on a backend-service binding. A bare email
36
+ // (no type prefix) defaults to `user:`.
37
+ const PRINCIPAL_TYPES = Object.freeze(['user', 'group', 'domain']);
38
+
39
+ // A DNS domain: at least two dot-separated labels, each a lowercase
40
+ // letters/digits/hyphen label (no leading/trailing hyphen). Used to validate a
41
+ // `domain:` principal and the domain half of a `user:`/`group:` email — a typo'd
42
+ // principal must fail loud before reaching gcloud.
43
+ const DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
44
+
45
+ /** Validate + normalize one IAM principal into the `<type>:<value>` form gcloud
46
+ * expects. Accepts `user:a@x.com`, `group:team@x.com`, `domain:x.com`; a bare
47
+ * `a@x.com` defaults to `user:`. Rejects (loudly) a malformed value so junk
48
+ * never reaches `gcloud iap web add-iam-policy-binding` — the same
49
+ * fail-at-the-boundary posture as normalizeVmSlug. Returns the lowercased,
50
+ * trimmed, type-prefixed principal. */
51
+ function normalizePrincipal(raw) {
52
+ if (raw == null) throw new Error('access principal is required');
53
+ const trimmed = String(raw).trim();
54
+ if (trimmed === '') throw new Error('access principal must not be empty');
55
+ const lower = trimmed.toLowerCase();
56
+ const colon = lower.indexOf(':');
57
+ const hasType = colon !== -1 && PRINCIPAL_TYPES.includes(lower.slice(0, colon));
58
+ const type = hasType ? lower.slice(0, colon) : 'user';
59
+ const value = hasType ? lower.slice(colon + 1) : lower;
60
+ if (value === '') {
61
+ throw new Error(`access principal "${raw}" has an empty value after "${type}:"`);
62
+ }
63
+ if (type === 'domain') {
64
+ if (value.includes('@') || !DOMAIN_RE.test(value)) {
65
+ throw new Error(
66
+ `access principal "${raw}" is not a valid domain (e.g. domain:example.com)`,
67
+ );
68
+ }
69
+ } else {
70
+ const at = value.indexOf('@');
71
+ if (at <= 0 || value.indexOf('@', at + 1) !== -1) {
72
+ throw new Error(
73
+ `access principal "${raw}" is not a valid ${type} email ` +
74
+ `(exactly one "@", e.g. ${type}:a@example.com)`,
75
+ );
76
+ }
77
+ const domain = value.slice(at + 1);
78
+ if (!DOMAIN_RE.test(domain)) {
79
+ throw new Error(`access principal "${raw}" has an invalid email domain "${domain}"`);
80
+ }
81
+ }
82
+ return `${type}:${value}`;
83
+ }
84
+
85
+ /** Normalize a whole access list — an array of principals or a comma-separated
86
+ * string — into a deduped, validated array of `<type>:<value>` principals.
87
+ * Blank entries are skipped; a single malformed entry throws (the list is
88
+ * rejected as a whole rather than silently dropping the bad one). Order of first
89
+ * appearance is preserved. */
90
+ function normalizeAccessList(input) {
91
+ const raw = Array.isArray(input)
92
+ ? input
93
+ : String(input == null ? '' : input).split(',');
94
+ const out = [];
95
+ const seen = new Set();
96
+ for (const item of raw) {
97
+ const s = String(item).trim();
98
+ if (s === '') continue;
99
+ const principal = normalizePrincipal(s);
100
+ if (!seen.has(principal)) {
101
+ seen.add(principal);
102
+ out.push(principal);
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ // Read the access sidecar, returning a `{ [vmNumber]: principal[] }` object, or
109
+ // null if the file is missing or malformed (treated as "no access lists"). Keys
110
+ // are the string form JSON emits; callers look them up by `String(n)`.
111
+ function readAccess() {
112
+ try {
113
+ const data = JSON.parse(fs.readFileSync(VM_ACCESS_FILE, 'utf8'));
114
+ if (data && typeof data === 'object' && !Array.isArray(data)) return data;
115
+ } catch { /* missing or malformed */ }
116
+ return null;
117
+ }
118
+
119
+ // Persist `map` as the sidecar, atomically: write a pid-stamped temp file then
120
+ // `rename` it over the target so a concurrent reader never observes a
121
+ // half-written file (mirrors fleet-vm-slugs.js::writeSlugs). Returns the written
122
+ // map.
123
+ function writeAccess(map) {
124
+ const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {};
125
+ fs.mkdirSync(path.dirname(VM_ACCESS_FILE), { recursive: true });
126
+ const tmp = `${VM_ACCESS_FILE}.${process.pid}.tmp`;
127
+ fs.writeFileSync(tmp, JSON.stringify(next));
128
+ fs.renameSync(tmp, VM_ACCESS_FILE); // atomic replace
129
+ return next;
130
+ }
131
+
132
+ /** The access list for VM `n` — the principals whitelisted onto its surfaces —
133
+ * or an empty array when it has none (or the file is absent). The number is
134
+ * validated so junk can't sneak through as a lookup key. */
135
+ function accessFor(n) {
136
+ const num = normalizeVmNumber(n);
137
+ const map = readAccess();
138
+ const list = map && map[String(num)];
139
+ return Array.isArray(list) ? list.filter((p) => typeof p === 'string' && p) : [];
140
+ }
141
+
142
+ /** Replace VM `n`'s access list wholesale with `principals` (validated + deduped
143
+ * via normalizeAccessList). An empty result removes the VM's entry entirely so
144
+ * the sidecar never accumulates empty arrays. Returns the stored list. */
145
+ function setAccess(n, principals) {
146
+ const num = normalizeVmNumber(n);
147
+ const list = normalizeAccessList(principals);
148
+ const map = { ...(readAccess() || {}) };
149
+ if (list.length === 0) delete map[String(num)];
150
+ else map[String(num)] = list;
151
+ writeAccess(map);
152
+ return list;
153
+ }
154
+
155
+ /** Add a single principal to VM `n`'s access list (validated + deduped). Adding
156
+ * one already present is an idempotent no-op-shaped write. Returns the new list. */
157
+ function addAccess(n, principal) {
158
+ const num = normalizeVmNumber(n);
159
+ const p = normalizePrincipal(principal);
160
+ const next = accessFor(num);
161
+ if (!next.includes(p)) next.push(p);
162
+ const map = { ...(readAccess() || {}) };
163
+ map[String(num)] = next;
164
+ writeAccess(map);
165
+ return next;
166
+ }
167
+
168
+ /** Remove a single principal from VM `n`'s access list. Removing an absent
169
+ * principal is a no-op; emptying the list removes the VM's entry. Returns the
170
+ * new list. */
171
+ function removeAccess(n, principal) {
172
+ const num = normalizeVmNumber(n);
173
+ const p = normalizePrincipal(principal);
174
+ const next = accessFor(num).filter((x) => x !== p);
175
+ const map = { ...(readAccess() || {}) };
176
+ if (next.length === 0) delete map[String(num)];
177
+ else map[String(num)] = next;
178
+ writeAccess(map);
179
+ return next;
180
+ }
181
+
182
+ /** Parse the raw stdout of `gcloud projects get-iam-policy ...
183
+ * --format='value(bindings.members)'` into a deduped, trimmed array of
184
+ * principals. gcloud separates members within a binding by `;` and (when not
185
+ * flattened) bindings by newline, so we split on BOTH. Order of first
186
+ * appearance is preserved.
187
+ *
188
+ * This is deliberately NOT normalizePrincipal: it is a *read* of whatever IAM
189
+ * already holds, so it must surface principals verbatim (lowercased only for
190
+ * dedup-stability) — including `serviceAccount:` members that the strict
191
+ * write-path validator would reject. The one thing it drops, besides blanks, is
192
+ * gcloud's `deleted:` tombstone members (e.g.
193
+ * `deleted:user:x@x.com?uid=123`), which name principals that no longer exist
194
+ * and can't be acted on. */
195
+ function parseProjectMembers(stdout) {
196
+ const out = [];
197
+ const seen = new Set();
198
+ for (const raw of String(stdout == null ? '' : stdout).split(/[;\n]/)) {
199
+ const s = raw.trim();
200
+ if (s === '') continue;
201
+ if (s.toLowerCase().startsWith('deleted:')) continue;
202
+ const principal = s.toLowerCase();
203
+ if (!seen.has(principal)) {
204
+ seen.add(principal);
205
+ out.push(principal);
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+
211
+ /** Drop VM `n`'s entire access list, returning the new map. Clearing an absent
212
+ * entry is a no-op. Called on Destroy so a number freed and later re-used
213
+ * doesn't inherit a stale whitelist. */
214
+ function clearAccess(n) {
215
+ const num = normalizeVmNumber(n);
216
+ const map = { ...(readAccess() || {}) };
217
+ delete map[String(num)];
218
+ return writeAccess(map);
219
+ }
220
+
221
+ module.exports = {
222
+ VM_ACCESS_FILE,
223
+ PRINCIPAL_TYPES,
224
+ normalizePrincipal,
225
+ normalizeAccessList,
226
+ parseProjectMembers,
227
+ readAccess,
228
+ writeAccess,
229
+ accessFor,
230
+ setAccess,
231
+ addAccess,
232
+ removeAccess,
233
+ clearAccess,
234
+ };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+ // Pure readiness derivation for the Fleet Dashboard's per-VM card
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Given the merged
4
+ // poll state `v` and the derived `ctx` the card builder already computes,
5
+ // collapse the VM into a single readiness label the card renders. No I/O —
6
+ // server.js owns the polling (including the slow-cadence LB backend-health
7
+ // probe that fills `v.backendHealth`).
8
+ //
9
+ // The bug this fixes: a VM card rendered as idle with a CLICKABLE editor URL the
10
+ // moment the VM was rostered + SSH-tunnel-reachable, even when its public LB URL
11
+ // (vm-<N>.editor.<domain>) would return Envoy "no healthy upstream" (503). The
12
+ // card reflected only SSH-tunnel reachability to :14199, never whether the load
13
+ // balancer actually routes to a HEALTHY backend. Two situations cause the 503,
14
+ // and the card had zero visibility into either:
15
+ //
16
+ // transient half-wire — the editor is serving but the LB health check hasn't
17
+ // marked the backend HEALTHY yet during cold start. It
18
+ // resolves on its own; the card should say 'starting'.
19
+ // permanent half-wire — the per-VM LB backend service was never created (e.g.
20
+ // the Add ran under a service account lacking
21
+ // compute.backendServices.create), so the URL 503s
22
+ // forever until re-wired. The card should say
23
+ // 'half-wired', visibly distinct from 'starting'.
24
+ //
25
+ // SSH reachability (v.reachable from probeVM over the tunnel) is necessary but
26
+ // not sufficient: the tunnel is up in BOTH 503 cases. Only the LB backend health
27
+ // (gcloud compute backend-services get-health, gathered into v.backendHealth)
28
+ // distinguishes ready / starting / half-wired. The editor's own /healthz can't
29
+ // see the half-wire, so it is not enough on its own.
30
+ //
31
+ // 'provisioning' — an Add/recover job is in flight; the VM is still being
32
+ // built, so judging its public-URL readiness is premature.
33
+ // 'unreachable' — the editor did not answer over the tunnel. Nothing
34
+ // downstream (broker, LB health) is meaningful; the link is
35
+ // gated exactly as today.
36
+ // 'half-wired' — LB mode, the editor backend service is ABSENT (never
37
+ // created). The permanent 503 case — the public URL will fail
38
+ // until re-wired. The state that had NO signal before.
39
+ // 'starting' — LB mode, the editor backend exists but is UNHEALTHY (LB has
40
+ // not yet marked it HEALTHY). The transient 503 case; flips to
41
+ // 'ready' on its own once the health check passes.
42
+ // 'broker-down' — reachable + LB-healthy (or legacy mode), but the broker
43
+ // daemon is down, so the VM cannot start an agent session.
44
+ // 'ready' — reachable, broker up, and (LB mode) the editor backend is
45
+ // HEALTHY. The only state whose editor link is live + green.
46
+ //
47
+ // Legacy mode (FLEET_DOMAIN unset ⇒ ctx.lbMode falsey, or no backendHealth
48
+ // supplied): there is no LB to health-check, so the backend-health branch is
49
+ // skipped and readiness degrades to today's reachability/broker behavior — no
50
+ // regression, no gcloud calls.
51
+ const READINESS_STATES = [
52
+ 'provisioning',
53
+ 'unreachable',
54
+ 'half-wired',
55
+ 'starting',
56
+ 'broker-down',
57
+ 'ready',
58
+ ];
59
+
60
+ // A VM mid-provision: an Add (incl. its recovering re-run) that is queued
61
+ // (number reserved, cloud:up not started) or running. Its public URL has no
62
+ // meaningful readiness yet, so this takes precedence over every other signal —
63
+ // a freshly-reserved VM must not flash 'half-wired' before its backend exists.
64
+ function isProvisioning(job) {
65
+ if (!job) return false;
66
+ if (job.action === 'add' && (job.state === 'running' || job.state === 'queued')) return true;
67
+ if (job.recovering && job.state === 'running') return true;
68
+ return false;
69
+ }
70
+
71
+ // `v` is the merged poll state (state.vms[n]); `ctx` carries the derived values
72
+ // the card builder already has on hand:
73
+ // ctx.job — the active job (or null), for the provisioning check.
74
+ // ctx.brokerStatus — the debounced broker tri-state ('down'|'idle'|
75
+ // 'agent-live'|'unknown') the card already derives.
76
+ // ctx.lbMode — true when FLEET_DOMAIN is set (the LB fronts the URLs).
77
+ // When false, the backend-health branch is skipped.
78
+ function deriveVmReadiness(v, ctx) {
79
+ const c = ctx || {};
80
+ // 1. Still being built — don't judge a half-built VM as ready or half-wired.
81
+ if (isProvisioning(c.job)) return 'provisioning';
82
+ // 2. Editor unreachable over the tunnel: we polled nothing, so broker / LB
83
+ // health are unknowable. Link gated, exactly as the pre-readiness card did.
84
+ if (!v || !v.reachable) return 'unreachable';
85
+ // 3. LB mode only: the public URL's fate is the editor backend's LB health.
86
+ // Skipped entirely in legacy mode (no LB) or before the first probe lands
87
+ // (no backendHealth) — both fall through to the reachability/broker path.
88
+ const bh = v.backendHealth;
89
+ if (c.lbMode && bh) {
90
+ // Backend service never created ⇒ permanent 503 until re-wired.
91
+ if (bh.editor === 'absent') return 'half-wired';
92
+ // Backend exists but not yet HEALTHY ⇒ transient 503 during cold start.
93
+ if (bh.editor === 'unhealthy') return 'starting';
94
+ // 'healthy' falls through to ready; 'unknown' (get-health errored) is
95
+ // absence-of-evidence — never rendered as a problem, falls through too.
96
+ }
97
+ // 4. Reachable but the broker daemon is down: the VM can't start a session.
98
+ if (c.brokerStatus === 'down') return 'broker-down';
99
+ // 5. Reachable, broker up, and (LB mode) the editor backend is HEALTHY —
100
+ // genuinely ready: green, clickable, public URL actually routes.
101
+ return 'ready';
102
+ }
103
+
104
+ module.exports = { deriveVmReadiness, READINESS_STATES, isProvisioning };