@codeyam-editor/codeyam-editor 0.1.7 → 0.1.9
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.js +477 -41
- package/npm/editor-dev.js +24 -2
- package/npm/editor.js +15 -5
- package/npm/fleet-add-failure.js +406 -25
- package/npm/fleet-branch.js +124 -1
- package/npm/fleet-build-data.js +12 -1
- package/npm/fleet-cleanup.js +84 -3
- package/npm/fleet-datastore-health.js +107 -0
- package/npm/fleet-memory.js +29 -4
- package/npm/fleet-queue.js +141 -0
- package/npm/fleet-revision.js +108 -0
- package/npm/fleet-run-setup.js +66 -0
- package/npm/fleet-session-activity.js +162 -0
- package/npm/fleet-vm-access.js +162 -0
- package/npm/fleet-vm-readiness.js +57 -16
- package/npm/ingress-invoke.js +46 -2
- package/npm/launch-config.js +169 -2
- package/npm/scenario-check.js +708 -35
- package/npm/scenario-handlers.js +18 -0
- package/npm/scenario-interactivity.js +477 -23
- package/npm/scenario-issues.js +72 -0
- package/npm/scenario-mocks.js +245 -39
- package/npm/scenario-playwright.js +137 -26
- package/npm/utils.js +262 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-Cu1tSN-o.js → ContainerInspector-C7I9wctz.js} +1 -1
- package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
- package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
- package/ui/dist/assets/{ProjectLauncherScreen-Dmm6Z0mv.js → ProjectLauncherScreen-E0NYYiwy.js} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BqMxWVKW.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
- package/ui/dist/assets/index-BRoAenc1.css +1 -0
- package/ui/dist/assets/index-iMR5siiG.js +27 -0
- package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/preview-subdir-demo/index.html +62 -0
- package/ui/dist/preview-subdir-demo/sibling.html +48 -0
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +0 -260
- package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +0 -21
- package/ui/dist/assets/index-CjxWhkSy.css +0 -1
- package/ui/dist/assets/index-VuyqBbGJ.js +0 -27
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +0 -1
package/npm/cloud.js
CHANGED
|
@@ -59,6 +59,7 @@ const {
|
|
|
59
59
|
normalizePrincipal,
|
|
60
60
|
resolveDefaultOperator,
|
|
61
61
|
withDefaultOperator,
|
|
62
|
+
unauthorizableAddRefusal,
|
|
62
63
|
accessFor,
|
|
63
64
|
setAccess,
|
|
64
65
|
addAccess,
|
|
@@ -513,6 +514,28 @@ function previewSameOriginSafeFromStack(stackJson) {
|
|
|
513
514
|
return typeof flag === "boolean" ? flag : true;
|
|
514
515
|
}
|
|
515
516
|
|
|
517
|
+
/** Resolve the sameOriginSafe flag a seed should ACT on, given the static
|
|
518
|
+
* `stack.json` reading and the running editor's own `/api/config` verdict.
|
|
519
|
+
*
|
|
520
|
+
* Precedence: a BOOLEAN effective verdict wins; anything else (`null`,
|
|
521
|
+
* `undefined`, a non-boolean) defers to the static flag. The type check is the
|
|
522
|
+
* whole rule — `null` means "no server was up to ask", which is the
|
|
523
|
+
* pre-container seed and must leave the static read in charge, NOT "the server
|
|
524
|
+
* said same-origin is fine". Collapsing the two would put the static flag back
|
|
525
|
+
* in control on exactly the path that needs the override.
|
|
526
|
+
*
|
|
527
|
+
* Extracted from `seedPreviewOrigin` because that function's inputs arrive over
|
|
528
|
+
* gcloud SSH, so this rule was only reachable through heavy mocking — while
|
|
529
|
+
* being precisely where the defect lived. `stack.json` cannot express the state
|
|
530
|
+
* that matters here: a project escalates off the same-origin subpath at RUNTIME
|
|
531
|
+
* after a hydration failure, and the adaptive-default stacks that happens to
|
|
532
|
+
* (Next.js) deliberately omit the key. So a seeder that re-derives the flag
|
|
533
|
+
* from the file writes back the same-origin sentinel that just failed, and the
|
|
534
|
+
* project sits at `previewDedicatedOriginUnavailable: true` forever. Pure. */
|
|
535
|
+
function resolveEffectiveSameOriginSafe(staticFlag, effectiveVerdict) {
|
|
536
|
+
return typeof effectiveVerdict === "boolean" ? effectiveVerdict : staticFlag;
|
|
537
|
+
}
|
|
538
|
+
|
|
516
539
|
// Matches an absolute cloudflared quick-tunnel origin
|
|
517
540
|
// (`https://<sub>.trycloudflare.com`, optionally with a port/path/query). These
|
|
518
541
|
// URLs ROTATE on every tunnel respawn, which is exactly why a frozen one rots.
|
|
@@ -957,6 +980,19 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
957
980
|
return ["compute", "instances", "delete", instanceName, "--project", project, "--zone", zone, "--quiet"];
|
|
958
981
|
case "instances-describe":
|
|
959
982
|
return ["compute", "instances", "describe", instanceName, "--project", project, "--zone", zone, "--format=json"];
|
|
983
|
+
// Outstanding (not-yet-DONE) zone operations targeting this instance. Used to
|
|
984
|
+
// tell a normal transient PROVISIONING from a wedged insert: GCE can accept an
|
|
985
|
+
// insert and then never finish it, leaving an operation parked at RUNNING/0%
|
|
986
|
+
// that holds the instance name forever. The status filter is what makes this
|
|
987
|
+
// cheap and unambiguous — a DONE operation is history and says nothing about
|
|
988
|
+
// whether the slot is currently blocked.
|
|
989
|
+
case "operations-list-pending":
|
|
990
|
+
return [
|
|
991
|
+
"compute", "operations", "list",
|
|
992
|
+
"--project", project,
|
|
993
|
+
"--filter", `zone:(${zone}) AND targetLink~${instanceName}$ AND status!=DONE`,
|
|
994
|
+
"--format=json",
|
|
995
|
+
];
|
|
960
996
|
case "instances-list-tagged":
|
|
961
997
|
return [
|
|
962
998
|
"compute", "instances", "list",
|
|
@@ -1053,8 +1089,41 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
1053
1089
|
// reconcile spawnSync sites (status probe, per-BES timeout converge) so all three
|
|
1054
1090
|
// reference one value. 2 min is generous for any single describe/update/SSH probe
|
|
1055
1091
|
// yet comfortably under the 600000 ms outer reconcile budget.
|
|
1092
|
+
//
|
|
1093
|
+
// It is a PROBE budget, and provisioning is exempt — see
|
|
1094
|
+
// GCLOUD_PROVISION_TIMEOUT_MS below. Do not re-collapse the two: raising this
|
|
1095
|
+
// constant to accommodate a slow create would un-bound the reconcile sweep,
|
|
1096
|
+
// which is the exact orphan-cascade this value exists to stop.
|
|
1056
1097
|
const GCLOUD_CALL_TIMEOUT_MS = 120000;
|
|
1057
1098
|
|
|
1099
|
+
// `instances create` / `instances start` are NOT probes: each blocks until GCE's
|
|
1100
|
+
// insert (or start) operation reaches a terminal state, which routinely outruns
|
|
1101
|
+
// the probe budget under zonal contention. Inheriting GCLOUD_CALL_TIMEOUT_MS
|
|
1102
|
+
// SIGTERMd gcloud mid-insert; `spawnSync` then returned `status: null`, which
|
|
1103
|
+
// `!== 0`, so a local kill was indistinguishable from a remote rejection and the
|
|
1104
|
+
// caller threw a bare `Failed to create instance <name>` with no cause in it.
|
|
1105
|
+
// That mattered downstream: every arm of classifyAddFailure keys on a transcript
|
|
1106
|
+
// signature, so a causeless log fell through to the optimistic `verifying`
|
|
1107
|
+
// fallback and told the operator to WAIT on a VM GCE had already wedged
|
|
1108
|
+
// (observed 2026-09-04 on VM-8: insert 14:29:50Z, add errored 14:32:26Z — the
|
|
1109
|
+
// 120s budget plus teardown).
|
|
1110
|
+
//
|
|
1111
|
+
// Deliberately above the 600000 ms outer reconcile budget, on the same reasoning
|
|
1112
|
+
// as PROJECT_BOOTSTRAP_TIMEOUT_MS: ensureInstance is only ever reached from the
|
|
1113
|
+
// add path, never from the reconcile sweep, so the sweep-relative ceiling that
|
|
1114
|
+
// governs GCLOUD_CALL_TIMEOUT_MS does not apply. A create still running at 10
|
|
1115
|
+
// minutes is wedged, not slow.
|
|
1116
|
+
const GCLOUD_PROVISION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
1117
|
+
|
|
1118
|
+
/** The per-call spawn budget a given wrapper verb should run under. Provisioning
|
|
1119
|
+
* verbs get GCLOUD_PROVISION_TIMEOUT_MS; everything else keeps the probe budget.
|
|
1120
|
+
* Pure — the mapping is asserted directly, without spawning a process. */
|
|
1121
|
+
function gcloudCallTimeoutMs(verb) {
|
|
1122
|
+
return verb === "instances-create" || verb === "instances-start"
|
|
1123
|
+
? GCLOUD_PROVISION_TIMEOUT_MS
|
|
1124
|
+
: GCLOUD_CALL_TIMEOUT_MS;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1058
1127
|
// The project bootstrap is NOT a probe: it runs `git clone` + `npm install` in
|
|
1059
1128
|
// the repo root and every `apps[].dir`, each package's `postinstall`, then
|
|
1060
1129
|
// `npm run setup` — minutes of remote work on a heavy repo. It once ran as ONE
|
|
@@ -1127,6 +1196,66 @@ function formatGcloudFailure(result) {
|
|
|
1127
1196
|
return "(no output captured from gcloud)";
|
|
1128
1197
|
}
|
|
1129
1198
|
|
|
1199
|
+
/** The thrown message for a failed `instances create` / `instances start`.
|
|
1200
|
+
*
|
|
1201
|
+
* Splits the two outcomes the old bare `Failed to create instance <name>`
|
|
1202
|
+
* collapsed, because they are different operator actions: a LOCAL timeout means
|
|
1203
|
+
* GCE may still be working the operation and it should be checked, while a
|
|
1204
|
+
* remote rejection means GCE said no and its own stderr is the answer. Both
|
|
1205
|
+
* arms put a cause in the transcript — classifyAddFailure keys on transcript
|
|
1206
|
+
* text, so a causeless message is what lands an add on the optimistic
|
|
1207
|
+
* `verifying` fallback. Pure: result in, string out.
|
|
1208
|
+
*
|
|
1209
|
+
* `verb` is the operator-facing word ("create" / "start"), not a wrapper verb. */
|
|
1210
|
+
/** Operator-facing copy for a create GCE accepted and then abandoned, kept
|
|
1211
|
+
* separate from the guard that detects it for the same reason
|
|
1212
|
+
* provisionFailureMessage is: the decision of WHEN to refuse and the wording of
|
|
1213
|
+
* WHAT to say are different concerns, and only the wording needs asserting.
|
|
1214
|
+
* Extracting it is what lets the copy be tested without a live gcloud — and on
|
|
1215
|
+
* this feature the copy IS the fix, since the bug was a message that told the
|
|
1216
|
+
* operator to wait for a VM that would never arrive.
|
|
1217
|
+
*
|
|
1218
|
+
* Every field is rendered defensively because this runs on a degraded path: the
|
|
1219
|
+
* operations read that produced `pending` is best-effort, so a partial record
|
|
1220
|
+
* must still yield a message that names the problem rather than printing
|
|
1221
|
+
* "undefined" at the operator. */
|
|
1222
|
+
function wedgedOperationMessage(instanceName, pending = {}) {
|
|
1223
|
+
const detail = [
|
|
1224
|
+
`${pending.name || "unnamed"}: ${pending.operationType || "unknown type"}`,
|
|
1225
|
+
`status ${pending.status || "unknown"}`,
|
|
1226
|
+
`progress ${pending.progress == null ? "unknown" : `${pending.progress}%`}`,
|
|
1227
|
+
`error ${pending.errorCode || "unknown"}${pending.errorMessage ? `: ${pending.errorMessage}` : ""}`,
|
|
1228
|
+
].join(", ");
|
|
1229
|
+
return (
|
|
1230
|
+
`Instance ${instanceName} is stuck in PROVISIONING behind a wedged create operation (${detail}). ` +
|
|
1231
|
+
"GCE accepted the create and never finished it, so this slot cannot be re-added and a delete will queue " +
|
|
1232
|
+
"behind the wedged operation rather than clear it. This needs GCE-side resolution — open a Google Cloud " +
|
|
1233
|
+
"Support case citing that error. Use a different VM number if you need a box now."
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function provisionFailureMessage(verb, instanceName, result) {
|
|
1238
|
+
if (isSpawnTimeout(result)) {
|
|
1239
|
+
const minutes = Math.round(GCLOUD_PROVISION_TIMEOUT_MS / 60000);
|
|
1240
|
+
// A SIGTERMd child legitimately often captured nothing, and appending
|
|
1241
|
+
// formatGcloudFailure's "(no output captured)" placeholder to a message that
|
|
1242
|
+
// already states the cause is noise. Append it only when it is real output.
|
|
1243
|
+
const detail = formatGcloudFailure(result);
|
|
1244
|
+
const captured = detail === "(no output captured from gcloud)" ? "" : `\n${detail}`;
|
|
1245
|
+
return (
|
|
1246
|
+
`Failed to ${verb} instance ${instanceName}: the gcloud call exceeded the local ` +
|
|
1247
|
+
`${minutes}-minute provisioning budget and was SIGTERMd locally. GCE may still be ` +
|
|
1248
|
+
`working the operation — check it with \`gcloud compute operations list\` before ` +
|
|
1249
|
+
`retrying, and delete the half-built instance if one exists.` +
|
|
1250
|
+
captured
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
return (
|
|
1254
|
+
`Failed to ${verb} instance ${instanceName} (gcloud exit ${result && result.status}).\n` +
|
|
1255
|
+
formatGcloudFailure(result)
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1130
1259
|
// A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
|
|
1131
1260
|
// for an ssh failure) and a signature match against known transient strings.
|
|
1132
1261
|
function isTransientSshFailure(result) {
|
|
@@ -1202,6 +1331,38 @@ function fetchInstanceState(cfg) {
|
|
|
1202
1331
|
}
|
|
1203
1332
|
}
|
|
1204
1333
|
|
|
1334
|
+
/** Read the instance's outstanding (not-DONE) zone operations and summarize the
|
|
1335
|
+
* first one, or null when there are none / the call failed. Shaped as
|
|
1336
|
+
* { name, operationType, status, progress, errorCode, errorMessage } — the fields
|
|
1337
|
+
* the wedged-insert refusal message and the fleet classifier both need.
|
|
1338
|
+
*
|
|
1339
|
+
* Returning null on a failed or unparseable call is deliberate: this is a
|
|
1340
|
+
* DIAGNOSTIC read used to make a refusal more informative, so it must never turn
|
|
1341
|
+
* a working provision into a failing one just because the operations API hiccuped. */
|
|
1342
|
+
function fetchPendingOperation(cfg) {
|
|
1343
|
+
const result = runGcloud(buildGcloudArgs("operations-list-pending", cfg), {
|
|
1344
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1345
|
+
});
|
|
1346
|
+
if (result.status !== 0) return null;
|
|
1347
|
+
let ops;
|
|
1348
|
+
try {
|
|
1349
|
+
ops = JSON.parse(result.stdout);
|
|
1350
|
+
} catch {
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
if (!Array.isArray(ops) || ops.length === 0) return null;
|
|
1354
|
+
const op = ops[0];
|
|
1355
|
+
const err = op && op.error && Array.isArray(op.error.errors) ? op.error.errors[0] : null;
|
|
1356
|
+
return {
|
|
1357
|
+
name: op.name || null,
|
|
1358
|
+
operationType: op.operationType || null,
|
|
1359
|
+
status: op.status || null,
|
|
1360
|
+
progress: typeof op.progress === "number" ? op.progress : null,
|
|
1361
|
+
errorCode: (err && err.code) || null,
|
|
1362
|
+
errorMessage: (err && err.message) || null,
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1205
1366
|
function loadOrInitCloudConfig(projectDir, flags) {
|
|
1206
1367
|
const existing = loadCloudConfig(projectDir);
|
|
1207
1368
|
// noTunnel is a per-invocation behavior, never persisted — else a later manual
|
|
@@ -1283,7 +1444,11 @@ async function ensureFirewallRule(cfg) {
|
|
|
1283
1444
|
}
|
|
1284
1445
|
}
|
|
1285
1446
|
|
|
1286
|
-
|
|
1447
|
+
// `sleepFn` is injectable so the 60-iteration wait loop can be driven with a
|
|
1448
|
+
// no-op instead of burning 2 real minutes of clock — the same seam
|
|
1449
|
+
// runGcloudSshWithRetry / waitForEditorComposeRunning already expose.
|
|
1450
|
+
async function ensureInstance(cfg, { sleepFn } = {}) {
|
|
1451
|
+
const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1287
1452
|
console.error(`Checking instance ${cfg.instanceName}...`);
|
|
1288
1453
|
const initialState = fetchInstanceState(cfg);
|
|
1289
1454
|
const createdThisRun = initialState === null;
|
|
@@ -1294,29 +1459,59 @@ async function ensureInstance(cfg) {
|
|
|
1294
1459
|
}
|
|
1295
1460
|
if (initialState === "TERMINATED") {
|
|
1296
1461
|
console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
|
|
1297
|
-
const start = runGcloud(buildGcloudArgs("instances-start", cfg)
|
|
1462
|
+
const start = runGcloud(buildGcloudArgs("instances-start", cfg), {
|
|
1463
|
+
timeout: gcloudCallTimeoutMs("instances-start"),
|
|
1464
|
+
});
|
|
1298
1465
|
if (start.status !== 0) {
|
|
1299
|
-
throw new Error(
|
|
1466
|
+
throw new Error(provisionFailureMessage("start", cfg.instanceName, start));
|
|
1300
1467
|
}
|
|
1301
1468
|
} else if (initialState === null) {
|
|
1302
1469
|
console.error(` ${cfg.instanceName} not found — creating...`);
|
|
1303
|
-
const create = runGcloud(buildGcloudArgs("instances-create", cfg)
|
|
1470
|
+
const create = runGcloud(buildGcloudArgs("instances-create", cfg), {
|
|
1471
|
+
timeout: gcloudCallTimeoutMs("instances-create"),
|
|
1472
|
+
});
|
|
1304
1473
|
if (create.status !== 0) {
|
|
1305
|
-
throw new Error(
|
|
1474
|
+
throw new Error(provisionFailureMessage("create", cfg.instanceName, create));
|
|
1306
1475
|
}
|
|
1307
1476
|
} else {
|
|
1477
|
+
// A slot found in PROVISIONING is ambiguous, and the two readings need
|
|
1478
|
+
// opposite handling. On a normal add it is a transient the poll loop below
|
|
1479
|
+
// resolves in seconds. But GCE can also accept an insert and then never
|
|
1480
|
+
// finish it (2026-09-04, VM-8): the operation parks at RUNNING/0%, eventually
|
|
1481
|
+
// attaches an INTERNAL_ERROR without ever reaching DONE, and holds the
|
|
1482
|
+
// instance name indefinitely — a delete queues behind it rather than clearing
|
|
1483
|
+
// it. Polling that for 2 minutes only rediscovers, slowly and with a message
|
|
1484
|
+
// naming neither the operation nor the wedge, a state the first describe
|
|
1485
|
+
// already showed. Read the outstanding operation instead and fail immediately
|
|
1486
|
+
// when it carries a terminal error, so the transcript carries the signature
|
|
1487
|
+
// fleet-add-failure.js's wedged-insert arm keys on.
|
|
1488
|
+
if (initialState === "PROVISIONING") {
|
|
1489
|
+
const pending = fetchPendingOperation(cfg);
|
|
1490
|
+
if (pending && pending.errorCode) {
|
|
1491
|
+
throw new Error(wedgedOperationMessage(cfg.instanceName, pending));
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1308
1494
|
console.error(` ${cfg.instanceName} is ${initialState}; waiting...`);
|
|
1309
1495
|
}
|
|
1310
1496
|
|
|
1497
|
+
let lastState = initialState;
|
|
1311
1498
|
for (let i = 0; i < 60; i++) {
|
|
1312
|
-
|
|
1499
|
+
lastState = fetchInstanceState(cfg);
|
|
1500
|
+
if (lastState === "RUNNING") {
|
|
1313
1501
|
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
1314
1502
|
await waitForIapReady(cfg);
|
|
1315
1503
|
return { createdThisRun };
|
|
1316
1504
|
}
|
|
1317
|
-
await
|
|
1505
|
+
await sleep(2000);
|
|
1318
1506
|
}
|
|
1319
|
-
|
|
1507
|
+
// Name the state it was actually stuck in. An instance parked in PROVISIONING
|
|
1508
|
+
// is a different operator action from one that stopped answering describe at
|
|
1509
|
+
// all, and the old message ("did not reach RUNNING") distinguished neither.
|
|
1510
|
+
throw new Error(
|
|
1511
|
+
`Instance ${cfg.instanceName} did not reach RUNNING within 2 minutes ` +
|
|
1512
|
+
`(last observed state: ${lastState || "unknown — describe returned no state"}` +
|
|
1513
|
+
`${createdThisRun ? "; a create was issued this run" : ""}).`,
|
|
1514
|
+
);
|
|
1320
1515
|
}
|
|
1321
1516
|
|
|
1322
1517
|
/** GCE reports the instance as RUNNING the moment the boot disk is attached,
|
|
@@ -1784,17 +1979,32 @@ function projectBootstrapRunnerScript(repoName) {
|
|
|
1784
1979
|
// makes the outcome probe honest: after a launch, a present `.done` can only
|
|
1785
1980
|
// mean THIS run's script reached its terminal line, never a leftover from a
|
|
1786
1981
|
// previous half-wired attempt.
|
|
1787
|
-
function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName) {
|
|
1982
|
+
function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName, libFiles = {}) {
|
|
1788
1983
|
const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
1789
1984
|
const runnerB64 = Buffer.from(projectBootstrapRunnerScript(repoName), "utf8").toString("base64");
|
|
1790
1985
|
const argsString = scriptArgs.map(shQuote).join(" ");
|
|
1791
1986
|
const envPrefix = envAssignmentPrefix(envVars);
|
|
1987
|
+
// The bootstrap script is written STANDALONE to $HOME, so any `source`/`.` it
|
|
1988
|
+
// does relative to its own directory resolves against $HOME — not against the
|
|
1989
|
+
// repo layout it was authored in. `libFiles` carries those siblings, keyed by
|
|
1990
|
+
// the $HOME-relative path the script will look for them at (e.g.
|
|
1991
|
+
// "lib/npm-pin.sh"). Their directories do not exist on a fresh VM, hence the
|
|
1992
|
+
// mkdir. Omitting a sourced library is not a soft failure: the script aborts
|
|
1993
|
+
// under `set -euo pipefail` and cloud:up tears the new VM down, which is the
|
|
1994
|
+
// 2026-09-04 fleet-wide add outage.
|
|
1995
|
+
const libWrites = Object.entries(libFiles).flatMap(([relPath, body]) => {
|
|
1996
|
+
const b64 = Buffer.from(body, "utf8").toString("base64");
|
|
1997
|
+
const dir = path.posix.dirname(relPath);
|
|
1998
|
+
const mkdir = dir && dir !== "." ? [`mkdir -p $HOME/${dir}`] : [];
|
|
1999
|
+
return [...mkdir, `echo '${b64}' | base64 -d > $HOME/${relPath}`];
|
|
2000
|
+
});
|
|
1792
2001
|
// Writes run synchronously (fast, before any backgrounding) so a decode error
|
|
1793
2002
|
// aborts before launch; then the runner is detached. The env prefix is on the
|
|
1794
2003
|
// setsid command so the tokens are inherited through nohup→setsid→bash but
|
|
1795
2004
|
// never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
|
|
1796
2005
|
// launch in the background while still confirming it fired.
|
|
1797
2006
|
const writes = [
|
|
2007
|
+
...libWrites,
|
|
1798
2008
|
`echo '${scriptB64}' | base64 -d > ${projectBootstrapScriptPath(repoName)}`,
|
|
1799
2009
|
`echo '${runnerB64}' | base64 -d > ${projectBootstrapRunnerPath(repoName)}`,
|
|
1800
2010
|
`rm -f ${projectBootstrapStatusPath(repoName)} ${projectBootstrapDoneMarker(repoName)}`,
|
|
@@ -2026,6 +2236,13 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
2026
2236
|
console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
|
|
2027
2237
|
const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
|
|
2028
2238
|
const scriptBody = readStreamedScript(scriptPath, cfg);
|
|
2239
|
+
// gcp-project-bootstrap.sh sources lib/npm-pin.sh relative to its own
|
|
2240
|
+
// directory. Read it through readStreamedScript too, so the library is subject
|
|
2241
|
+
// to the same drift guard as the script that sources it — streaming a reviewed
|
|
2242
|
+
// remote script beside a stale on-disk library would defeat the guard.
|
|
2243
|
+
const bootstrapLibs = {
|
|
2244
|
+
"lib/npm-pin.sh": readStreamedScript(path.join(rootDir, "scripts", "lib", "npm-pin.sh"), cfg),
|
|
2245
|
+
};
|
|
2029
2246
|
const scriptArgs = clientProjectBootstrapArgs(cfg);
|
|
2030
2247
|
// Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
|
|
2031
2248
|
// toggle — see projectBootstrapEnv for what each key is for and when it is
|
|
@@ -2053,7 +2270,7 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
2053
2270
|
// install that will survive an SSH disconnect. Every step of
|
|
2054
2271
|
// gcp-project-bootstrap.sh is skip-on-exists, so re-entering is cheap.
|
|
2055
2272
|
console.error(" launching detached project bootstrap on the VM (survives SSH disconnect)...");
|
|
2056
|
-
const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName);
|
|
2273
|
+
const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName, bootstrapLibs);
|
|
2057
2274
|
const launched = await runGcloudSshWithRetry(
|
|
2058
2275
|
"ssh-script",
|
|
2059
2276
|
cfg,
|
|
@@ -2804,8 +3021,30 @@ function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
|
|
|
2804
3021
|
* `_managedPreviewOrigin` sentinel). We stamp the sentinel here too so the
|
|
2805
3022
|
* monitor doesn't mistake the cloud:up seed for a user pin. The
|
|
2806
3023
|
* skip-on-already-set guard stays: a non-empty previewOrigin (whether a user
|
|
2807
|
-
* pin or a monitor write) is left untouched on every `cloud:up`.
|
|
2808
|
-
|
|
3024
|
+
* pin or a monitor write) is left untouched on every `cloud:up`.
|
|
3025
|
+
*
|
|
3026
|
+
* `effectiveSameOriginSafe` is the RUNNING editor's own verdict
|
|
3027
|
+
* (`/api/config`'s `previewSameOriginSafe`), supplied by a caller that has
|
|
3028
|
+
* already fetched it. When it is a boolean it WINS over the static
|
|
3029
|
+
* `stack.json` read; `null`/absent leaves the static read in charge.
|
|
3030
|
+
*
|
|
3031
|
+
* That parameter exists because this function had two sources of truth for one
|
|
3032
|
+
* flag, and they disagreed exactly where the adaptive default lives. A
|
|
3033
|
+
* subpath hydration failure escalates a project off `/__codeyam_preview` at
|
|
3034
|
+
* RUNTIME even when `stack.json` never declared `sameOriginSafe: false` — which
|
|
3035
|
+
* is the documented Next.js scaffold shape (a static `false` broke every
|
|
3036
|
+
* SSH-forward VM, so the key is deliberately absent). `healPreviewOriginAtReady`
|
|
3037
|
+
* decided WHETHER to heal from that runtime verdict and was right, then called
|
|
3038
|
+
* this function, which recomputed WHAT to write from the static flag and wrote
|
|
3039
|
+
* the very same-origin sentinel that had just failed. The project then sat
|
|
3040
|
+
* permanently at `previewDedicatedOriginUnavailable: true` — the editor knowing
|
|
3041
|
+
* it must leave the subpath and having nowhere to go — beside a preview host
|
|
3042
|
+
* that was already provisioned, LB-routed, HEALTHY and unused. */
|
|
3043
|
+
async function seedPreviewOrigin(
|
|
3044
|
+
cfg,
|
|
3045
|
+
appPort = VITE_PORT,
|
|
3046
|
+
effectiveSameOriginSafe = null,
|
|
3047
|
+
) {
|
|
2809
3048
|
const repoName = resolveProject(cfg).name;
|
|
2810
3049
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
2811
3050
|
const stackPath = `~/projects/${repoName}/.codeyam/stack.json`;
|
|
@@ -2841,6 +3080,12 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
2841
3080
|
current = parseObj(localJson);
|
|
2842
3081
|
sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
|
|
2843
3082
|
}
|
|
3083
|
+
// The running editor's verdict overrides the static file — see
|
|
3084
|
+
// `resolveEffectiveSameOriginSafe` for why only a boolean counts.
|
|
3085
|
+
sameOriginSafe = resolveEffectiveSameOriginSafe(
|
|
3086
|
+
sameOriginSafe,
|
|
3087
|
+
effectiveSameOriginSafe,
|
|
3088
|
+
);
|
|
2844
3089
|
|
|
2845
3090
|
// Resolve this VM's custom slug (if any) so a dedicated-origin seed uses the
|
|
2846
3091
|
// slug host. The slug rides on cfg at Add time (the --vm-slug flag, validated
|
|
@@ -2874,6 +3119,12 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
2874
3119
|
// `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
|
|
2875
3120
|
// remote browser then loads as `127.0.0.1 refused to connect`). Once
|
|
2876
3121
|
// stack.json exists, heal it forward to the dedicated origin.
|
|
3122
|
+
// `sameOriginSafe` here is the EFFECTIVE verdict, so this exception also
|
|
3123
|
+
// covers the case stack.json can never report: a project whose static
|
|
3124
|
+
// flag is legitimately absent (the Next.js scaffold default) that
|
|
3125
|
+
// escalated off the subpath at runtime after a hydration failure. The
|
|
3126
|
+
// caller passes the editor's own answer; without it this predicate read
|
|
3127
|
+
// `true` and declined to heal the one shape it was needed for.
|
|
2877
3128
|
// Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
|
|
2878
3129
|
// re-runs this seed over cloud:up, either heal reaches every touch-point the
|
|
2879
3130
|
// moment stack.json is present — closing the race without re-ordering
|
|
@@ -2969,11 +3220,14 @@ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
|
|
|
2969
3220
|
`READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
|
|
2970
3221
|
`but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
|
|
2971
3222
|
);
|
|
2972
|
-
// Re-run the seed
|
|
2973
|
-
//
|
|
2974
|
-
//
|
|
2975
|
-
//
|
|
2976
|
-
|
|
3223
|
+
// Re-run the seed with the verdict we just read, so WHETHER to heal and WHAT
|
|
3224
|
+
// to write come from the same source. Passing it is what makes this heal reach
|
|
3225
|
+
// the adaptive-default stacks it exists for: on a Next.js project whose
|
|
3226
|
+
// `stack.json` omits `sameOriginSafe` (the scaffolded shape), the static read
|
|
3227
|
+
// inside the seed answers `true` and re-writes the same-origin sentinel that
|
|
3228
|
+
// just failed to hydrate. We got here precisely BECAUSE the editor said
|
|
3229
|
+
// `false`, so `false` is the answer the seed must use.
|
|
3230
|
+
await seedPreviewOrigin(cfg, appPort, false);
|
|
2977
3231
|
// Tell the running server to re-read editor.local.json — no restart (the
|
|
2978
3232
|
// footgun that killed VM7's live Claude session).
|
|
2979
3233
|
const reload = runGcloud(
|
|
@@ -3371,7 +3625,16 @@ function promptForInstanceName(expected) {
|
|
|
3371
3625
|
});
|
|
3372
3626
|
}
|
|
3373
3627
|
|
|
3374
|
-
|
|
3628
|
+
// Every file streamed to a VM, and therefore every file the drift guard must
|
|
3629
|
+
// compare against the reviewed remote tip. `scripts/lib/npm-pin.sh` is here
|
|
3630
|
+
// because gcp-project-bootstrap.sh SOURCES it: a library that reaches the VM is
|
|
3631
|
+
// as much streamed content as the script that sources it, and leaving it out
|
|
3632
|
+
// meant the drift guard silently vouched for only half of what was shipped.
|
|
3633
|
+
const STREAMED_SCRIPTS = [
|
|
3634
|
+
"scripts/gcp-bootstrap.sh",
|
|
3635
|
+
"scripts/gcp-project-bootstrap.sh",
|
|
3636
|
+
"scripts/lib/npm-pin.sh",
|
|
3637
|
+
];
|
|
3375
3638
|
|
|
3376
3639
|
/** The configured editor branch to compare/stream streamed scripts against.
|
|
3377
3640
|
* Resolved from config (`resolveEditorConfig` → `editorBranch`, itself derived
|
|
@@ -3608,8 +3871,9 @@ function fleetInstancePrefix() {
|
|
|
3608
3871
|
* scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
|
|
3609
3872
|
* result so callers decide fatality — Add treats a non-zero as a loud warning
|
|
3610
3873
|
* (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
|
|
3611
|
-
* teardown still removes the instance.
|
|
3612
|
-
|
|
3874
|
+
* teardown still removes the instance. `vmAccess` (up only) is the Add's intended
|
|
3875
|
+
* principal set, which the script's grant step uses in place of its sidecar read. */
|
|
3876
|
+
function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
|
|
3613
3877
|
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3614
3878
|
const inv = vmIngressInvocation({
|
|
3615
3879
|
scriptPath,
|
|
@@ -3621,6 +3885,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
|
|
|
3621
3885
|
appPort,
|
|
3622
3886
|
instancePrefix: fleetInstancePrefix(),
|
|
3623
3887
|
slug,
|
|
3888
|
+
vmAccess,
|
|
3624
3889
|
});
|
|
3625
3890
|
return spawnSync(inv.command, inv.args, {
|
|
3626
3891
|
stdio: "inherit",
|
|
@@ -3651,17 +3916,77 @@ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
|
|
|
3651
3916
|
});
|
|
3652
3917
|
}
|
|
3653
3918
|
|
|
3919
|
+
/** THE principal set that must have IAP access to this VM after the Add: the
|
|
3920
|
+
* explicit `cfg.vmAccess` collaborators unioned with the resolved default
|
|
3921
|
+
* operator. Both add-time access paths consume this ONE function —
|
|
3922
|
+
* `wireVmIngressOnAdd` (which hands it to vm-ingress.sh's grant) and
|
|
3923
|
+
* `applyVmAccessOnAdd` (which grants it and subtracts it from the stale-revoke
|
|
3924
|
+
* set). They used to decide independently, and that is precisely how one path
|
|
3925
|
+
* granted `user:jared@codeyam.com` at 00:09:31 and the other revoked the same
|
|
3926
|
+
* principal 18 seconds later as a "stale prior tenant" (VMs 4/5/6, 2026-08-09):
|
|
3927
|
+
* the ingress path read the SIDECAR (still holding the prior tenant) while
|
|
3928
|
+
* `applyVmAccessOnAdd` resolved an empty operator. One source of truth removes
|
|
3929
|
+
* the whole class of disagreement. `env` is injectable for tests; the only I/O
|
|
3930
|
+
* is `resolveDefaultOperator`'s config/gcloud read. */
|
|
3931
|
+
function intendedVmPrincipals(cfg = {}, env = process.env) {
|
|
3932
|
+
return withDefaultOperator(
|
|
3933
|
+
normalizeAccessList(cfg.vmAccess || []),
|
|
3934
|
+
resolveDefaultOperator({ env }),
|
|
3935
|
+
);
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
/** Add preflight: throw — BEFORE any GCE instance exists — when this Add would
|
|
3939
|
+
* come up on an IAP-gated public URL that authorizes NOBODY. The whole condition
|
|
3940
|
+
* is env + config (`intendedVmPrincipals` needs no GCP state), which is why it
|
|
3941
|
+
* belongs beside cmdUp's other cheap boundary validations rather than after a
|
|
3942
|
+
* machine has been provisioned: checking later means a half-provisioned VM to
|
|
3943
|
+
* clean up. On 2026-08-09 the un-refused version of this Add reported `done` for
|
|
3944
|
+
* VMs 4/5/6, left three unreachable URLs, and revoked each slot's prior tenant on
|
|
3945
|
+
* the way.
|
|
3946
|
+
*
|
|
3947
|
+
* Only fires in LB mode on a numbered fleet VM with nothing to grant; an explicit
|
|
3948
|
+
* `--vm-access` satisfies it with no default operator configured, so the
|
|
3949
|
+
* collaborator flow is unaffected, as is every legacy quick-tunnel Add. The
|
|
3950
|
+
* refusal text (and its `Next valid action:` remedy naming all three config
|
|
3951
|
+
* tiers) is the pure `unauthorizableAddRefusal`; this function is the composition
|
|
3952
|
+
* and the throw. */
|
|
3953
|
+
function assertAddCanAuthorizeSomeone(cfg, env = process.env) {
|
|
3954
|
+
let plan;
|
|
3955
|
+
try {
|
|
3956
|
+
plan = ingressWiringPlan(cfg, env);
|
|
3957
|
+
} catch {
|
|
3958
|
+
// A malformed domain is `resolveIngressDomain`'s error to raise on the wiring
|
|
3959
|
+
// path; it must not masquerade here as an authorization refusal.
|
|
3960
|
+
plan = { wire: false };
|
|
3961
|
+
}
|
|
3962
|
+
const refusal = unauthorizableAddRefusal({
|
|
3963
|
+
lbMode: !!plan.wire,
|
|
3964
|
+
vmNumber: plan.wire ? plan.vmNumber : null,
|
|
3965
|
+
intendedCount: plan.wire ? intendedVmPrincipals(cfg, env).length : null,
|
|
3966
|
+
});
|
|
3967
|
+
if (refusal) throw new Error(refusal);
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3654
3970
|
/** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
|
|
3655
3971
|
* Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
|
|
3656
3972
|
* (the VM is reachable operator-side over SSH regardless), so on failure it
|
|
3657
3973
|
* prints the exact re-run command rather than throwing. No-op (and silent) in
|
|
3658
|
-
* legacy quick-tunnel mode or for a non-fleet client project.
|
|
3974
|
+
* legacy quick-tunnel mode or for a non-fleet client project.
|
|
3975
|
+
*
|
|
3976
|
+
* RETURNS the principals this call granted (`intendedVmPrincipals`), or [] when
|
|
3977
|
+
* it did not wire — the explicit dataflow `applyVmAccessOnAdd` needs to know what
|
|
3978
|
+
* the Add has already granted before it computes a stale-revoke. Returned on the
|
|
3979
|
+
* FAILURE path too: a non-zero `vm-ingress.sh up` may still have completed its
|
|
3980
|
+
* grant step, and a principal that was possibly granted must never be revoked. */
|
|
3659
3981
|
function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
|
|
3660
3982
|
const plan = ingressWiringPlan(cfg, env);
|
|
3661
|
-
if (!plan.wire) return;
|
|
3983
|
+
if (!plan.wire) return [];
|
|
3662
3984
|
const label = plan.slug || `vm-${plan.vmNumber}`;
|
|
3985
|
+
// Hand the intended set to the script so its `apply_default_iap_access` grants
|
|
3986
|
+
// THIS Add's principals instead of reading the sidecar's prior tenant.
|
|
3987
|
+
const vmAccess = intendedVmPrincipals(cfg, env);
|
|
3663
3988
|
console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
|
|
3664
|
-
const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
|
|
3989
|
+
const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug, vmAccess);
|
|
3665
3990
|
if (res.status === 0) {
|
|
3666
3991
|
console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
|
|
3667
3992
|
} else {
|
|
@@ -3683,6 +4008,7 @@ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
|
|
|
3683
4008
|
`(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
|
|
3684
4009
|
);
|
|
3685
4010
|
}
|
|
4011
|
+
return vmAccess;
|
|
3686
4012
|
}
|
|
3687
4013
|
|
|
3688
4014
|
/** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
|
|
@@ -3751,8 +4077,15 @@ function runIapAccess(cfg, vmNumber, action, principals) {
|
|
|
3751
4077
|
* a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
|
|
3752
4078
|
* command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
|
|
3753
4079
|
* array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
|
|
3754
|
-
* list (the clean-slate case).
|
|
3755
|
-
|
|
4080
|
+
* list (the clean-slate case).
|
|
4081
|
+
*
|
|
4082
|
+
* `grantedThisRun` is what an EARLIER step of this same Add already granted —
|
|
4083
|
+
* `wireVmIngressOnAdd`'s return value. It is subtracted from the stale-revoke
|
|
4084
|
+
* set, so a principal this Add just authorized can never be revoked moments later
|
|
4085
|
+
* as a prior tenant. That is a second, cheaper guard than the shared
|
|
4086
|
+
* `intendedVmPrincipals` resolver above: it holds even if the two paths drift
|
|
4087
|
+
* apart again, which is exactly how VMs 4/5/6 broke on 2026-08-09. */
|
|
4088
|
+
function applyVmAccessOnAdd(cfg, env = process.env, { grantedThisRun = [] } = {}) {
|
|
3756
4089
|
const desired = normalizeAccessList(cfg.vmAccess || []);
|
|
3757
4090
|
let plan;
|
|
3758
4091
|
try {
|
|
@@ -3769,11 +4102,15 @@ function applyVmAccessOnAdd(cfg, env = process.env) {
|
|
|
3769
4102
|
// never inaccessible-by-default (the Error-51 footgun). The default operator is
|
|
3770
4103
|
// NOT persisted to the sidecar (it's a standing grant, not a per-VM
|
|
3771
4104
|
// collaborator), so `desired` alone drives the sidecar + stale-revoke below.
|
|
3772
|
-
const toGrant =
|
|
4105
|
+
const toGrant = intendedVmPrincipals(cfg, env);
|
|
3773
4106
|
const stale = accessFor(vmNumber);
|
|
3774
4107
|
// Never revoke a principal we're about to (re-)grant — a prior tenant who was
|
|
3775
|
-
// also the default operator must not be revoked-then-regranted for nothing
|
|
3776
|
-
|
|
4108
|
+
// also the default operator must not be revoked-then-regranted for nothing —
|
|
4109
|
+
// NOR one an earlier step of this same Add already granted. `toGrant` alone
|
|
4110
|
+
// protected nothing when it was empty: the ingress step had granted the operator
|
|
4111
|
+
// seconds earlier and this revoke took it straight back off (VMs 4/5/6).
|
|
4112
|
+
const grantedAlready = new Set([...toGrant, ...normalizeAccessList(grantedThisRun || [])]);
|
|
4113
|
+
const staleOnly = stale.filter((p) => !grantedAlready.has(p));
|
|
3777
4114
|
|
|
3778
4115
|
if (plan.wire) {
|
|
3779
4116
|
if (staleOnly.length > 0) {
|
|
@@ -3890,12 +4227,23 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3890
4227
|
}
|
|
3891
4228
|
if (!plan.wire) {
|
|
3892
4229
|
// Nothing to converge — the no-op wire keeps the clean-slate invariant.
|
|
3893
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
3894
|
-
return { wired: true, reason: "no-wire" };
|
|
3895
|
-
}
|
|
3896
|
-
|
|
4230
|
+
const granted = wireVmIngressOnAdd(cfg, appPort, env);
|
|
4231
|
+
return { wired: true, reason: "no-wire", grantedThisRun: granted };
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
// The intended set, resolved ONCE and handed to every `up` attempt so each
|
|
4235
|
+
// converge iteration grants the same principals — and reported back to the
|
|
4236
|
+
// caller as `grantedThisRun` so the access reconcile that follows cannot revoke
|
|
4237
|
+
// them. Every `up` runs the script's grant step, so these are granted whether
|
|
4238
|
+
// the loop converges or hits its deadline. Injectable via `deps` like the rest
|
|
4239
|
+
// of this loop's slow I/O: the resolve spawns `gcloud config get-value account`
|
|
4240
|
+
// on its last tier, and a unit test must never depend on the gcloud identity
|
|
4241
|
+
// that happens to be active on the machine running it.
|
|
4242
|
+
const grantedThisRun =
|
|
4243
|
+
deps.grantedThisRun != null ? deps.grantedThisRun : intendedVmPrincipals(cfg, env);
|
|
3897
4244
|
const runUp =
|
|
3898
|
-
deps.runUp
|
|
4245
|
+
deps.runUp
|
|
4246
|
+
|| ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug, grantedThisRun));
|
|
3899
4247
|
const probeVerdict = deps.probeVerdict || ((p) => probeVmIngressVerdict(cfg, p));
|
|
3900
4248
|
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
3901
4249
|
const now = deps.now || (() => Date.now());
|
|
@@ -3938,7 +4286,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3938
4286
|
if (decision.done) {
|
|
3939
4287
|
if (decision.wired) {
|
|
3940
4288
|
console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
|
|
3941
|
-
return { wired: true, reason: "wired", attempts: attempt + 1 };
|
|
4289
|
+
return { wired: true, reason: "wired", attempts: attempt + 1, grantedThisRun };
|
|
3942
4290
|
}
|
|
3943
4291
|
const rewire =
|
|
3944
4292
|
`FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
|
|
@@ -3955,7 +4303,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3955
4303
|
`CY_DEGRADED ingress-wiring-failed: VM-${plan.vmNumber} public URL did not wire ` +
|
|
3956
4304
|
`within the add window (last verdict=${verdict || "unknown"}). Re-wire: ${rewire}`,
|
|
3957
4305
|
);
|
|
3958
|
-
return { wired: false, reason: "deadline", attempts: attempt + 1 };
|
|
4306
|
+
return { wired: false, reason: "deadline", attempts: attempt + 1, grantedThisRun };
|
|
3959
4307
|
}
|
|
3960
4308
|
attempt += 1;
|
|
3961
4309
|
await sleep(decision.delayMs);
|
|
@@ -3984,8 +4332,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
|
|
|
3984
4332
|
if (!plan.wire) {
|
|
3985
4333
|
// Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
|
|
3986
4334
|
// sidecar write. Run them inline so the clean-slate invariant still holds.
|
|
3987
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
3988
|
-
applyVmAccessOnAdd(cfg, env);
|
|
4335
|
+
const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
|
|
4336
|
+
applyVmAccessOnAdd(cfg, env, { grantedThisRun });
|
|
3989
4337
|
return;
|
|
3990
4338
|
}
|
|
3991
4339
|
const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
|
|
@@ -4017,8 +4365,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
|
|
|
4017
4365
|
`running it inline instead.`,
|
|
4018
4366
|
);
|
|
4019
4367
|
try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
|
|
4020
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
4021
|
-
applyVmAccessOnAdd(cfg, env);
|
|
4368
|
+
const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
|
|
4369
|
+
applyVmAccessOnAdd(cfg, env, { grantedThisRun });
|
|
4022
4370
|
}
|
|
4023
4371
|
}
|
|
4024
4372
|
|
|
@@ -4050,8 +4398,11 @@ async function cmdWireAddTail() {
|
|
|
4050
4398
|
// Converge the URL to `verdict=wired` (retry-until-wired), not a single-shot
|
|
4051
4399
|
// `up` left to the slow reconcile sweep — then the IAP access grant, in the
|
|
4052
4400
|
// SAME order cmdUp ran them inline.
|
|
4053
|
-
|
|
4054
|
-
|
|
4401
|
+
// Carry what the converge granted into the access reconcile, so the revoke
|
|
4402
|
+
// step cannot take back a principal this same Add just authorized — the exact
|
|
4403
|
+
// grant-then-revoke-18s-later sequence `/tmp/cy-wire-add-vm-4.log` recorded.
|
|
4404
|
+
const { grantedThisRun } = await convergeVmIngressOnAdd(cfg, appPort);
|
|
4405
|
+
applyVmAccessOnAdd(cfg, process.env, { grantedThisRun });
|
|
4055
4406
|
} finally {
|
|
4056
4407
|
if (marker) {
|
|
4057
4408
|
try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
|
|
@@ -4113,6 +4464,75 @@ function releaseVmSlugOnDestroy(cfg) {
|
|
|
4113
4464
|
return vmNumber;
|
|
4114
4465
|
}
|
|
4115
4466
|
|
|
4467
|
+
/** Does `branch` still exist on `repo`? Answered with a single
|
|
4468
|
+
* `git ls-remote --heads` — about a second, and no cloud resource touched.
|
|
4469
|
+
*
|
|
4470
|
+
* Tri-state on purpose, and the third state is the load-bearing one:
|
|
4471
|
+
* true — the ref resolved.
|
|
4472
|
+
* false — the remote answered and the ref is NOT there. Provably gone.
|
|
4473
|
+
* null — we could not get an answer (no network, no credentials, git
|
|
4474
|
+
* missing, timeout). NOT evidence of a deleted branch.
|
|
4475
|
+
* Only `false` refuses an add. Collapsing `null` into `false` would turn every
|
|
4476
|
+
* transient GitHub blip into a fleet-wide add outage, which is a far worse
|
|
4477
|
+
* failure than the one this preflight exists to prevent — and the classifier
|
|
4478
|
+
* arm downstream still catches a genuinely dead ref, just later and dearer.
|
|
4479
|
+
*
|
|
4480
|
+
* A private repo needs credentials, so the token is inlined into the URL the
|
|
4481
|
+
* same way clone_editor_repo does it on the VM side (x-access-token), and for
|
|
4482
|
+
* the same reason: the plain URL 401s. The token is confined to this argv and
|
|
4483
|
+
* never logged — the caller prints `repo` (the un-inlined form) instead. */
|
|
4484
|
+
function gitBranchRefExists({ repo, branch }) {
|
|
4485
|
+
if (!repo || !branch) return null;
|
|
4486
|
+
let url = repo;
|
|
4487
|
+
const token = resolveGithubToken();
|
|
4488
|
+
if (token && /^https:\/\/github\.com\//.test(repo)) {
|
|
4489
|
+
url = repo.replace("https://", `https://x-access-token:${token}@`);
|
|
4490
|
+
}
|
|
4491
|
+
const res = spawnSync("git", ["ls-remote", "--heads", url, branch], {
|
|
4492
|
+
encoding: "utf8",
|
|
4493
|
+
timeout: 30000,
|
|
4494
|
+
});
|
|
4495
|
+
// A non-zero exit is an unreachable/unauthorized remote, not an absent branch:
|
|
4496
|
+
// git exits 0 and prints nothing for "connected fine, no such head".
|
|
4497
|
+
if (res.error || res.status !== 0) return null;
|
|
4498
|
+
return String(res.stdout || "").trim().length > 0;
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
/** Refuse an add whose stored branch has been deleted from origin, BEFORE the
|
|
4502
|
+
* first gcloud call.
|
|
4503
|
+
*
|
|
4504
|
+
* Without this the failure lands as late and as expensively as it possibly can:
|
|
4505
|
+
* on 2026-08-25 four fleet VMs each created a real GCE instance, booted it,
|
|
4506
|
+
* installed npm and cloned 13,226 files before `git fetch` inside the VM said
|
|
4507
|
+
* `fatal: couldn't find remote ref editor-improvements-75` — a branch pruned
|
|
4508
|
+
* from origin hours earlier when it merged. One `git ls-remote` establishes the
|
|
4509
|
+
* same fact in about a second, for free. This repo prunes branches on merge, so
|
|
4510
|
+
* ANY stored feature-branch name expires silently; that makes this a recurring
|
|
4511
|
+
* condition, not a one-off.
|
|
4512
|
+
*
|
|
4513
|
+
* Placed beside checkScriptDrift and assertVariantProvisionable — the other
|
|
4514
|
+
* "fail loud before creating a GCE instance" guards — because it is only worth
|
|
4515
|
+
* anything ahead of ensureInstance. It cannot live in scripts/gcp-bootstrap.sh,
|
|
4516
|
+
* which already runs ON the provisioned box and never sees the client branch.
|
|
4517
|
+
*
|
|
4518
|
+
* Throws with the field, the branch and the repo named. Never throws for a ref
|
|
4519
|
+
* it could not check (see gitBranchRefExists). */
|
|
4520
|
+
function assertBranchRefsExist(cfg, { refExists = gitBranchRefExists } = {}) {
|
|
4521
|
+
// Lazy require: launch-config.js requires THIS module at load time, so a
|
|
4522
|
+
// top-level require here would close the cycle. Same pattern as the
|
|
4523
|
+
// isPlaceholderProject call sites above.
|
|
4524
|
+
const lc = require("./launch-config");
|
|
4525
|
+
const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
|
|
4526
|
+
const targets = lc.branchRefTargets(
|
|
4527
|
+
{ clientRepo: cfg.repo, clientBranch: cfg.branch, editorBranch },
|
|
4528
|
+
{ editorRepo },
|
|
4529
|
+
);
|
|
4530
|
+
if (!targets.length) return;
|
|
4531
|
+
console.error("Checking that the requested branches still exist on origin...");
|
|
4532
|
+
const missing = lc.missingBranchRefs(targets, refExists);
|
|
4533
|
+
if (missing.length) throw new Error(lc.missingBranchRefMessage(missing));
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4116
4536
|
async function cmdUp(args, projectDir = rootDir) {
|
|
4117
4537
|
ensureGcloud();
|
|
4118
4538
|
const flags = parseFlags(args);
|
|
@@ -4163,8 +4583,14 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4163
4583
|
}
|
|
4164
4584
|
}
|
|
4165
4585
|
|
|
4586
|
+
assertAddCanAuthorizeSomeone(cfg);
|
|
4587
|
+
|
|
4166
4588
|
checkScriptDrift(cfg);
|
|
4167
4589
|
|
|
4590
|
+
// Last of the pre-instance guards: a deleted branch ref is checkable in ~1s
|
|
4591
|
+
// and otherwise costs a full instance + boot + 13k-file clone to discover.
|
|
4592
|
+
assertBranchRefsExist(cfg);
|
|
4593
|
+
|
|
4168
4594
|
await ensureFirewallRule(cfg);
|
|
4169
4595
|
const { createdThisRun } = await ensureInstance(cfg);
|
|
4170
4596
|
|
|
@@ -5208,6 +5634,9 @@ async function cloudMain() {
|
|
|
5208
5634
|
module.exports = {
|
|
5209
5635
|
// pure helpers
|
|
5210
5636
|
isSpawnTimeout,
|
|
5637
|
+
gcloudCallTimeoutMs,
|
|
5638
|
+
provisionFailureMessage,
|
|
5639
|
+
GCLOUD_PROVISION_TIMEOUT_MS,
|
|
5211
5640
|
agentEnvVarName,
|
|
5212
5641
|
agentNpmPackage,
|
|
5213
5642
|
AGENT_ENV_VARS,
|
|
@@ -5243,6 +5672,7 @@ module.exports = {
|
|
|
5243
5672
|
resolveConfiguredAppPort,
|
|
5244
5673
|
resolveSeedPreviewOrigin,
|
|
5245
5674
|
previewSameOriginSafeFromStack,
|
|
5675
|
+
resolveEffectiveSameOriginSafe,
|
|
5246
5676
|
isRottedManagedPreviewOrigin,
|
|
5247
5677
|
isWrongModePreviewOrigin,
|
|
5248
5678
|
isSilentlyBrokenPreview,
|
|
@@ -5301,6 +5731,8 @@ module.exports = {
|
|
|
5301
5731
|
FORWARD_ENV_KEYS_ENV,
|
|
5302
5732
|
parseEditorComposeState,
|
|
5303
5733
|
fetchInstanceState,
|
|
5734
|
+
fetchPendingOperation,
|
|
5735
|
+
wedgedOperationMessage,
|
|
5304
5736
|
formatGcloudFailure,
|
|
5305
5737
|
isTransientSshFailure,
|
|
5306
5738
|
runGcloudSshWithRetry,
|
|
@@ -5320,6 +5752,8 @@ module.exports = {
|
|
|
5320
5752
|
ingressWiringPlan,
|
|
5321
5753
|
fleetInstancePrefix,
|
|
5322
5754
|
runVmIngress,
|
|
5755
|
+
intendedVmPrincipals,
|
|
5756
|
+
assertAddCanAuthorizeSomeone,
|
|
5323
5757
|
wireVmIngressOnAdd,
|
|
5324
5758
|
probeVmIngressVerdict,
|
|
5325
5759
|
convergeVmIngressOnAdd,
|
|
@@ -5356,6 +5790,8 @@ module.exports = {
|
|
|
5356
5790
|
readVmAppPort,
|
|
5357
5791
|
buildVmAppPortReadCommand,
|
|
5358
5792
|
resolveLaptopGitIdentity,
|
|
5793
|
+
gitBranchRefExists,
|
|
5794
|
+
assertBranchRefsExist,
|
|
5359
5795
|
};
|
|
5360
5796
|
|
|
5361
5797
|
if (require.main === module) {
|