@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.
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +1415 -73
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +51 -2
- package/npm/fleet-cleanup.js +118 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +135 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +204 -3
- package/npm/scenario-check.js +603 -32
- package/npm/scenario-handlers.js +78 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +329 -50
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
|
@@ -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
|
+
};
|
package/npm/launch-config.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// and past launches one click to re-run.
|
|
10
10
|
|
|
11
11
|
const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
|
|
12
|
+
const { normalizeVmSlug } = require("./vm-urls");
|
|
13
|
+
const { normalizeAccessList } = require("./fleet-vm-access");
|
|
12
14
|
|
|
13
15
|
/** The build agents a VM's editor terminal can run, as the keys of cloud.js's
|
|
14
16
|
* AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
|
|
@@ -28,8 +30,30 @@ const EMPTY_CONFIG = Object.freeze({
|
|
|
28
30
|
// Build agent provider the VM's editor terminal runs (claude | codex | gemini).
|
|
29
31
|
// Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
|
|
30
32
|
agent: "claude",
|
|
33
|
+
// Optional human-readable hostname slug for a SINGLE VM. When set, cloudUpArgs
|
|
34
|
+
// forwards it as cloud:up's --vm-slug flag so the VM's public URLs become
|
|
35
|
+
// `<slug>.editor.<domain>`. Empty ⇒ the numeric vm-<N> scheme (unchanged).
|
|
36
|
+
vmSlug: "",
|
|
37
|
+
// Optional per-VM IAP access whitelist for a SINGLE VM: a comma-separated list
|
|
38
|
+
// of principals (user:/group:/domain:, a bare email defaults to user:) granted
|
|
39
|
+
// resource-level access to this VM's surfaces. cloudUpArgs forwards it as
|
|
40
|
+
// cloud:up's --vm-access flag. Empty ⇒ only the owner's project-wide grant.
|
|
41
|
+
vmAccess: "",
|
|
31
42
|
});
|
|
32
43
|
|
|
44
|
+
/** Collapse a free-text access list (the add-form textarea, where the operator
|
|
45
|
+
* may separate principals by newlines or commas) into the canonical
|
|
46
|
+
* comma-separated string cloud:up's --vm-access expects. Pure + never throws —
|
|
47
|
+
* shape validation is validateLaunchConfig's job. */
|
|
48
|
+
function normalizeAccessText(raw) {
|
|
49
|
+
if (typeof raw !== "string") return "";
|
|
50
|
+
return raw
|
|
51
|
+
.split(/[\n,]+/)
|
|
52
|
+
.map((s) => s.trim())
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join(",");
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
/** Coerce a raw form/query object into the canonical launch-config shape: trim
|
|
34
58
|
* strings, default the variant to a known value. Never throws — validation is
|
|
35
59
|
* a separate, explicit step. */
|
|
@@ -47,6 +71,8 @@ function normalizeLaunchConfig(input = {}) {
|
|
|
47
71
|
editorVariant,
|
|
48
72
|
editorBranch: str(input.editorBranch),
|
|
49
73
|
agent,
|
|
74
|
+
vmSlug: str(input.vmSlug),
|
|
75
|
+
vmAccess: normalizeAccessText(input.vmAccess),
|
|
50
76
|
};
|
|
51
77
|
}
|
|
52
78
|
|
|
@@ -70,6 +96,26 @@ function validateLaunchConfig(cfg) {
|
|
|
70
96
|
if (!EDITOR_VARIANTS.includes(cfg.editorVariant)) {
|
|
71
97
|
return { ok: false, msg: `unknown editor variant: ${cfg.editorVariant}` };
|
|
72
98
|
}
|
|
99
|
+
// A custom slug, when supplied, must be a single DNS label (same rule cloud:up
|
|
100
|
+
// enforces) — surface a friendly reason here rather than letting the spawned
|
|
101
|
+
// job fail loud later. Empty ⇒ numeric vm-<N>, no check.
|
|
102
|
+
if (cfg.vmSlug) {
|
|
103
|
+
try {
|
|
104
|
+
normalizeVmSlug(cfg.vmSlug);
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { ok: false, msg: e.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// A per-VM access whitelist, when supplied, must be a list of valid principals
|
|
110
|
+
// (same rule cloud:up enforces) — surface a friendly reason here rather than
|
|
111
|
+
// letting the spawned job fail loud later. Empty ⇒ no check.
|
|
112
|
+
if (cfg.vmAccess) {
|
|
113
|
+
try {
|
|
114
|
+
normalizeAccessList(cfg.vmAccess);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return { ok: false, msg: e.message };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
73
119
|
return { ok: true };
|
|
74
120
|
}
|
|
75
121
|
|
|
@@ -123,6 +169,18 @@ function variantImageTag(variant) {
|
|
|
123
169
|
return null; // dev / unknown — no packaged image
|
|
124
170
|
}
|
|
125
171
|
|
|
172
|
+
/** Return true if the value matches a known test-fixture placeholder (e.g., "gcp-proj"),
|
|
173
|
+
* is null, empty, or whitespace, signaling that it is not a valid GCP project name.
|
|
174
|
+
* Documented as the home for future fixture sentinels. Pure; no I/O. */
|
|
175
|
+
function isPlaceholderProject(value) {
|
|
176
|
+
if (!value) return true;
|
|
177
|
+
if (typeof value !== "string") return true;
|
|
178
|
+
const trimmed = value.trim();
|
|
179
|
+
if (trimmed === "") return true;
|
|
180
|
+
if (trimmed.toLowerCase() === "gcp-proj") return true;
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
126
184
|
/** Build the `cloud:up` flag array for a launch config + instance name. The
|
|
127
185
|
* editor source is the canonical codeyam-editor repo (dev variant builds the
|
|
128
186
|
* image from editorBranch); the client repo/branch is what lands in
|
|
@@ -137,9 +195,10 @@ function cloudUpArgs(cfg, instanceName, project) {
|
|
|
137
195
|
// a persisted .codeyam/cloud.json `project` (cloud.js merges
|
|
138
196
|
// `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
|
|
139
197
|
// gcloud fallback — the sticky-wrong-project failure mode where a stale
|
|
140
|
-
//
|
|
141
|
-
// PERMISSION_DENIED. Omitted when unset to
|
|
142
|
-
// (cloud.js then falls back to the gcloud
|
|
198
|
+
// placeholder in cloud.json (validated via isPlaceholderProject) wedges every
|
|
199
|
+
// re-provision with a firewall-create PERMISSION_DENIED. Omitted when unset to
|
|
200
|
+
// preserve the pre-existing arg shape (cloud.js then falls back to the gcloud
|
|
201
|
+
// default, as before).
|
|
143
202
|
if (project) args.push("--project", project);
|
|
144
203
|
// Greenfield (new folder) vs clone an existing repo.
|
|
145
204
|
if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
|
|
@@ -147,6 +206,15 @@ function cloudUpArgs(cfg, instanceName, project) {
|
|
|
147
206
|
args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
|
|
148
207
|
if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);
|
|
149
208
|
if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);
|
|
209
|
+
// Forward an optional custom hostname slug. Only single-VM Adds carry one (the
|
|
210
|
+
// dashboard strips it for batch count > 1 — N VMs can't share one hostname), so
|
|
211
|
+
// cloudUpArgs simply emits it when present and trusts the caller's batch guard.
|
|
212
|
+
if (cfg.vmSlug) args.push("--vm-slug", cfg.vmSlug);
|
|
213
|
+
// Forward an optional per-VM access whitelist. Like the slug, only single-VM
|
|
214
|
+
// Adds carry one (the dashboard strips it for batch count > 1 — the same guard
|
|
215
|
+
// actionAdd applies to vmSlug), so cloudUpArgs emits it when present and trusts
|
|
216
|
+
// the caller's batch guard.
|
|
217
|
+
if (cfg.vmAccess) args.push("--vm-access", cfg.vmAccess);
|
|
150
218
|
// The dashboard opens its own per-VM tunnel, so cloud:up must NOT open one:
|
|
151
219
|
// it lets cloud:up exit cleanly and avoids parallel batches colliding on the
|
|
152
220
|
// fixed local ports 14199/5173 (the cause of multi-add "false failures").
|
|
@@ -313,6 +381,28 @@ function configForVm(history, n) {
|
|
|
313
381
|
return h && h.config ? { ...EMPTY_CONFIG, ...h.config } : null;
|
|
314
382
|
}
|
|
315
383
|
|
|
384
|
+
/** The most-recent launch timestamp for VM `n`, or null when it has no recorded
|
|
385
|
+
* launch. The history is newest-first (pushHistory unshifts), so the FIRST entry
|
|
386
|
+
* for the VM is its latest launch — the same lookup shape configForVm uses.
|
|
387
|
+
* Powers the editor-starting grace window (server.js's deriveStatus): "was this
|
|
388
|
+
* unreachable VM launched recently enough to still be starting up?" Pure. */
|
|
389
|
+
function latestLaunchTs(history, n) {
|
|
390
|
+
const h = (Array.isArray(history) ? history : []).find((e) => e && e.vm === n);
|
|
391
|
+
return h && typeof h.ts === "string" ? h.ts : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** True when ISO launch timestamp `ts` is within `graceMs` of `nowMs` — the
|
|
395
|
+
* editor-starting grace window. A null/unparseable `ts` (no launch on record) is
|
|
396
|
+
* never "recent", so a VM with no history never reads as starting. Half-open
|
|
397
|
+
* window: exactly at the boundary (`nowMs - ts === graceMs`) is NOT recent, so
|
|
398
|
+
* the calm-blue window has a crisp, testable end. Pure; caller owns `nowMs`. */
|
|
399
|
+
function recentlyLaunchedWithin(ts, nowMs, graceMs) {
|
|
400
|
+
if (typeof ts !== "string") return false;
|
|
401
|
+
const t = Date.parse(ts);
|
|
402
|
+
if (Number.isNaN(t)) return false;
|
|
403
|
+
return nowMs - t < graceMs;
|
|
404
|
+
}
|
|
405
|
+
|
|
316
406
|
/** Resolve the card's display identity, preferring LIVE truth (the editor's
|
|
317
407
|
* `/api/session-info` `clientProject` + `editorIdentity`) over STALE launch
|
|
318
408
|
* history. This is the fix for fragility #33's lying label: a CLI branch
|
|
@@ -382,6 +472,31 @@ function stickyClientProject(prev, info) {
|
|
|
382
472
|
return prev || null;
|
|
383
473
|
}
|
|
384
474
|
|
|
475
|
+
/** Resolve a VM's app dev-server port stickily: prefer the freshly-polled
|
|
476
|
+
* numeric port, else retain the last-known-good `prevAppPort`, so a transient
|
|
477
|
+
* null poll (timeout / partial probe) never blanks a port the VM has already
|
|
478
|
+
* reported. Mirrors `stickyClientProject` — a populated `prev` survives a
|
|
479
|
+
* null/absent fresh poll, a genuinely-changed fresh poll always wins
|
|
480
|
+
* (stickiness must NOT pin a stale port: a real 3000→4321 move is honored),
|
|
481
|
+
* and `null` results ONLY when neither a polled nor a previous port exists —
|
|
482
|
+
* the true first-provision case, where the caller applies its 5173 bootstrap
|
|
483
|
+
* fallback.
|
|
484
|
+
*
|
|
485
|
+
* Without this, `appPort` was `(info && info.appPort) || null`, so one
|
|
486
|
+
* timed-out poll blanked the resolved port; the tunnel-open fallback then
|
|
487
|
+
* re-seeded the hardcoded 5173 Vite default and the next poll saw a 5173→3000
|
|
488
|
+
* drift and re-wired — a churn loop that never converged. Pure; no I/O.
|
|
489
|
+
*
|
|
490
|
+
* @param prevAppPort the previous `state.vms[n].appPort` (null/undefined ok)
|
|
491
|
+
* @param info the fresh poll result object (null when the probe failed)
|
|
492
|
+
* @returns the app port to store: fresh numeric port when present, else the
|
|
493
|
+
* last-known-good `prevAppPort`, else null. */
|
|
494
|
+
function stickyAppPort(prevAppPort, info) {
|
|
495
|
+
const fresh = info && info.appPort;
|
|
496
|
+
if (typeof fresh === 'number' && !Number.isNaN(fresh)) return fresh;
|
|
497
|
+
return typeof prevAppPort === 'number' && !Number.isNaN(prevAppPort) ? prevAppPort : null;
|
|
498
|
+
}
|
|
499
|
+
|
|
385
500
|
/** Classify a VM's project KIND as `"self-host"` vs `"guest"` from the live
|
|
386
501
|
* identity reported in `/api/session-info` (`clientProject.repo`), with
|
|
387
502
|
* launch-history `clientRepo` as a fallback when live is absent. A guest is
|
|
@@ -540,6 +655,21 @@ function resolveCredsSource({ reachableDonors = [], claudeCredsSrc = "", githubT
|
|
|
540
655
|
return { mode: "none", donor: null };
|
|
541
656
|
}
|
|
542
657
|
|
|
658
|
+
/** True when the resolved creds `mode` (see `resolveCredsSource`) carries a real
|
|
659
|
+
* Claude-auth seed source, so the add path can seed it automatically and must
|
|
660
|
+
* NOT raise the `claude-auth-not-seeded` setup step:
|
|
661
|
+
* - "donor" — copies the donor's `.credentials.json` + `.claude.json`.
|
|
662
|
+
* - "host-stash" — pushes the operator's CLAUDE_CREDS_SRC `.credentials.json`.
|
|
663
|
+
* The token-only and none modes carry NO Claude credentials — a GITHUB_TOKEN
|
|
664
|
+
* seeds *git* auth at boot but not Claude, so those genuinely have nothing to
|
|
665
|
+
* seed Claude from and the add path raises `claude-auth-not-seeded` (rendered
|
|
666
|
+
* as the calm Plan-1 `auth` setup step with the one-click Login). Branching on
|
|
667
|
+
* this instead of unconditionally landing degraded is what keeps the reason from
|
|
668
|
+
* appearing when a donor/stash WAS available. Pure; no I/O. */
|
|
669
|
+
function claudeAuthSeeded(mode) {
|
|
670
|
+
return mode === "donor" || mode === "host-stash";
|
|
671
|
+
}
|
|
672
|
+
|
|
543
673
|
// Single-quote a string for safe inclusion in a remote shell command. Mirrors
|
|
544
674
|
// the server.js helper so callers don't have to thread one through.
|
|
545
675
|
function _shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
|
|
@@ -652,6 +782,69 @@ function buildEnvApplyCommand({ gcloud, instance, zone, envFile, container, edit
|
|
|
652
782
|
return `base64 < ${_shq(envFile)} | ${gcloud} compute ssh ${instance} --tunnel-through-iap --zone=${zone} --command=${_shq(sink)}`;
|
|
653
783
|
}
|
|
654
784
|
|
|
785
|
+
/** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
|
|
786
|
+
* EDITOR reachability forward (`editorPort` → VM:14199) as its OWN ssh process,
|
|
787
|
+
* logging to `/tmp/ssh-tunnel-vm-<n>.log`. The editor forward is the dashboard's
|
|
788
|
+
* reachability signal — the probe to `PORT_BASE+N` connects through it — so it
|
|
789
|
+
* must have a lifetime INDEPENDENT of the preview forward (whose port legitimately
|
|
790
|
+
* changes as the app's dev server is resolved). Splitting the two forwards into
|
|
791
|
+
* separate ssh processes is what makes a preview re-wire non-disruptive: a
|
|
792
|
+
* preview-port change restarts only the preview tunnel and never blips :14199, so
|
|
793
|
+
* the reachability probe can't flip the VM to a false "unreachable" (the
|
|
794
|
+
* VM-1/2/3 flapping observed 2026-06-22). Pairs with `previewTunnelCmd`; the two
|
|
795
|
+
* builders are the single source of truth for the attach (addVm) and re-wire call
|
|
796
|
+
* sites so neither forward's `-L` spec can drift back into the other's process.
|
|
797
|
+
* Pure; no I/O. */
|
|
798
|
+
function editorTunnelCmd({ gcloud, inst, zone, editorPort, n } = {}) {
|
|
799
|
+
return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${editorPort}:127.0.0.1:14199 > /tmp/ssh-tunnel-vm-${n}.log 2>&1 &`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Build the `nohup … gcloud compute ssh … -N -L …` shell string that opens the
|
|
803
|
+
* LIVE-PREVIEW forward (`previewPort` → VM:<appPort>) as its OWN ssh process,
|
|
804
|
+
* logging to `/tmp/ssh-preview-vm-<n>.log`. The preview forward's target port
|
|
805
|
+
* changes whenever the app's dev-server port is (re)resolved, so it lives apart
|
|
806
|
+
* from the editor reachability forward (`editorTunnelCmd`): a preview re-wire
|
|
807
|
+
* pkills + respawns ONLY this process, leaving the editor :14199 forward — and
|
|
808
|
+
* the reachability probe riding it — untouched. The preview local port is unique
|
|
809
|
+
* per VM and never collides with the editor port, so the re-wire/destroy
|
|
810
|
+
* `pkill -f "<previewPort>:127.0.0.1:"` targets exactly this process. Pure; no
|
|
811
|
+
* I/O. */
|
|
812
|
+
function previewTunnelCmd({ gcloud, inst, zone, previewPort, appPort, n } = {}) {
|
|
813
|
+
return `nohup ${gcloud} compute ssh ${inst} --tunnel-through-iap --zone=${zone} -- -N -L ${previewPort}:127.0.0.1:${appPort} > /tmp/ssh-preview-vm-${n}.log 2>&1 &`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Build the shell snippet the Add script runs to AWAIT cloud:up's clean exit at
|
|
817
|
+
* container-healthy instead of reaping it mid-flight.
|
|
818
|
+
*
|
|
819
|
+
* History — two bugs, one helper: (1) vm-add-ingress-survives-bes-create: the Add
|
|
820
|
+
* used to `kill $UP_PID` ~15s after the container was Up, SIGTERMing
|
|
821
|
+
* `backend-services create` mid-flight (the 503 strand), so this began polling
|
|
822
|
+
* `cloud:up` to exit on its own rather than reaping it. (2)
|
|
823
|
+
* vm--async-add-time-lb-ingress-wiring: `cloud:up` no longer BLOCKS on the wiring
|
|
824
|
+
* tail — it kicks the EXTERNAL_MANAGED `backend-services create` + propagation off
|
|
825
|
+
* DETACHED (see cloud.js startDetachedAddWiring) and returns at container-healthy,
|
|
826
|
+
* so the detached child can no longer be reaped by anything that kills `cloud:up`.
|
|
827
|
+
* This poll therefore now resolves in ~seconds on the happy path (cloud:up has
|
|
828
|
+
* already exited); it stays only as the bounded backstop that reaps a `cloud:up`
|
|
829
|
+
* genuinely wedged BEFORE the early return (e.g. a stuck container gate). The
|
|
830
|
+
* background wiring converges via the dashboard's auto-rewire gather, so
|
|
831
|
+
* `deadlineSec` shrinks from the old 900s wiring budget to a container-readiness
|
|
832
|
+
* bound. `pidVar` is referenced by name so it expands at run time. Pure; no I/O. */
|
|
833
|
+
function awaitWiringCmd({ inst, deadlineSec = 180, pidVar = "UP_PID" } = {}) {
|
|
834
|
+
return [
|
|
835
|
+
`echo "awaiting cloud:up exit at container-healthy for ${inst} (up to ${deadlineSec}s; LB wiring converges in the background)..."`,
|
|
836
|
+
`__cy_wiring_waited=0`,
|
|
837
|
+
`while kill -0 $${pidVar} 2>/dev/null; do`,
|
|
838
|
+
` if [ "$__cy_wiring_waited" -ge ${deadlineSec} ]; then`,
|
|
839
|
+
` echo "WARN cloud:up still running after ${deadlineSec}s (wedged before the container-healthy early return) — reaping it (the dashboard self-heal will converge any half-wired backend)"`,
|
|
840
|
+
` kill $${pidVar} 2>/dev/null; pkill -f "cloud.js up --instance-name ${inst}" 2>/dev/null; break`,
|
|
841
|
+
` fi`,
|
|
842
|
+
` sleep 5; __cy_wiring_waited=$((__cy_wiring_waited + 5))`,
|
|
843
|
+
`done`,
|
|
844
|
+
`wait $${pidVar} 2>/dev/null || true`,
|
|
845
|
+
].join("\n");
|
|
846
|
+
}
|
|
847
|
+
|
|
655
848
|
/** Get authenticated Git URL for checking client branch. */
|
|
656
849
|
function getAuthRepoUrl(repo, token) {
|
|
657
850
|
if (!repo) return "";
|
|
@@ -711,10 +904,14 @@ module.exports = {
|
|
|
711
904
|
recentLaunchDisplay,
|
|
712
905
|
defaultConfig,
|
|
713
906
|
configForVm,
|
|
907
|
+
latestLaunchTs,
|
|
908
|
+
recentlyLaunchedWithin,
|
|
714
909
|
resolveCardIdentity,
|
|
715
910
|
stickyClientProject,
|
|
911
|
+
stickyAppPort,
|
|
716
912
|
classifyProjectKind,
|
|
717
913
|
resolveCredsSource,
|
|
914
|
+
claudeAuthSeeded,
|
|
718
915
|
buildCredsSteps,
|
|
719
916
|
buildClaudeJsonPreserveSteps,
|
|
720
917
|
buildEnvApplyCommand,
|
|
@@ -722,6 +919,10 @@ module.exports = {
|
|
|
722
919
|
editorSourceShellResolve,
|
|
723
920
|
rebuildStrategy,
|
|
724
921
|
decideStartJob,
|
|
922
|
+
editorTunnelCmd,
|
|
923
|
+
previewTunnelCmd,
|
|
924
|
+
awaitWiringCmd,
|
|
725
925
|
getAuthRepoUrl,
|
|
726
926
|
interpretLsRemote,
|
|
927
|
+
isPlaceholderProject,
|
|
727
928
|
};
|