@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -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 +51 -2
  10. package/npm/fleet-cleanup.js +118 -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 +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -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 +329 -50
  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-BuK0lizt.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-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -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 };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-vm-slugs.js — the per-VM custom-slug sidecar
4
+ // (`.codeyam/logs/fleet-vm-slugs.json`). A slug is an optional human-readable
5
+ // override for a VM's leftmost hostname label, so its public URLs become
6
+ // `<slug>.editor.<domain>` instead of the number-derived `vm-<N>.editor.<domain>`.
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 that
10
+ // bash consumers (`vm-urls.sh`, the watchdog) depend on. Rather than break that
11
+ // shape, slugs live in a SEPARATE map `{ "<N>": "<slug>" }`, fronted by a
12
+ // read+write seam modeled exactly on fleet-roster.js (atomic temp-file + rename,
13
+ // tolerant of a missing/malformed file).
14
+ //
15
+ // The slug is purely a public-hostname override — internal GCP resource names
16
+ // (NEG / backend service / path matcher) stay keyed off the VM number, so a VM
17
+ // added without a slug keeps its `vm-<N>` URLs unchanged.
18
+ //
19
+ // Env override: `VM_SLUGS_FILE` relocates the file (mirrors fleet-roster.js's
20
+ // `VMS_STATE_FILE`); resolved once at module load.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const { rootDir } = require('./utils');
26
+ const { normalizeVmNumber, normalizeVmSlug } = require('./vm-urls');
27
+
28
+ const VM_SLUGS_FILE = process.env.VM_SLUGS_FILE
29
+ || path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-slugs.json');
30
+
31
+ // Read the slug sidecar, returning a `{ [vmNumber]: slug }` object, or null if
32
+ // the file is missing or malformed (treated as "no slugs"). Keys are kept as the
33
+ // string form JSON emits; callers look them up by `String(n)`.
34
+ function readSlugs() {
35
+ try {
36
+ const data = JSON.parse(fs.readFileSync(VM_SLUGS_FILE, 'utf8'));
37
+ if (data && typeof data === 'object' && !Array.isArray(data)) return data;
38
+ } catch { /* missing or malformed */ }
39
+ return null;
40
+ }
41
+
42
+ // Persist `map` as the sidecar, atomically: write a temp file then `rename` it
43
+ // over the target so a concurrent reader never observes a half-written file. The
44
+ // temp name carries the pid so two processes writing at once don't clobber each
45
+ // other's temp file before their renames land. Returns the written map.
46
+ function writeSlugs(map) {
47
+ const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {};
48
+ fs.mkdirSync(path.dirname(VM_SLUGS_FILE), { recursive: true });
49
+ const tmp = `${VM_SLUGS_FILE}.${process.pid}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(next));
51
+ fs.renameSync(tmp, VM_SLUGS_FILE); // atomic replace
52
+ return next;
53
+ }
54
+
55
+ /** The slug mapped to VM `n`, or null when it has none (or the file is absent).
56
+ * This is the resolver every hostname derivation funnels through:
57
+ * `slugFor(n) || ('vm-' + n)`. */
58
+ function slugFor(n) {
59
+ const num = normalizeVmNumber(n);
60
+ const map = readSlugs();
61
+ const slug = map && map[String(num)];
62
+ return typeof slug === 'string' && slug.length > 0 ? slug : null;
63
+ }
64
+
65
+ /** The VM number currently mapped to `slug`, or null when no VM owns it. Used to
66
+ * reject a duplicate slug BEFORE it is assigned (a slug must be unique across
67
+ * the fleet, else two VMs would claim the same hostname). `slug` is normalized
68
+ * first so the comparison is case-insensitive. */
69
+ function vmForSlug(slug) {
70
+ const want = normalizeVmSlug(slug);
71
+ const map = readSlugs() || {};
72
+ for (const [k, v] of Object.entries(map)) {
73
+ if (typeof v === 'string' && v.toLowerCase() === want) {
74
+ const num = Number(k);
75
+ if (Number.isInteger(num) && num > 0) return num;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** Assign `slug` to VM `n`, validating the slug (via `normalizeVmSlug`) and
82
+ * rejecting a slug already mapped to a DIFFERENT VM. Returns the written map.
83
+ * Re-assigning a VM its own existing slug is an idempotent no-op-shaped write. */
84
+ function setSlug(n, slug) {
85
+ const num = normalizeVmNumber(n);
86
+ const norm = normalizeVmSlug(slug);
87
+ const owner = vmForSlug(norm);
88
+ if (owner != null && owner !== num) {
89
+ throw new Error(
90
+ `VM slug "${norm}" is already assigned to VM-${owner} — pick another name`,
91
+ );
92
+ }
93
+ const map = { ...(readSlugs() || {}) };
94
+ map[String(num)] = norm;
95
+ return writeSlugs(map);
96
+ }
97
+
98
+ /** Remove VM `n`'s slug mapping, returning the new map. Removing an absent entry
99
+ * is a no-op. Called on Destroy so a number freed and later re-used doesn't
100
+ * inherit a stale slug. */
101
+ function removeSlug(n) {
102
+ const num = normalizeVmNumber(n);
103
+ const map = { ...(readSlugs() || {}) };
104
+ delete map[String(num)];
105
+ return writeSlugs(map);
106
+ }
107
+
108
+ module.exports = {
109
+ VM_SLUGS_FILE,
110
+ readSlugs,
111
+ writeSlugs,
112
+ slugFor,
113
+ vmForSlug,
114
+ setSlug,
115
+ removeSlug,
116
+ };
@@ -0,0 +1,211 @@
1
+ 'use strict';
2
+ // Deterministic Google Cloud HTTPS-LB resource-name derivation for the fleet.
3
+ //
4
+ // The fleet's public browser ingress moves from cloudflared named tunnels to a
5
+ // single Google external HTTPS Load Balancer (one URL map, host-based routing).
6
+ // Reaching a specific port on a specific VM needs a zonal NEG + backend service
7
+ // per (VM, surface); the URL map then routes each `vm-<N>.<surface>.<domain>`
8
+ // host to that backend. This module is the single source of truth for those
9
+ // Google resource NAMES so the bash provisioners (scripts/gcp/google-ingress.sh,
10
+ // scripts/gcp/vm-ingress.sh) and any node caller derive identical strings and a
11
+ // unit test guards them against drift — exactly the contract npm/vm-urls.js and
12
+ // npm/launch-config.js follow.
13
+ //
14
+ // Pure logic only — no I/O, no env reads beyond the explicit `env` argument a
15
+ // caller passes in, so it stays unit-testable in isolation
16
+ // (npm/google-ingress.test.js). The HOSTNAMES it routes to are reused verbatim
17
+ // from npm/vm-urls.js (vmHostnames / dashboardHostname): the Google migration is
18
+ // a serving-layer swap, not a URL-scheme change, so hostnames must never drift
19
+ // from the cert/DNS that vm-urls.js already defines.
20
+ //
21
+ // Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that
22
+ // runs codeyam-editor itself), not a per-app-stack assumption. A project that
23
+ // never joins a Google fleet never sets FLEET_DOMAIN / FLEET_INGRESS=google and
24
+ // never derives a name here; the legacy cloudflared path stays the fallback.
25
+
26
+ const {
27
+ normalizeVmNumber,
28
+ vmHostnames,
29
+ dashboardHostname,
30
+ } = require('./vm-urls');
31
+
32
+ // The two per-VM ingress surfaces. `editor` fronts the in-VM editor control API
33
+ // (a fixed port); `preview` fronts the VM's dev server (a per-project port).
34
+ // Keep this list as the single definition both name derivation and the host-rule
35
+ // builder iterate, so a third surface is one edit, not three.
36
+ const SURFACES = Object.freeze(['editor', 'preview']);
37
+
38
+ // The in-VM editor control API port. Fixed across the fleet (matches
39
+ // named-tunnel-config.sh's EDITOR_PORT default and cloud.js's `-L …:14199`), so
40
+ // the editor backend NEG always targets this port regardless of project.
41
+ const EDITOR_PORT = 14199;
42
+
43
+ /** The fixed in-VM editor control-API port the editor backend NEG targets. A
44
+ * function (not a bare constant export) so callers read it the same way they
45
+ * read the per-VM appPort — a uniform "resolve the port for this surface" shape. */
46
+ function editorPort() {
47
+ return EDITOR_PORT;
48
+ }
49
+
50
+ /** Validate an ingress surface against SURFACES. The NEG / backend-service /
51
+ * host-rule derivations all key off it, so a typo'd surface would mint a
52
+ * resource that nothing routes to — reject it loudly at the boundary, mirroring
53
+ * how vm-urls.js rejects a bad VM number or domain. Returns the surface. */
54
+ function normalizeSurface(surface) {
55
+ if (!SURFACES.includes(surface)) {
56
+ throw new Error(
57
+ `surface must be one of ${SURFACES.join('|')}, got: ${surface}`,
58
+ );
59
+ }
60
+ return surface;
61
+ }
62
+
63
+ /** The zonal NEG name for a (VM, surface): `vm-<N>-<surface>-neg`. One network
64
+ * endpoint group per surface points at the VM's internal IP + that surface's
65
+ * port — the Google-native analog of a cloudflared ingress rule's local origin. */
66
+ function negName(n, surface) {
67
+ const num = normalizeVmNumber(n);
68
+ return `vm-${num}-${normalizeSurface(surface)}-neg`;
69
+ }
70
+
71
+ /** The backend-service name for a (VM, surface): `vm-<N>-<surface>-bes`. One
72
+ * backend service per host rule wraps the matching NEG (with IAP + a health
73
+ * check); the URL map's host rule points at this name. */
74
+ function backendServiceName(n, surface) {
75
+ const num = normalizeVmNumber(n);
76
+ return `vm-${num}-${normalizeSurface(surface)}-bes`;
77
+ }
78
+
79
+ /** The URL-map host rules for a VM: one `{ host, surface, backendService }` per
80
+ * surface, where `host` is the EXACT hostname vm-urls.js derives (so the LB
81
+ * routes the same hostnames the cert covers and DNS resolves) and
82
+ * `backendService` is this module's name for that surface. Reusing
83
+ * `vmHostnames` rather than re-deriving keeps the hostname scheme single-sourced.
84
+ * Returns the surfaces in SURFACES order so the provisioner's output is stable.
85
+ *
86
+ * An optional `slug` overrides the routed `host` with the VM's custom hostname
87
+ * (`<slug>.<surface>.<domain>`) while the NEG / `backendService` names stay
88
+ * keyed off the VM NUMBER — the slug changes only what the URL map matches, not
89
+ * the internal GCP resource names. */
90
+ function urlMapHostRules(n, domain, slug) {
91
+ const hosts = vmHostnames(n, domain, slug); // also validates n, domain, slug
92
+ return SURFACES.map((surface) => ({
93
+ host: hosts[surface],
94
+ surface,
95
+ backendService: backendServiceName(n, surface),
96
+ }));
97
+ }
98
+
99
+ /** Decide which gcloud URL-map operation reconciles a single `(host,
100
+ * pathMatcher)` against the map's CURRENT state, given `hostRules` — the
101
+ * flattened `[{ host, pathMatcher }]` projection of the URL map's host rules
102
+ * (one entry per host, since one host rule can list several hosts). Returns
103
+ * `{ op }`, one of:
104
+ * - `skip` — `host` already routes to `pathMatcher`; nothing to do.
105
+ * - `add-host-rule` — `pathMatcher` already exists (some other host points
106
+ * at it, e.g. the numeric `vmN.<surface>` host) but
107
+ * `host` is not attached: attach it. Fixes the
108
+ * `Duplicate path matcher` failure on a reused slug.
109
+ * - `repoint` — `host` exists but aims at a DIFFERENT matcher (a stale
110
+ * rule left by a destroyed VM): remove it, then attach
111
+ * `host` to the correct matcher. Fixes a reused slug
112
+ * silently skipped as "already configured" while it
113
+ * still routed to a dead backend.
114
+ * - `add-path-matcher`— neither `host` nor `pathMatcher` is present: create
115
+ * the matcher and attach the host (the original path).
116
+ *
117
+ * Pure and side-effect-free — no gcloud, no I/O — so vm-ingress.sh can read the
118
+ * URL-map state in bash, ask this helper for the op, and run the matching
119
+ * gcloud command, keeping the four-way decision unit-testable (the same
120
+ * bash↔node contract the name derivations above follow). Tolerates a
121
+ * missing/empty `hostRules`. */
122
+ function hostRuleOperation({ hostRules, host, pathMatcher } = {}) {
123
+ if (!host) throw new Error('hostRuleOperation requires a host');
124
+ if (!pathMatcher) throw new Error('hostRuleOperation requires a pathMatcher');
125
+ const rules = Array.isArray(hostRules) ? hostRules : [];
126
+ const existing = rules.find((r) => r && r.host === host);
127
+ if (existing) {
128
+ return { op: existing.pathMatcher === pathMatcher ? 'skip' : 'repoint' };
129
+ }
130
+ const matcherExists = rules.some((r) => r && r.pathMatcher === pathMatcher);
131
+ return { op: matcherExists ? 'add-host-rule' : 'add-path-matcher' };
132
+ }
133
+
134
+ /** Decide whether a NEG must be attached as a backend to its backend service,
135
+ * given `backends` — the BES's CURRENT `backends[].group` self-links (what
136
+ * `gcloud compute backend-services describe --format='value(backends[].group)'`
137
+ * emits) — and `neg`, the bare NEG name this surface expects. Returns `{ op }`:
138
+ * - `skip` — some current backend already resolves to `neg`; nothing to do.
139
+ * - `attach` — `neg` is not yet a backend (an empty BES, or one wired to a
140
+ * different group): `add-backend` it.
141
+ *
142
+ * `backends[].group` is a full self-link
143
+ * (`.../networkEndpointGroups/vm-3-editor-neg`), so the match compares by
144
+ * basename — the bare NEG name — not the whole URL. Pure and side-effect-free
145
+ * (no gcloud, no I/O), so vm-ingress.sh reads the BES's backends in bash, asks
146
+ * this helper for the op, and `add-backend`s only when it returns `attach`: the
147
+ * same bash↔node contract `hostRuleOperation` follows. This is what makes `up`
148
+ * *converge* a BES that pre-exists with zero backends (the permanent-503 empty-
149
+ * backend bug) instead of skipping the attach as "already configured". Throws on
150
+ * a missing `neg` (mirrors `hostRuleOperation`'s guard); tolerates a
151
+ * missing/empty `backends`. */
152
+ function backendNegAttachOperation({ backends, neg } = {}) {
153
+ if (!neg) throw new Error('backendNegAttachOperation requires a neg');
154
+ const groups = Array.isArray(backends) ? backends : [];
155
+ const basename = (g) => String(g).split('/').pop();
156
+ const attached = groups.some((g) => g && basename(g) === neg);
157
+ return { op: attached ? 'skip' : 'attach' };
158
+ }
159
+
160
+ /** The dashboard's backend-service name: `dashboard-bes`. The dashboard is a
161
+ * single fleet-wide surface (not per-VM), so its name carries no VM number. The
162
+ * backend itself is only fully wired once the dashboard moves onto GCE
163
+ * (vm-dashboard-on-gce), but the name is defined here so the shared provisioner
164
+ * and that dependent plan agree. */
165
+ function dashboardBackendServiceName() {
166
+ return 'dashboard-bes';
167
+ }
168
+
169
+ /** The dashboard's zonal NEG name: `dashboard-neg`. One network endpoint group
170
+ * points at the operator host's internal IP + the dashboard port (8787) — the
171
+ * Google-native analog of the dashboard's old ngrok upstream. Single fleet-wide
172
+ * surface, so (like the backend-service name) it carries no VM number. Wired by
173
+ * scripts/gcp/dashboard-ingress.sh once the dashboard moves onto GCE. */
174
+ function dashboardNegName() {
175
+ return 'dashboard-neg';
176
+ }
177
+
178
+ /** The dashboard's dedicated health-check name: `dashboard-hc`. The per-VM
179
+ * surfaces share `fleet-ingress-hc` (port 14199), but the dashboard backend
180
+ * health-checks its own port (8787, path /healthz), so it needs its own check
181
+ * rather than reusing the editor-port one. */
182
+ function dashboardHealthCheckName() {
183
+ return 'dashboard-hc';
184
+ }
185
+
186
+ /** The dashboard's single URL-map host rule: `{ host, backendService }`, where
187
+ * `host` is `dashboard.<domain>` from vm-urls.js. Separate from
188
+ * `urlMapHostRules` because the dashboard is fleet-wide, not keyed off a VM
189
+ * number. */
190
+ function dashboardHostRule(domain) {
191
+ return {
192
+ host: dashboardHostname(domain), // validates domain
193
+ backendService: dashboardBackendServiceName(),
194
+ };
195
+ }
196
+
197
+ module.exports = {
198
+ SURFACES,
199
+ EDITOR_PORT,
200
+ editorPort,
201
+ normalizeSurface,
202
+ negName,
203
+ backendServiceName,
204
+ urlMapHostRules,
205
+ hostRuleOperation,
206
+ backendNegAttachOperation,
207
+ dashboardBackendServiceName,
208
+ dashboardNegName,
209
+ dashboardHealthCheckName,
210
+ dashboardHostRule,
211
+ };