@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,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
+ };
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+ // Pure builders for the LOCAL shell invocations that wire the fleet's Google
3
+ // HTTPS Load Balancer ingress: the shared, VM-independent infra
4
+ // (scripts/gcp/google-ingress.sh) and one VM's per-surface backends
5
+ // (scripts/gcp/vm-ingress.sh). cloud.js calls these to decide WHEN to wire and
6
+ // to build the exact `{ command, args, env }` it spawns — keeping the decision
7
+ // and the argv/env shape unit-testable without ever spawning a process, exactly
8
+ // like npm/vm-urls.js and npm/google-ingress.js single-source their derivations.
9
+ //
10
+ // Pure logic only — no I/O, no env reads beyond the explicit `env`/`cfg`
11
+ // arguments callers pass in. The fleet domain governing ingress comes from the
12
+ // project's `.codeyam/cloud.json` (`fleetDomain`, persisted by cloud.js) with a
13
+ // `FLEET_DOMAIN` env fallback; when neither is set every builder reports the
14
+ // legacy quick-tunnel mode (resolveIngressDomain → null) and cloud.js leaves the
15
+ // Add/Destroy path byte-for-byte unchanged.
16
+ //
17
+ // Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that
18
+ // runs codeyam-editor itself), GCP-specific by design — the same posture as the
19
+ // scripts it invokes. A project that never joins the fleet never sets
20
+ // fleetDomain and never reaches a builder here.
21
+
22
+ const { resolveFleetDomain } = require('./vm-urls');
23
+
24
+ // Cloud DNS managed-zone name google-ingress.sh defaults to. Mirrored here so a
25
+ // cloud.json without an explicit `dnsZone` resolves to the same zone the script
26
+ // would, rather than passing an empty DNS_ZONE that shadows the script default.
27
+ const DEFAULT_DNS_ZONE = 'fleet-zone';
28
+
29
+ /** Resolve the fleet domain that governs ingress wiring, or null when none is
30
+ * configured (legacy quick-tunnel mode). `cfg.fleetDomain` (persisted in
31
+ * cloud.json) wins; the `FLEET_DOMAIN` env value is the fallback. Both paths go
32
+ * through vm-urls.js::resolveFleetDomain, so a present-but-malformed value
33
+ * throws here (a typo'd domain must fail loud, never bake a dead host into the
34
+ * URL map) and a blank value reads as "unset". */
35
+ function resolveIngressDomain(cfg = {}, env = {}) {
36
+ const fromCfg = cfg.fleetDomain;
37
+ if (fromCfg != null && String(fromCfg).trim() !== '') {
38
+ return resolveFleetDomain({ FLEET_DOMAIN: fromCfg });
39
+ }
40
+ return resolveFleetDomain(env);
41
+ }
42
+
43
+ /** Resolve the Cloud DNS managed-zone name: `cfg.dnsZone`, then a `DNS_ZONE`
44
+ * env override, then the `fleet-zone` default google-ingress.sh itself uses. */
45
+ function resolveDnsZone(cfg = {}, env = {}) {
46
+ const fromCfg = cfg.dnsZone;
47
+ if (fromCfg != null && String(fromCfg).trim() !== '') return String(fromCfg).trim();
48
+ const fromEnv = env.DNS_ZONE;
49
+ if (fromEnv != null && String(fromEnv).trim() !== '') return String(fromEnv).trim();
50
+ return DEFAULT_DNS_ZONE;
51
+ }
52
+
53
+ /** Derive the GCE region from a zone (`us-central1-a` → `us-central1`), or null
54
+ * when the zone is missing/unparseable so the caller omits REGION and lets
55
+ * google-ingress.sh apply its own default. A GCE zone is a region plus a
56
+ * trailing `-<letter>`. */
57
+ function regionFromZone(zone) {
58
+ if (zone == null) return null;
59
+ const z = String(zone).trim();
60
+ if (z === '') return null;
61
+ const m = /^(.+)-[a-z]$/.exec(z);
62
+ return m ? m[1] : null;
63
+ }
64
+
65
+ /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> <up|down>` —
66
+ * one VM's per-surface NEG → IAP backend → URL-map host rule. Returns
67
+ * `{ command, args, env }`; `env` carries only the keys the script reads beyond
68
+ * its own defaults (GCP_PROJECT, ZONE, FLEET_DOMAIN, APP_PORT, INSTANCE_PREFIX,
69
+ * VM_SLUG). `appPort` is the resolved per-project dev-server port the preview
70
+ * NEG targets; omit it (down, or unknown) to let the script's bootstrap default
71
+ * stand. An optional `slug` becomes `VM_SLUG`, which makes the script route the
72
+ * VM's custom `<slug>.<surface>.<domain>` host (the same slug must be passed on
73
+ * `down` so the matching host rule is removed); omit it to keep the numeric
74
+ * `vm-<N>` host. */
75
+ function vmIngressInvocation({
76
+ scriptPath,
77
+ vmNumber,
78
+ action,
79
+ domain,
80
+ project,
81
+ zone,
82
+ appPort,
83
+ instancePrefix,
84
+ slug,
85
+ } = {}) {
86
+ if (!scriptPath) throw new Error('vmIngressInvocation requires scriptPath');
87
+ if (action !== 'up' && action !== 'down') {
88
+ throw new Error(`vm-ingress action must be 'up' or 'down', got: ${action}`);
89
+ }
90
+ const n = Number(vmNumber);
91
+ if (!Number.isInteger(n) || n <= 0) {
92
+ throw new Error(`vm-ingress requires a positive VM number, got: ${vmNumber}`);
93
+ }
94
+ if (!domain) throw new Error('vmIngressInvocation requires a fleet domain');
95
+ const env = { FLEET_DOMAIN: domain };
96
+ if (project) env.GCP_PROJECT = project;
97
+ if (zone) env.ZONE = zone;
98
+ if (appPort != null && String(appPort).trim() !== '') env.APP_PORT = String(appPort);
99
+ if (instancePrefix) env.INSTANCE_PREFIX = instancePrefix;
100
+ if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
101
+ return { command: 'bash', args: [scriptPath, String(n), action], env };
102
+ }
103
+
104
+ /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> status` — the
105
+ * read-only probe that reports which of {NEG, backend service, host rule} exist
106
+ * per surface plus an overall `verdict=wired|partial|unwired`. Returns
107
+ * `{ command, args, env }`. Like `vmIngressInvocation` but the action is fixed
108
+ * to `status` and APP_PORT is omitted — status describes resources by NAME, and
109
+ * names key off the VM number, never the port. An optional `slug` rides through
110
+ * as VM_SLUG so the host-rule presence check matches the VM's custom
111
+ * `<slug>.<surface>.<domain>` host rather than the numeric `vm-<N>` one. */
112
+ function vmIngressStatusInvocation({
113
+ scriptPath,
114
+ vmNumber,
115
+ domain,
116
+ project,
117
+ zone,
118
+ slug,
119
+ } = {}) {
120
+ if (!scriptPath) throw new Error('vmIngressStatusInvocation requires scriptPath');
121
+ const n = Number(vmNumber);
122
+ if (!Number.isInteger(n) || n <= 0) {
123
+ throw new Error(`vm-ingress status requires a positive VM number, got: ${vmNumber}`);
124
+ }
125
+ if (!domain) throw new Error('vmIngressStatusInvocation requires a fleet domain');
126
+ const env = { FLEET_DOMAIN: domain };
127
+ if (project) env.GCP_PROJECT = project;
128
+ if (zone) env.ZONE = zone;
129
+ if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
130
+ return { command: 'bash', args: [scriptPath, String(n), 'status'], env };
131
+ }
132
+
133
+ /** Parse the `verdict=<wired|partial|unwired>` line vm-ingress.sh `status`
134
+ * prints, returning that verdict or null when the output carries none (e.g. the
135
+ * script errored before emitting it). Pure string parsing so the reconcile
136
+ * decision below stays unit-testable without spawning the script. */
137
+ function parseIngressStatusVerdict(stdout) {
138
+ if (typeof stdout !== 'string') return null;
139
+ for (const line of stdout.split('\n')) {
140
+ const m = /^verdict=(wired|partial|unwired)\s*$/.exec(line.trim());
141
+ if (m) return m[1];
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** Pick which rostered VMs reconcile must re-wire from their status verdicts.
147
+ * `statuses` is `[{ vmNumber, verdict, ... }]`; a VM is selected iff its verdict
148
+ * is `unwired` or `partial` (it has missing LB resources). `wired` VMs are left
149
+ * untouched — no redundant gcloud churn — and an unknown/null verdict (the probe
150
+ * failed) is conservatively SKIPPED rather than blindly re-wired. Returns the
151
+ * selected entries in their input order, preserving any extra fields (slug) the
152
+ * caller threads through. Pure. */
153
+ function selectIngressReconcileTargets(statuses) {
154
+ if (!Array.isArray(statuses)) return [];
155
+ return statuses.filter((s) => s && (s.verdict === 'unwired' || s.verdict === 'partial'));
156
+ }
157
+
158
+ // =============================================================================
159
+ // Async add-time wiring (vm--async-add-time-lb-ingress-wiring)
160
+ // =============================================================================
161
+ // The Add used to BLOCK ~10 min on synchronous LB ingress wiring even after the
162
+ // container was healthy and serving. The fix returns the Add at container-healthy
163
+ // and converges the wiring in a DETACHED background tail (wire → access, the same
164
+ // ordering cmdUp ran inline) that outlives `cloud:up` — so the dashboard's reap of
165
+ // `cloud:up` can never SIGTERM an in-flight `backend-services create` (the add-time
166
+ // 503 strand the old 900s synchronous await was protecting against). The
167
+ // already-existing per-VM auto-rewire gather is the convergence backstop; it must
168
+ // NOT race the in-flight detached tail, so the tail drops an in-flight marker the
169
+ // dashboard reads via the pure `isWiringInflight` below.
170
+
171
+ /** Build the local invocation that re-execs THIS node entry (`scriptPath`, i.e.
172
+ * cloud.js) as a DETACHED `wire-add-tail` child: it runs the slow wiring + access
173
+ * grant in the background so the Add returns at container-healthy. Returns
174
+ * `{ command, args, env }`; the child reads `cfg`/`appPort` back out of the env
175
+ * (`CY_WIRE_ADD_CFG` JSON, `CY_WIRE_ADD_PORT`) rather than re-deriving them, so the
176
+ * background tail wires EXACTLY what the foreground Add would have. `nodePath` is
177
+ * the node executable to re-exec (caller passes `process.execPath`); kept a
178
+ * parameter so this stays pure/testable without reading the ambient env. */
179
+ function detachedAddTailInvocation({ nodePath, scriptPath, cfg, appPort } = {}) {
180
+ if (!nodePath) throw new Error('detachedAddTailInvocation requires nodePath');
181
+ if (!scriptPath) throw new Error('detachedAddTailInvocation requires scriptPath');
182
+ const env = { CY_WIRE_ADD_CFG: JSON.stringify(cfg || {}) };
183
+ if (appPort != null && String(appPort).trim() !== '') {
184
+ env.CY_WIRE_ADD_PORT = String(appPort);
185
+ }
186
+ return { command: nodePath, args: [scriptPath, 'wire-add-tail'], env };
187
+ }
188
+
189
+ /** The per-VM marker file the detached add-tail holds while its wiring is still
190
+ * running, so the dashboard's auto-rewire gather skips a VM whose FIRST wiring
191
+ * attempt is in flight (never firing a competing `vm-ingress.sh up`). Pure path
192
+ * derivation; the create/unlink/stat I/O lives at the call sites (cloud.js writes
193
+ * it, server.js reads it), mirroring how the status/verdict split keeps the
194
+ * decision testable without touching the filesystem. */
195
+ function ingressInflightMarkerPath(dir, vmNumber) {
196
+ const n = Number(vmNumber);
197
+ if (!Number.isInteger(n) || n <= 0) {
198
+ throw new Error(`ingressInflightMarkerPath requires a positive VM number, got: ${vmNumber}`);
199
+ }
200
+ return `${dir.replace(/\/+$/, '')}/cy-ingress-inflight-vm-${n}`;
201
+ }
202
+
203
+ /** Decide whether a freshly-added VM's detached wiring is still in flight, given
204
+ * the marker file's CONTENTS (the unix-ms start timestamp the tail wrote) and the
205
+ * current time. Returns false when the marker is missing/empty (`content` null) —
206
+ * no tail running — or STALE (older than `maxAgeMs`): a tail that crashed without
207
+ * unlinking its marker must not pin auto-rewire off forever, so once the staleness
208
+ * bound (comfortably past the ~10-min worst-case wiring) elapses the backstop takes
209
+ * over. Pure: the caller does the `readFileSync`/`existsSync`. */
210
+ function isWiringInflight(content, now, maxAgeMs) {
211
+ if (content == null) return false;
212
+ const started = Number(String(content).trim());
213
+ if (!Number.isFinite(started) || started <= 0) return false;
214
+ return (Number(now) - started) < Number(maxAgeMs);
215
+ }
216
+
217
+ /** Build the local invocation for `scripts/gcp/iap-access.sh <N> <grant|revoke>`
218
+ * — grant or revoke `roles/iap.httpsResourceAccessor` on one VM's per-surface
219
+ * backend services for a set of principals (the per-VM access whitelist).
220
+ * Returns `{ command, args, env }`; `env.VM_ACCESS` is the comma-joined,
221
+ * trimmed principal list the script reads (omitted when empty, so a revoke with
222
+ * nothing to remove is a clean no-op), plus GCP_PROJECT / ZONE when supplied.
223
+ * Mirrors `vmIngressInvocation` so the spawn shape stays unit-testable without
224
+ * ever invoking gcloud. The principal STRINGS are passed through verbatim —
225
+ * validation/normalization is the sidecar's job (npm/fleet-vm-access.js); this
226
+ * builder only shapes the argv/env. */
227
+ function iapAccessInvocation({
228
+ scriptPath,
229
+ vmNumber,
230
+ action,
231
+ principals,
232
+ project,
233
+ zone,
234
+ } = {}) {
235
+ if (!scriptPath) throw new Error('iapAccessInvocation requires scriptPath');
236
+ if (action !== 'grant' && action !== 'revoke') {
237
+ throw new Error(`iap-access action must be 'grant' or 'revoke', got: ${action}`);
238
+ }
239
+ const n = Number(vmNumber);
240
+ if (!Number.isInteger(n) || n <= 0) {
241
+ throw new Error(`iap-access requires a positive VM number, got: ${vmNumber}`);
242
+ }
243
+ const list = Array.isArray(principals)
244
+ ? principals
245
+ : String(principals == null ? '' : principals).split(',');
246
+ const joined = list.map((p) => String(p).trim()).filter(Boolean).join(',');
247
+ const env = {};
248
+ if (joined) env.VM_ACCESS = joined;
249
+ if (project) env.GCP_PROJECT = project;
250
+ if (zone) env.ZONE = zone;
251
+ return { command: 'bash', args: [scriptPath, String(n), action], env };
252
+ }
253
+
254
+ /** Build the local invocation for `scripts/gcp/google-ingress.sh` — the shared,
255
+ * VM-independent LB infra (static IP, wildcard cert, Cloud DNS, URL map, IAP
256
+ * brand). Returns `{ command, args, env }`; `env` carries only the keys the
257
+ * script reads beyond its defaults (GCP_PROJECT, REGION, ZONE, FLEET_DOMAIN,
258
+ * DNS_ZONE). The script is idempotent, so re-invoking against existing infra is
259
+ * a safe no-op. */
260
+ function sharedIngressInvocation({
261
+ scriptPath,
262
+ domain,
263
+ project,
264
+ zone,
265
+ dnsZone,
266
+ region,
267
+ } = {}) {
268
+ if (!scriptPath) throw new Error('sharedIngressInvocation requires scriptPath');
269
+ if (!domain) throw new Error('sharedIngressInvocation requires a fleet domain');
270
+ const env = { FLEET_DOMAIN: domain };
271
+ if (project) env.GCP_PROJECT = project;
272
+ if (region) env.REGION = region;
273
+ if (zone) env.ZONE = zone;
274
+ if (dnsZone) env.DNS_ZONE = dnsZone;
275
+ return { command: 'bash', args: [scriptPath], env };
276
+ }
277
+
278
+ module.exports = {
279
+ DEFAULT_DNS_ZONE,
280
+ resolveIngressDomain,
281
+ resolveDnsZone,
282
+ regionFromZone,
283
+ vmIngressInvocation,
284
+ vmIngressStatusInvocation,
285
+ parseIngressStatusVerdict,
286
+ selectIngressReconcileTargets,
287
+ detachedAddTailInvocation,
288
+ ingressInflightMarkerPath,
289
+ isWiringInflight,
290
+ iapAccessInvocation,
291
+ sharedIngressInvocation,
292
+ };