@codeyam-editor/codeyam-editor 0.1.0-staging.g296f8a2 → 0.1.0-staging.g3858572
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 +308 -52
- package/npm/compose-conflict.js +59 -0
- package/npm/fleet-add-failure.js +59 -12
- package/npm/fleet-cleanup.js +129 -0
- package/npm/fleet-memory.js +29 -4
- package/npm/fleet-queue.js +141 -0
- package/npm/fleet-vm-access.js +162 -0
- package/npm/ingress-invoke.js +46 -2
- package/npm/launch-config.js +43 -0
- package/npm/scenario-check.js +63 -19
- package/npm/scenario-handlers.js +18 -0
- package/npm/scenario-issues.js +11 -0
- package/npm/scenario-mocks.js +245 -39
- package/npm/scenario-playwright.js +114 -22
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-B4reOSvu.js → ContainerInspector-DSVZvEkB.js} +1 -1
- package/ui/dist/assets/EditorShell-DnXdHrar.js +256 -0
- package/ui/dist/assets/EditorShell-FvbXyqqD.css +1 -0
- package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +15 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-aIivPUt9.js → ScenarioDataPanelFullPage-Cyk6efiI.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +21 -0
- package/ui/dist/assets/{index-D_Bz8tMJ.js → index-BPLKgwon.js} +9 -9
- package/ui/dist/assets/index-DKpgJLo3.css +1 -0
- package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-CxwZ5mHM.js +0 -252
- package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-wBhNIA9N.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-BhJNFcF2.js +0 -21
- package/ui/dist/assets/index-BygCiJSb.css +0 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.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,
|
|
@@ -105,6 +106,10 @@ const {
|
|
|
105
106
|
// edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
|
|
106
107
|
// fleet for `reconcile-ingress`.
|
|
107
108
|
const { addToRoster, readRoster } = require("./fleet-roster");
|
|
109
|
+
// Tells a docker container-name conflict (a concurrent run already started this
|
|
110
|
+
// container) apart from a real `docker compose up -d` failure, so the abort path
|
|
111
|
+
// can converge instead of escalating to instance deletion.
|
|
112
|
+
const { classifyComposeUpFailure } = require("./compose-conflict");
|
|
108
113
|
|
|
109
114
|
// =============================================================================
|
|
110
115
|
// Pure helpers — every export here is unit-tested in cloud.test.js without
|
|
@@ -845,6 +850,79 @@ function parseEditorComposeState(stdout) {
|
|
|
845
850
|
return null;
|
|
846
851
|
}
|
|
847
852
|
|
|
853
|
+
/** Decide whether `cloud:up`'s abort handler may delete the instance it is
|
|
854
|
+
* unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive }
|
|
855
|
+
* → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
|
|
856
|
+
* `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
|
|
857
|
+
* exhaustively testable without a VM.
|
|
858
|
+
*
|
|
859
|
+
* `createdThisRun` stays a NECESSARY condition — never delete an instance this
|
|
860
|
+
* run did not create — but 2026-08-07 proved it is not SUFFICIENT. It answers
|
|
861
|
+
* "did I create this?", not "is it safe to destroy?", and in a concurrent
|
|
862
|
+
* provision race the losing run legitimately owns the flag while the winner has
|
|
863
|
+
* already brought the VM up and rostered it. Deleting there destroyed VM-4 and
|
|
864
|
+
* VM-5. So authorization also requires CURRENT-STATE evidence that the VM is
|
|
865
|
+
* out of service:
|
|
866
|
+
*
|
|
867
|
+
* - `containerUp` — the editor container is running (per the compose-ps probe).
|
|
868
|
+
* A VM serving an editor is in use by definition.
|
|
869
|
+
* - `rostered` — the VM is in the fleet roster, the operator-visible source of
|
|
870
|
+
* truth. Note this is true whether the winning run or THIS run rostered it;
|
|
871
|
+
* either way the VM is visible in the dashboard and removable in one command,
|
|
872
|
+
* so it is not the silent orphan teardown exists to prevent.
|
|
873
|
+
* - `probeInconclusive` — liveness could not be determined. This must fail
|
|
874
|
+
* SAFE: a failing probe is how we reach the abort handler in the first place,
|
|
875
|
+
* so it can never count as evidence the VM is dead. An orphan costs one
|
|
876
|
+
* `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
|
|
877
|
+
* any unpushed work on the VM. */
|
|
878
|
+
function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive } = {}) {
|
|
879
|
+
if (!createdThisRun) return "keep";
|
|
880
|
+
if (probeInconclusive) return "keep";
|
|
881
|
+
if (containerUp) return "keep";
|
|
882
|
+
if (rostered) return "keep";
|
|
883
|
+
return "delete";
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Gather the CURRENT-STATE inputs {@link abortTeardownDecision} needs, so the
|
|
887
|
+
* abort handler reads gather → decide → act instead of inlining probes among
|
|
888
|
+
* the delete calls.
|
|
889
|
+
*
|
|
890
|
+
* Only runs the probes when `createdThisRun` is true — the decision is `keep`
|
|
891
|
+
* regardless otherwise, and an abort path should not spend SSH round trips
|
|
892
|
+
* proving something it already knows. In that case it returns the
|
|
893
|
+
* probe-inconclusive shape, which is the safe default at every layer.
|
|
894
|
+
*
|
|
895
|
+
* Both lookups are wrapped: a throwing probe or an unreadable roster must fail
|
|
896
|
+
* SAFE (toward `keep`), never take a second exception path into the delete.
|
|
897
|
+
* `deps` is injectable so the matrix is unit-tested without a VM. */
|
|
898
|
+
function probeAbortTeardownState(cfg, createdThisRun, deps = {}) {
|
|
899
|
+
if (!createdThisRun) {
|
|
900
|
+
return { containerUp: false, rostered: false, probeInconclusive: true };
|
|
901
|
+
}
|
|
902
|
+
const probe = deps.probeEditorComposeState || probeEditorComposeState;
|
|
903
|
+
const roster = deps.readRoster || readRoster;
|
|
904
|
+
const resolveVm = deps.resolveFleetVmNumber || resolveFleetVmNumber;
|
|
905
|
+
const log = deps.log || ((m) => console.error(m));
|
|
906
|
+
|
|
907
|
+
let liveness = { state: null, inconclusive: true };
|
|
908
|
+
try {
|
|
909
|
+
liveness = probe(cfg);
|
|
910
|
+
} catch (e) {
|
|
911
|
+
log(` (liveness probe failed: ${e && e.message})`);
|
|
912
|
+
}
|
|
913
|
+
let rostered = false;
|
|
914
|
+
try {
|
|
915
|
+
const vm = resolveVm(cfg.instanceName);
|
|
916
|
+
rostered = vm != null && (roster() || []).includes(vm);
|
|
917
|
+
} catch { /* unreadable roster — treated as not-rostered; liveness still guards */ }
|
|
918
|
+
|
|
919
|
+
return {
|
|
920
|
+
containerUp: liveness.state === "running",
|
|
921
|
+
rostered,
|
|
922
|
+
probeInconclusive: liveness.inconclusive,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
848
926
|
/** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
|
|
849
927
|
* switch's job is to keep every per-verb command shape in one reviewable
|
|
850
928
|
* place — tests assert the arrays without ever spawning a process. */
|
|
@@ -2049,20 +2127,65 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
|
2049
2127
|
}
|
|
2050
2128
|
}
|
|
2051
2129
|
|
|
2052
|
-
|
|
2130
|
+
/** Run the compose-ps liveness probe over SSH and report BOTH the editor
|
|
2131
|
+
* service's state and whether the probe itself could be trusted.
|
|
2132
|
+
*
|
|
2133
|
+
* The `|| true` keeps the remote pipeline's exit status 0 whenever SSH itself
|
|
2134
|
+
* worked, so a non-zero `status` means the transport failed (auth/SSH/IAP) —
|
|
2135
|
+
* not that the container is down. Callers that must fail safe (the abort
|
|
2136
|
+
* teardown) need that distinction; `parseEditorComposeState` alone collapses
|
|
2137
|
+
* "no container" and "could not ask" into the same `null`.
|
|
2138
|
+
*
|
|
2139
|
+
* Returns `{ state, inconclusive }` where `state` is the compose `State` string
|
|
2140
|
+
* (`"running"`, `"exited"`, …) or null when the service is absent, and
|
|
2141
|
+
* `inconclusive` is true only for a transport failure. */
|
|
2142
|
+
function probeEditorComposeState(cfg) {
|
|
2053
2143
|
const repoName = resolveProject(cfg).name;
|
|
2054
|
-
// -f docker-compose.cloud.yml everywhere: the editor repo's own
|
|
2055
|
-
// docker-compose.yml does `build: .` from source and would shadow the
|
|
2056
|
-
// cloud variant when self-hosting (see gcp-project-bootstrap.sh).
|
|
2057
2144
|
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
2058
|
-
console.error("Checking editor container state...");
|
|
2059
2145
|
const probe = runGcloud(
|
|
2060
2146
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
2061
|
-
command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
2147
|
+
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
2062
2148
|
}),
|
|
2063
2149
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2064
2150
|
);
|
|
2065
|
-
if (probe.status
|
|
2151
|
+
if (probe.status !== 0) {
|
|
2152
|
+
return { state: null, inconclusive: true };
|
|
2153
|
+
}
|
|
2154
|
+
return { state: parseEditorComposeState(probe.stdout), inconclusive: false };
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/** Poll {@link probeEditorComposeState} until the editor service reads
|
|
2158
|
+
* `running`, returning true as soon as it does. Used to close the TOCTOU window
|
|
2159
|
+
* after a container-name conflict: the winning run may still be inside
|
|
2160
|
+
* `Creating`/`Created` when the conflict surfaces, so a single immediate probe
|
|
2161
|
+
* can under-read and report a healthy container as absent.
|
|
2162
|
+
*
|
|
2163
|
+
* Deliberately short (default ~30s): this is not the multi-minute first-boot
|
|
2164
|
+
* budget `waitForContainerRunning` grants, it is the tail of a start that
|
|
2165
|
+
* another process has already begun. `sleepFn` and `probeFn` are injectable so
|
|
2166
|
+
* a unit test drives the whole loop with neither wall-clock delay nor a VM. */
|
|
2167
|
+
async function waitForEditorComposeRunning(cfg, { attempts = 6, delayMs = 5000, sleepFn, probeFn } = {}) {
|
|
2168
|
+
const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
2169
|
+
const probe = probeFn || probeEditorComposeState;
|
|
2170
|
+
for (let i = 0; i < attempts; i++) {
|
|
2171
|
+
if (probe(cfg).state === "running") {
|
|
2172
|
+
return true;
|
|
2173
|
+
}
|
|
2174
|
+
if (i < attempts - 1) {
|
|
2175
|
+
await sleep(delayMs);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
return false;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
async function ensureEditorContainerUp(cfg) {
|
|
2182
|
+
const repoName = resolveProject(cfg).name;
|
|
2183
|
+
// -f docker-compose.cloud.yml everywhere: the editor repo's own
|
|
2184
|
+
// docker-compose.yml does `build: .` from source and would shadow the
|
|
2185
|
+
// cloud variant when self-hosting (see gcp-project-bootstrap.sh).
|
|
2186
|
+
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
2187
|
+
console.error("Checking editor container state...");
|
|
2188
|
+
if (probeEditorComposeState(cfg).state === "running") {
|
|
2066
2189
|
console.error(" ✓ editor container is already running");
|
|
2067
2190
|
// containerStarted: the container is confirmed up, so the CLI install runs
|
|
2068
2191
|
// its POST-start path with no wait — see the precondition on ensureAgentCliInstalled.
|
|
@@ -2070,14 +2193,47 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
2070
2193
|
return;
|
|
2071
2194
|
}
|
|
2072
2195
|
console.error(" starting editor container (docker compose up -d)...");
|
|
2196
|
+
// CAPTURE rather than `stdio: "inherit"`: the daemon writes its container-name
|
|
2197
|
+
// conflict to stderr, and with "inherit" that text went straight to our parent
|
|
2198
|
+
// and was never observable here — so the conflict could not be told apart from
|
|
2199
|
+
// a real failure (the 2026-08-07 VM-4/VM-5 delete). spawnSync cannot both
|
|
2200
|
+
// stream and capture, so the operator's view is preserved by echoing the
|
|
2201
|
+
// captured streams immediately below instead of live. Do NOT revert this to
|
|
2202
|
+
// "inherit"; classification depends on it.
|
|
2073
2203
|
const up = runGcloud(
|
|
2074
2204
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
2075
2205
|
command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
|
|
2076
2206
|
}),
|
|
2077
|
-
{ stdio: "
|
|
2207
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
2078
2208
|
);
|
|
2209
|
+
if (up.stdout) process.stderr.write(up.stdout);
|
|
2210
|
+
if (up.stderr) process.stderr.write(up.stderr);
|
|
2079
2211
|
if (up.status !== 0) {
|
|
2080
|
-
|
|
2212
|
+
const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
|
|
2213
|
+
if (verdict !== "name-conflict") {
|
|
2214
|
+
throw new Error("docker compose up -d failed on the VM");
|
|
2215
|
+
}
|
|
2216
|
+
// A name conflict means a concurrent run already created this container —
|
|
2217
|
+
// the goal we were about to pursue is being reached by someone else. Confirm
|
|
2218
|
+
// that positively before converging: a conflict whose container never
|
|
2219
|
+
// reaches `running` (e.g. a stale STOPPED container squatting the name) is a
|
|
2220
|
+
// genuine problem and must still fail loudly, or convergence becomes a
|
|
2221
|
+
// catch-all that papers over real breakage.
|
|
2222
|
+
console.error(
|
|
2223
|
+
" a concurrent run already created the editor container — confirming it reaches running...",
|
|
2224
|
+
);
|
|
2225
|
+
if (!(await waitForEditorComposeRunning(cfg))) {
|
|
2226
|
+
throw new Error(
|
|
2227
|
+
"docker compose up -d hit a container-name conflict on the VM, but the conflicting " +
|
|
2228
|
+
"container never reached running (a stale stopped container may be holding the name). " +
|
|
2229
|
+
`Inspect with: gcloud compute ssh ${cfg.instanceName} --command 'cd ~/projects/${repoName} && ${composeCmd} ps -a'`,
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
console.error(" ✓ editor container is running (started by a concurrent run)");
|
|
2233
|
+
// containerStarted: the container is confirmed up — same post-start contract
|
|
2234
|
+
// as the already-running short-circuit above.
|
|
2235
|
+
await ensureAgentCliInstalled(cfg, { containerStarted: true });
|
|
2236
|
+
return;
|
|
2081
2237
|
}
|
|
2082
2238
|
// containerStarted: `up -d` returned 0, so the CLI install is legitimately a
|
|
2083
2239
|
// post-start step. A brief wait-for-running here (slow boot settling) is
|
|
@@ -2093,18 +2249,10 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
2093
2249
|
// success. This throws (non-zero exit) when the editor service isn't
|
|
2094
2250
|
// `running`, converting a silent partial provision into a loud, actionable
|
|
2095
2251
|
// failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
|
|
2096
|
-
// on.
|
|
2097
|
-
//
|
|
2252
|
+
// on. An inconclusive probe is not "running" here — a terminal gate must have
|
|
2253
|
+
// positive evidence, and the caller's job is to fail loudly without it.
|
|
2098
2254
|
async function assertEditorContainerRunning(cfg) {
|
|
2099
|
-
|
|
2100
|
-
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
2101
|
-
const probe = runGcloud(
|
|
2102
|
-
buildGcloudArgs("ssh-probe", cfg, {
|
|
2103
|
-
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
2104
|
-
}),
|
|
2105
|
-
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2106
|
-
);
|
|
2107
|
-
if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
|
|
2255
|
+
if (probeEditorComposeState(cfg).state !== "running") {
|
|
2108
2256
|
throw new Error(
|
|
2109
2257
|
`provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
|
|
2110
2258
|
`(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
|
|
@@ -3461,8 +3609,9 @@ function fleetInstancePrefix() {
|
|
|
3461
3609
|
* scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
|
|
3462
3610
|
* result so callers decide fatality — Add treats a non-zero as a loud warning
|
|
3463
3611
|
* (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
|
|
3464
|
-
* teardown still removes the instance.
|
|
3465
|
-
|
|
3612
|
+
* teardown still removes the instance. `vmAccess` (up only) is the Add's intended
|
|
3613
|
+
* principal set, which the script's grant step uses in place of its sidecar read. */
|
|
3614
|
+
function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
|
|
3466
3615
|
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3467
3616
|
const inv = vmIngressInvocation({
|
|
3468
3617
|
scriptPath,
|
|
@@ -3474,6 +3623,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
|
|
|
3474
3623
|
appPort,
|
|
3475
3624
|
instancePrefix: fleetInstancePrefix(),
|
|
3476
3625
|
slug,
|
|
3626
|
+
vmAccess,
|
|
3477
3627
|
});
|
|
3478
3628
|
return spawnSync(inv.command, inv.args, {
|
|
3479
3629
|
stdio: "inherit",
|
|
@@ -3504,17 +3654,77 @@ function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
|
|
|
3504
3654
|
});
|
|
3505
3655
|
}
|
|
3506
3656
|
|
|
3657
|
+
/** THE principal set that must have IAP access to this VM after the Add: the
|
|
3658
|
+
* explicit `cfg.vmAccess` collaborators unioned with the resolved default
|
|
3659
|
+
* operator. Both add-time access paths consume this ONE function —
|
|
3660
|
+
* `wireVmIngressOnAdd` (which hands it to vm-ingress.sh's grant) and
|
|
3661
|
+
* `applyVmAccessOnAdd` (which grants it and subtracts it from the stale-revoke
|
|
3662
|
+
* set). They used to decide independently, and that is precisely how one path
|
|
3663
|
+
* granted `user:jared@codeyam.com` at 00:09:31 and the other revoked the same
|
|
3664
|
+
* principal 18 seconds later as a "stale prior tenant" (VMs 4/5/6, 2026-08-09):
|
|
3665
|
+
* the ingress path read the SIDECAR (still holding the prior tenant) while
|
|
3666
|
+
* `applyVmAccessOnAdd` resolved an empty operator. One source of truth removes
|
|
3667
|
+
* the whole class of disagreement. `env` is injectable for tests; the only I/O
|
|
3668
|
+
* is `resolveDefaultOperator`'s config/gcloud read. */
|
|
3669
|
+
function intendedVmPrincipals(cfg = {}, env = process.env) {
|
|
3670
|
+
return withDefaultOperator(
|
|
3671
|
+
normalizeAccessList(cfg.vmAccess || []),
|
|
3672
|
+
resolveDefaultOperator({ env }),
|
|
3673
|
+
);
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
/** Add preflight: throw — BEFORE any GCE instance exists — when this Add would
|
|
3677
|
+
* come up on an IAP-gated public URL that authorizes NOBODY. The whole condition
|
|
3678
|
+
* is env + config (`intendedVmPrincipals` needs no GCP state), which is why it
|
|
3679
|
+
* belongs beside cmdUp's other cheap boundary validations rather than after a
|
|
3680
|
+
* machine has been provisioned: checking later means a half-provisioned VM to
|
|
3681
|
+
* clean up. On 2026-08-09 the un-refused version of this Add reported `done` for
|
|
3682
|
+
* VMs 4/5/6, left three unreachable URLs, and revoked each slot's prior tenant on
|
|
3683
|
+
* the way.
|
|
3684
|
+
*
|
|
3685
|
+
* Only fires in LB mode on a numbered fleet VM with nothing to grant; an explicit
|
|
3686
|
+
* `--vm-access` satisfies it with no default operator configured, so the
|
|
3687
|
+
* collaborator flow is unaffected, as is every legacy quick-tunnel Add. The
|
|
3688
|
+
* refusal text (and its `Next valid action:` remedy naming all three config
|
|
3689
|
+
* tiers) is the pure `unauthorizableAddRefusal`; this function is the composition
|
|
3690
|
+
* and the throw. */
|
|
3691
|
+
function assertAddCanAuthorizeSomeone(cfg, env = process.env) {
|
|
3692
|
+
let plan;
|
|
3693
|
+
try {
|
|
3694
|
+
plan = ingressWiringPlan(cfg, env);
|
|
3695
|
+
} catch {
|
|
3696
|
+
// A malformed domain is `resolveIngressDomain`'s error to raise on the wiring
|
|
3697
|
+
// path; it must not masquerade here as an authorization refusal.
|
|
3698
|
+
plan = { wire: false };
|
|
3699
|
+
}
|
|
3700
|
+
const refusal = unauthorizableAddRefusal({
|
|
3701
|
+
lbMode: !!plan.wire,
|
|
3702
|
+
vmNumber: plan.wire ? plan.vmNumber : null,
|
|
3703
|
+
intendedCount: plan.wire ? intendedVmPrincipals(cfg, env).length : null,
|
|
3704
|
+
});
|
|
3705
|
+
if (refusal) throw new Error(refusal);
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3507
3708
|
/** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
|
|
3508
3709
|
* Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
|
|
3509
3710
|
* (the VM is reachable operator-side over SSH regardless), so on failure it
|
|
3510
3711
|
* prints the exact re-run command rather than throwing. No-op (and silent) in
|
|
3511
|
-
* legacy quick-tunnel mode or for a non-fleet client project.
|
|
3712
|
+
* legacy quick-tunnel mode or for a non-fleet client project.
|
|
3713
|
+
*
|
|
3714
|
+
* RETURNS the principals this call granted (`intendedVmPrincipals`), or [] when
|
|
3715
|
+
* it did not wire — the explicit dataflow `applyVmAccessOnAdd` needs to know what
|
|
3716
|
+
* the Add has already granted before it computes a stale-revoke. Returned on the
|
|
3717
|
+
* FAILURE path too: a non-zero `vm-ingress.sh up` may still have completed its
|
|
3718
|
+
* grant step, and a principal that was possibly granted must never be revoked. */
|
|
3512
3719
|
function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
|
|
3513
3720
|
const plan = ingressWiringPlan(cfg, env);
|
|
3514
|
-
if (!plan.wire) return;
|
|
3721
|
+
if (!plan.wire) return [];
|
|
3515
3722
|
const label = plan.slug || `vm-${plan.vmNumber}`;
|
|
3723
|
+
// Hand the intended set to the script so its `apply_default_iap_access` grants
|
|
3724
|
+
// THIS Add's principals instead of reading the sidecar's prior tenant.
|
|
3725
|
+
const vmAccess = intendedVmPrincipals(cfg, env);
|
|
3516
3726
|
console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
|
|
3517
|
-
const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
|
|
3727
|
+
const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug, vmAccess);
|
|
3518
3728
|
if (res.status === 0) {
|
|
3519
3729
|
console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
|
|
3520
3730
|
} else {
|
|
@@ -3536,6 +3746,7 @@ function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
|
|
|
3536
3746
|
`(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
|
|
3537
3747
|
);
|
|
3538
3748
|
}
|
|
3749
|
+
return vmAccess;
|
|
3539
3750
|
}
|
|
3540
3751
|
|
|
3541
3752
|
/** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
|
|
@@ -3604,8 +3815,15 @@ function runIapAccess(cfg, vmNumber, action, principals) {
|
|
|
3604
3815
|
* a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
|
|
3605
3816
|
* command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
|
|
3606
3817
|
* array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
|
|
3607
|
-
* list (the clean-slate case).
|
|
3608
|
-
|
|
3818
|
+
* list (the clean-slate case).
|
|
3819
|
+
*
|
|
3820
|
+
* `grantedThisRun` is what an EARLIER step of this same Add already granted —
|
|
3821
|
+
* `wireVmIngressOnAdd`'s return value. It is subtracted from the stale-revoke
|
|
3822
|
+
* set, so a principal this Add just authorized can never be revoked moments later
|
|
3823
|
+
* as a prior tenant. That is a second, cheaper guard than the shared
|
|
3824
|
+
* `intendedVmPrincipals` resolver above: it holds even if the two paths drift
|
|
3825
|
+
* apart again, which is exactly how VMs 4/5/6 broke on 2026-08-09. */
|
|
3826
|
+
function applyVmAccessOnAdd(cfg, env = process.env, { grantedThisRun = [] } = {}) {
|
|
3609
3827
|
const desired = normalizeAccessList(cfg.vmAccess || []);
|
|
3610
3828
|
let plan;
|
|
3611
3829
|
try {
|
|
@@ -3622,11 +3840,15 @@ function applyVmAccessOnAdd(cfg, env = process.env) {
|
|
|
3622
3840
|
// never inaccessible-by-default (the Error-51 footgun). The default operator is
|
|
3623
3841
|
// NOT persisted to the sidecar (it's a standing grant, not a per-VM
|
|
3624
3842
|
// collaborator), so `desired` alone drives the sidecar + stale-revoke below.
|
|
3625
|
-
const toGrant =
|
|
3843
|
+
const toGrant = intendedVmPrincipals(cfg, env);
|
|
3626
3844
|
const stale = accessFor(vmNumber);
|
|
3627
3845
|
// Never revoke a principal we're about to (re-)grant — a prior tenant who was
|
|
3628
|
-
// also the default operator must not be revoked-then-regranted for nothing
|
|
3629
|
-
|
|
3846
|
+
// also the default operator must not be revoked-then-regranted for nothing —
|
|
3847
|
+
// NOR one an earlier step of this same Add already granted. `toGrant` alone
|
|
3848
|
+
// protected nothing when it was empty: the ingress step had granted the operator
|
|
3849
|
+
// seconds earlier and this revoke took it straight back off (VMs 4/5/6).
|
|
3850
|
+
const grantedAlready = new Set([...toGrant, ...normalizeAccessList(grantedThisRun || [])]);
|
|
3851
|
+
const staleOnly = stale.filter((p) => !grantedAlready.has(p));
|
|
3630
3852
|
|
|
3631
3853
|
if (plan.wire) {
|
|
3632
3854
|
if (staleOnly.length > 0) {
|
|
@@ -3743,12 +3965,23 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3743
3965
|
}
|
|
3744
3966
|
if (!plan.wire) {
|
|
3745
3967
|
// Nothing to converge — the no-op wire keeps the clean-slate invariant.
|
|
3746
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
3747
|
-
return { wired: true, reason: "no-wire" };
|
|
3748
|
-
}
|
|
3749
|
-
|
|
3968
|
+
const granted = wireVmIngressOnAdd(cfg, appPort, env);
|
|
3969
|
+
return { wired: true, reason: "no-wire", grantedThisRun: granted };
|
|
3970
|
+
}
|
|
3971
|
+
|
|
3972
|
+
// The intended set, resolved ONCE and handed to every `up` attempt so each
|
|
3973
|
+
// converge iteration grants the same principals — and reported back to the
|
|
3974
|
+
// caller as `grantedThisRun` so the access reconcile that follows cannot revoke
|
|
3975
|
+
// them. Every `up` runs the script's grant step, so these are granted whether
|
|
3976
|
+
// the loop converges or hits its deadline. Injectable via `deps` like the rest
|
|
3977
|
+
// of this loop's slow I/O: the resolve spawns `gcloud config get-value account`
|
|
3978
|
+
// on its last tier, and a unit test must never depend on the gcloud identity
|
|
3979
|
+
// that happens to be active on the machine running it.
|
|
3980
|
+
const grantedThisRun =
|
|
3981
|
+
deps.grantedThisRun != null ? deps.grantedThisRun : intendedVmPrincipals(cfg, env);
|
|
3750
3982
|
const runUp =
|
|
3751
|
-
deps.runUp
|
|
3983
|
+
deps.runUp
|
|
3984
|
+
|| ((p) => runVmIngress(cfg, "up", p.vmNumber, p.domain, appPort, p.slug, grantedThisRun));
|
|
3752
3985
|
const probeVerdict = deps.probeVerdict || ((p) => probeVmIngressVerdict(cfg, p));
|
|
3753
3986
|
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
3754
3987
|
const now = deps.now || (() => Date.now());
|
|
@@ -3791,7 +4024,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3791
4024
|
if (decision.done) {
|
|
3792
4025
|
if (decision.wired) {
|
|
3793
4026
|
console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
|
|
3794
|
-
return { wired: true, reason: "wired", attempts: attempt + 1 };
|
|
4027
|
+
return { wired: true, reason: "wired", attempts: attempt + 1, grantedThisRun };
|
|
3795
4028
|
}
|
|
3796
4029
|
const rewire =
|
|
3797
4030
|
`FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
|
|
@@ -3808,7 +4041,7 @@ async function convergeVmIngressOnAdd(cfg, appPort, env = process.env, deps = {}
|
|
|
3808
4041
|
`CY_DEGRADED ingress-wiring-failed: VM-${plan.vmNumber} public URL did not wire ` +
|
|
3809
4042
|
`within the add window (last verdict=${verdict || "unknown"}). Re-wire: ${rewire}`,
|
|
3810
4043
|
);
|
|
3811
|
-
return { wired: false, reason: "deadline", attempts: attempt + 1 };
|
|
4044
|
+
return { wired: false, reason: "deadline", attempts: attempt + 1, grantedThisRun };
|
|
3812
4045
|
}
|
|
3813
4046
|
attempt += 1;
|
|
3814
4047
|
await sleep(decision.delayMs);
|
|
@@ -3837,8 +4070,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
|
|
|
3837
4070
|
if (!plan.wire) {
|
|
3838
4071
|
// Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
|
|
3839
4072
|
// sidecar write. Run them inline so the clean-slate invariant still holds.
|
|
3840
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
3841
|
-
applyVmAccessOnAdd(cfg, env);
|
|
4073
|
+
const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
|
|
4074
|
+
applyVmAccessOnAdd(cfg, env, { grantedThisRun });
|
|
3842
4075
|
return;
|
|
3843
4076
|
}
|
|
3844
4077
|
const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
|
|
@@ -3870,8 +4103,8 @@ function startDetachedAddWiring(cfg, appPort, env = process.env) {
|
|
|
3870
4103
|
`running it inline instead.`,
|
|
3871
4104
|
);
|
|
3872
4105
|
try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
|
|
3873
|
-
wireVmIngressOnAdd(cfg, appPort, env);
|
|
3874
|
-
applyVmAccessOnAdd(cfg, env);
|
|
4106
|
+
const grantedThisRun = wireVmIngressOnAdd(cfg, appPort, env);
|
|
4107
|
+
applyVmAccessOnAdd(cfg, env, { grantedThisRun });
|
|
3875
4108
|
}
|
|
3876
4109
|
}
|
|
3877
4110
|
|
|
@@ -3903,8 +4136,11 @@ async function cmdWireAddTail() {
|
|
|
3903
4136
|
// Converge the URL to `verdict=wired` (retry-until-wired), not a single-shot
|
|
3904
4137
|
// `up` left to the slow reconcile sweep — then the IAP access grant, in the
|
|
3905
4138
|
// SAME order cmdUp ran them inline.
|
|
3906
|
-
|
|
3907
|
-
|
|
4139
|
+
// Carry what the converge granted into the access reconcile, so the revoke
|
|
4140
|
+
// step cannot take back a principal this same Add just authorized — the exact
|
|
4141
|
+
// grant-then-revoke-18s-later sequence `/tmp/cy-wire-add-vm-4.log` recorded.
|
|
4142
|
+
const { grantedThisRun } = await convergeVmIngressOnAdd(cfg, appPort);
|
|
4143
|
+
applyVmAccessOnAdd(cfg, process.env, { grantedThisRun });
|
|
3908
4144
|
} finally {
|
|
3909
4145
|
if (marker) {
|
|
3910
4146
|
try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
|
|
@@ -4016,6 +4252,8 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4016
4252
|
}
|
|
4017
4253
|
}
|
|
4018
4254
|
|
|
4255
|
+
assertAddCanAuthorizeSomeone(cfg);
|
|
4256
|
+
|
|
4019
4257
|
checkScriptDrift(cfg);
|
|
4020
4258
|
|
|
4021
4259
|
await ensureFirewallRule(cfg);
|
|
@@ -4122,7 +4360,27 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4122
4360
|
);
|
|
4123
4361
|
}
|
|
4124
4362
|
} catch (err) {
|
|
4125
|
-
|
|
4363
|
+
// `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
|
|
4364
|
+
// VM is in right now, then let the pure decision rule — see
|
|
4365
|
+
// abortTeardownDecision's doc comment for why each input is load-bearing.
|
|
4366
|
+
const state = probeAbortTeardownState(cfg, createdThisRun);
|
|
4367
|
+
const verdict = abortTeardownDecision({ createdThisRun, ...state });
|
|
4368
|
+
if (createdThisRun && verdict === "keep") {
|
|
4369
|
+
// Deliberately loud and specific. The old blanket "tearing down the VM
|
|
4370
|
+
// cloud:up created this run (no orphan left behind)" was actively
|
|
4371
|
+
// misleading here — it asserted no orphan while deleting a working VM.
|
|
4372
|
+
const why = state.probeInconclusive
|
|
4373
|
+
? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
|
|
4374
|
+
: state.containerUp
|
|
4375
|
+
? "its editor container is RUNNING — another run brought it up"
|
|
4376
|
+
: "it is in the fleet roster and visible to the dashboard";
|
|
4377
|
+
console.error(
|
|
4378
|
+
`✗ provisioning aborted — the VM ${cfg.instanceName} was LEFT IN PLACE because ${why}.\n` +
|
|
4379
|
+
` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
|
|
4380
|
+
` If it really should go, remove it deliberately: npm run cloud:destroy`,
|
|
4381
|
+
);
|
|
4382
|
+
}
|
|
4383
|
+
if (verdict === "delete") {
|
|
4126
4384
|
console.error(
|
|
4127
4385
|
`✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
|
|
4128
4386
|
);
|
|
@@ -4304,14 +4562,7 @@ async function cmdStatus(args, projectDir = rootDir) {
|
|
|
4304
4562
|
const imgStatus = String(imgProbe.stdout || "").trim() || "?";
|
|
4305
4563
|
console.error(` image: ${imgStatus}`);
|
|
4306
4564
|
|
|
4307
|
-
const
|
|
4308
|
-
const containerProbe = runGcloud(
|
|
4309
|
-
buildGcloudArgs("ssh-probe", cfg, {
|
|
4310
|
-
command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
|
|
4311
|
-
}),
|
|
4312
|
-
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
4313
|
-
);
|
|
4314
|
-
const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
|
|
4565
|
+
const containerStatus = probeEditorComposeState(cfg).state || "missing";
|
|
4315
4566
|
console.error(` editor container: ${containerStatus}`);
|
|
4316
4567
|
|
|
4317
4568
|
const appPort = await readVmAppPort(cfg);
|
|
@@ -5058,6 +5309,9 @@ module.exports = {
|
|
|
5058
5309
|
assertAgentCliInstalled,
|
|
5059
5310
|
probeContainerRunning,
|
|
5060
5311
|
probeContainerState,
|
|
5312
|
+
waitForEditorComposeRunning,
|
|
5313
|
+
abortTeardownDecision,
|
|
5314
|
+
probeAbortTeardownState,
|
|
5061
5315
|
probeAgentBinaryInstalled,
|
|
5062
5316
|
waitForContainerRunning,
|
|
5063
5317
|
KNOWN_FLAGS,
|
|
@@ -5157,6 +5411,8 @@ module.exports = {
|
|
|
5157
5411
|
ingressWiringPlan,
|
|
5158
5412
|
fleetInstancePrefix,
|
|
5159
5413
|
runVmIngress,
|
|
5414
|
+
intendedVmPrincipals,
|
|
5415
|
+
assertAddCanAuthorizeSomeone,
|
|
5160
5416
|
wireVmIngressOnAdd,
|
|
5161
5417
|
probeVmIngressVerdict,
|
|
5162
5418
|
convergeVmIngressOnAdd,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/compose-conflict.js — classify a failed `docker compose up -d` on a fleet
|
|
4
|
+
// VM, so `cloud:up` can tell "another run beat me to this container" apart from
|
|
5
|
+
// "provisioning genuinely broke".
|
|
6
|
+
//
|
|
7
|
+
// Why this exists: on 2026-08-07 two concurrent `cloud:up` runs raced to start
|
|
8
|
+
// the editor container on one fresh VM. The loser's `up -d` exited non-zero with
|
|
9
|
+
// the docker daemon's container-name conflict, `cloud.js` flattened that into a
|
|
10
|
+
// generic "docker compose up -d failed on the VM", and the abort handler
|
|
11
|
+
// escalated it all the way to deleting a VM the winner had already brought up
|
|
12
|
+
// and rostered. A name conflict is the opposite of a failure — it is proof the
|
|
13
|
+
// shared goal (container running) was reached by someone else.
|
|
14
|
+
//
|
|
15
|
+
// Matching a daemon error string is exactly the kind of thing that rots when
|
|
16
|
+
// docker rephrases it, so it lives here as a named, unit-tested function with
|
|
17
|
+
// the real observed message pinned as a fixture — not as an inline regex at the
|
|
18
|
+
// call site.
|
|
19
|
+
|
|
20
|
+
// The stable fragments of the daemon's conflict message. The container id and
|
|
21
|
+
// name vary per run and docker's exact phrasing has changed across versions, so
|
|
22
|
+
// we require all three fragments rather than the whole sentence. Observed
|
|
23
|
+
// verbatim in the VM-4 add log:
|
|
24
|
+
//
|
|
25
|
+
// Error response from daemon: Conflict. The container name
|
|
26
|
+
// "/codeyam-editor-editor-1" is already in use by container "c9d448c6…".
|
|
27
|
+
// You have to remove (or rename) that container to be able to reuse that name.
|
|
28
|
+
//
|
|
29
|
+
// Compose emits it twice (once prefixed `service:editor:1`, once bare) and
|
|
30
|
+
// interleaves its own progress lines, so the match is substring-based over the
|
|
31
|
+
// whole captured stream rather than line- or anchor-based.
|
|
32
|
+
const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
|
|
33
|
+
|
|
34
|
+
/** Classify the outcome of `docker compose up -d`.
|
|
35
|
+
*
|
|
36
|
+
* Returns one of:
|
|
37
|
+
* - `'ok'` — the command succeeded; there is no failure to classify. Guards
|
|
38
|
+
* against a caller that classifies unconditionally, so a zero status can
|
|
39
|
+
* never be read as a conflict.
|
|
40
|
+
* - `'name-conflict'` — the daemon refused because a container of that name
|
|
41
|
+
* already exists, i.e. a concurrent run already started it. The caller
|
|
42
|
+
* re-probes for a running container and converges instead of throwing.
|
|
43
|
+
* - `'failed'` — anything else. Existing behavior: a real provisioning
|
|
44
|
+
* failure.
|
|
45
|
+
*
|
|
46
|
+
* Pure: `{ status, stderr } → tag`, with no I/O, so the whole matrix is
|
|
47
|
+
* testable without a VM. `stderr` may be null/undefined (the pre-fix call site
|
|
48
|
+
* captured nothing), which classifies as `'failed'` — the conservative
|
|
49
|
+
* direction, since converging requires positive evidence of a conflict. */
|
|
50
|
+
function classifyComposeUpFailure({ status, stderr } = {}) {
|
|
51
|
+
if (status === 0) return 'ok';
|
|
52
|
+
const text = String(stderr == null ? '' : stderr);
|
|
53
|
+
if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
|
|
54
|
+
return 'name-conflict';
|
|
55
|
+
}
|
|
56
|
+
return 'failed';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { classifyComposeUpFailure };
|