@codeyam-editor/codeyam-editor 0.1.12 → 0.1.13-staging.g3c6e0c9
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/app-port-drift.js +169 -0
- package/npm/cloud.js +269 -37
- package/npm/compose-conflict.js +34 -1
- package/npm/fleet-add-failure.js +99 -6
- package/npm/fleet-api-budget.js +105 -0
- package/npm/fleet-job-badge.js +5 -2
- package/npm/ingress-invoke.js +11 -2
- package/npm/launch-config.js +125 -4
- package/npm/scenario-playwright.js +4 -1
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-B86bl6-K.js → ContainerInspector-DKH4LjcY.js} +1 -1
- package/ui/dist/assets/EditorShell-BwNMzsnF.js +286 -0
- package/ui/dist/assets/ScenarioDataPanel-CtYXcbIr.js +21 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-Dy8mPj3e.js → ScenarioDataPanelFullPage-DrhY8Okl.js} +1 -1
- package/ui/dist/assets/{ScenarioRenderer-CmcR8ciQ.js → ScenarioRenderer-kBIhG1qo.js} +3 -3
- package/ui/dist/assets/index-4TZsSjHA.css +1 -0
- package/ui/dist/assets/{index-maa9OUlU.js → index-mgNPDFlH.js} +2 -2
- package/ui/dist/assets/useEvents-DNb1olaj.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-BLCavSsd.js +0 -284
- package/ui/dist/assets/ScenarioDataPanel-BOz-4rZe.js +0 -21
- package/ui/dist/assets/index-D2tD0_8V.css +0 -1
- package/ui/dist/assets/useEvents-S3Qk1Y_d.js +0 -1
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Detect a fleet VM whose app port drifted away from the port its container
|
|
4
|
+
// publishes on the host.
|
|
5
|
+
//
|
|
6
|
+
// A VM's compose render publishes the app port resolved ONCE at bootstrap
|
|
7
|
+
// (`apps[0].port ?? port ?? 5173`). When the project later moves its app port
|
|
8
|
+
// in-repo, the editor's preview proxy follows the new port while the host keeps
|
|
9
|
+
// publishing the old one: docker-proxy accepts the load balancer's connection
|
|
10
|
+
// and immediately resets it, and every in-container probe reads green because
|
|
11
|
+
// the editor probes its own proxy on the new port. The only comparison that can
|
|
12
|
+
// see it is DECLARED (editor.json + editor.local.json) against PUBLISHED (what
|
|
13
|
+
// the running container actually maps on the host) — that is what this module
|
|
14
|
+
// does. It never repairs: changing the publication means recreating the
|
|
15
|
+
// container, which drops every live agent session.
|
|
16
|
+
//
|
|
17
|
+
// Pure helpers plus an injectable collector, so the reconcile and `vm-ingress.sh
|
|
18
|
+
// status` share one rule and the unit tests never reach for SSH.
|
|
19
|
+
|
|
20
|
+
/** The editor's control port. Pinned by contract, so on a VM's editor container
|
|
21
|
+
* the app surface is the OTHER published mapping — the same disambiguation
|
|
22
|
+
* `rcc_recover_ports` (scripts/lib/render-cloud-compose.sh) makes. */
|
|
23
|
+
const EDITOR_CONTROL_PORT = 14199;
|
|
24
|
+
|
|
25
|
+
const PORT_DRIFT_RUNBOOK = "docs/preview-port-drift.md";
|
|
26
|
+
|
|
27
|
+
function isValidPort(n) {
|
|
28
|
+
return Number.isInteger(n) && n > 0 && n < 65536;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Remote command that prints the editor container's published port list
|
|
32
|
+
* (`docker ps` `{{.Ports}}`), selecting the container by the control port it
|
|
33
|
+
* publishes rather than by a name, so a client VM whose compose project is not
|
|
34
|
+
* named `codeyam-editor` resolves the same way. Reads the LIVE bindings — no
|
|
35
|
+
* compose-file path to guess. Pure string builder. */
|
|
36
|
+
function buildPublishedAppPortReadCommand() {
|
|
37
|
+
return `docker ps --filter publish=${EDITOR_CONTROL_PORT} --format '{{.Ports}}' 2>/dev/null`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse that command's output into the published app port, or null.
|
|
41
|
+
*
|
|
42
|
+
* `{{.Ports}}` reads like `0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp,
|
|
43
|
+
* 10.0.0.5:14199->14199/tcp`. The app port is the single host port that is not
|
|
44
|
+
* the control port. Anything else — no container, more than one container, no
|
|
45
|
+
* control-port mapping, zero or several candidate app ports — returns null:
|
|
46
|
+
* an unreadable publication must never be reported as a mismatch. */
|
|
47
|
+
function parsePublishedAppPort(stdout) {
|
|
48
|
+
if (typeof stdout !== "string") return null;
|
|
49
|
+
const lines = stdout
|
|
50
|
+
.split("\n")
|
|
51
|
+
.map((l) => l.trim())
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
if (lines.length !== 1) return null;
|
|
54
|
+
const hostPorts = new Set();
|
|
55
|
+
for (const m of lines[0].matchAll(/:(\d+)->\d+\/tcp/g)) hostPorts.add(Number(m[1]));
|
|
56
|
+
if (!hostPorts.delete(EDITOR_CONTROL_PORT)) return null;
|
|
57
|
+
if (hostPorts.size !== 1) return null;
|
|
58
|
+
const [port] = hostPorts;
|
|
59
|
+
return isValidPort(port) ? port : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Parse `cloud-session-idle.sh` stdout into `idle` | `active` | null. Null
|
|
63
|
+
* (unknown) is deliberately NOT idle — the same asymmetry that script and
|
|
64
|
+
* `converge-vm.sh --converge-env` honour. */
|
|
65
|
+
function parseIdleVerdict(stdout) {
|
|
66
|
+
if (typeof stdout !== "string") return null;
|
|
67
|
+
const v = stdout.trim().split("\n").pop();
|
|
68
|
+
return v === "idle" || v === "active" ? v : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The remediations that apply to one drifted VM, cheapest first.
|
|
72
|
+
*
|
|
73
|
+
* The session-preserving pin always applies. The re-publish (recreate the
|
|
74
|
+
* container so the host publishes the declared port) is named ONLY when the
|
|
75
|
+
* VM is known idle, because it drops the PTY broker and every live session. */
|
|
76
|
+
function appPortDriftRemediations({ vmNumber, declared, published, idle }) {
|
|
77
|
+
const out = [
|
|
78
|
+
{
|
|
79
|
+
kind: "pin-to-published",
|
|
80
|
+
preservesSessions: true,
|
|
81
|
+
text:
|
|
82
|
+
`on VM-${vmNumber}, run \`codeyam-editor editor config-override apps.0.port ${published}\` ` +
|
|
83
|
+
`in the project — pins the app back to the published port with an in-place proxy rebind, no restart`,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
if (idle === "idle") {
|
|
87
|
+
out.push({
|
|
88
|
+
kind: "republish-declared",
|
|
89
|
+
preservesSessions: false,
|
|
90
|
+
text:
|
|
91
|
+
`VM-${vmNumber} is idle, so the container can instead be recreated to publish ${declared}: ` +
|
|
92
|
+
`change the app-port mapping in its docker-compose.cloud.yml from ${published} to ${declared} ` +
|
|
93
|
+
`and recreate the editor container (see ${PORT_DRIFT_RUNBOOK})`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Compare one VM's declared app port against its published host port.
|
|
100
|
+
*
|
|
101
|
+
* `deps.readDeclaredPort` / `deps.readPublishedPort` return a port or null;
|
|
102
|
+
* `deps.readIdleVerdict` (optional) returns `idle` | `active` | null and is
|
|
103
|
+
* only consulted once a drift is found. Returns `[]` when the two agree or
|
|
104
|
+
* when EITHER cannot be read — no 5173 fallback is ever used for the
|
|
105
|
+
* comparison — else a one-element findings array. The published read is
|
|
106
|
+
* skipped when the declared port is unreadable, so a VM with no scaffolded
|
|
107
|
+
* project costs one probe, not two. */
|
|
108
|
+
async function collectAppPortDrift(vm, deps) {
|
|
109
|
+
const { vmNumber, slug = null } = vm || {};
|
|
110
|
+
const declared = await deps.readDeclaredPort();
|
|
111
|
+
if (!isValidPort(declared)) return [];
|
|
112
|
+
const published = await deps.readPublishedPort();
|
|
113
|
+
if (!isValidPort(published) || published === declared) return [];
|
|
114
|
+
const idle = deps.readIdleVerdict ? await deps.readIdleVerdict() : null;
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
117
|
+
vmNumber,
|
|
118
|
+
slug,
|
|
119
|
+
declared,
|
|
120
|
+
published,
|
|
121
|
+
idle,
|
|
122
|
+
remediations: appPortDriftRemediations({ vmNumber, declared, published, idle }),
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Render one finding as the operator-facing warning line(s). */
|
|
128
|
+
function formatAppPortDriftWarning(finding) {
|
|
129
|
+
const label = finding.slug || `vm-${finding.vmNumber}`;
|
|
130
|
+
const lines = [
|
|
131
|
+
` VM-${finding.vmNumber} ${label}: PORT DRIFT — the project declares app port ` +
|
|
132
|
+
`${finding.declared} but the container publishes ${finding.published}, so the ` +
|
|
133
|
+
`load balancer cannot reach the Live Preview (in-container probes still read healthy).`,
|
|
134
|
+
];
|
|
135
|
+
for (const r of finding.remediations) lines.push(` fix: ${r.text}`);
|
|
136
|
+
if (finding.idle !== "idle") {
|
|
137
|
+
lines.push(
|
|
138
|
+
` (recreating the container to publish ${finding.declared} is not offered: ` +
|
|
139
|
+
`the VM is ${finding.idle || "not confirmed idle"} and a recreate drops live sessions)`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Render the single machine-readable field `vm-ingress.sh status` prints.
|
|
146
|
+
* Kept separate from the `verdict=` line on purpose: the verdict drives the
|
|
147
|
+
* reconcile's re-`up` loop, and re-`up`ping cannot fix a port-publication
|
|
148
|
+
* drift. */
|
|
149
|
+
function formatPortDriftStatusLine(findings, { declared = null, published = null } = {}) {
|
|
150
|
+
if (findings.length) {
|
|
151
|
+
const f = findings[0];
|
|
152
|
+
return `port-drift=yes declared=${f.declared} published=${f.published}`;
|
|
153
|
+
}
|
|
154
|
+
if (isValidPort(declared) && isValidPort(published)) {
|
|
155
|
+
return `port-drift=no declared=${declared} published=${published}`;
|
|
156
|
+
}
|
|
157
|
+
return "port-drift=unknown";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
EDITOR_CONTROL_PORT,
|
|
162
|
+
buildPublishedAppPortReadCommand,
|
|
163
|
+
parsePublishedAppPort,
|
|
164
|
+
parseIdleVerdict,
|
|
165
|
+
appPortDriftRemediations,
|
|
166
|
+
collectAppPortDrift,
|
|
167
|
+
formatAppPortDriftWarning,
|
|
168
|
+
formatPortDriftStatusLine,
|
|
169
|
+
};
|
package/npm/cloud.js
CHANGED
|
@@ -105,6 +105,17 @@ const {
|
|
|
105
105
|
firewallNeedsConverge,
|
|
106
106
|
firewallAdmitsPort,
|
|
107
107
|
} = require("./ingress-invoke");
|
|
108
|
+
// Declared-vs-published app-port comparison (pure; unit-tested in
|
|
109
|
+
// app-port-drift.test.js). The SSH readers that feed it live below, beside
|
|
110
|
+
// readVmAppPort.
|
|
111
|
+
const {
|
|
112
|
+
buildPublishedAppPortReadCommand,
|
|
113
|
+
parsePublishedAppPort,
|
|
114
|
+
parseIdleVerdict,
|
|
115
|
+
collectAppPortDrift,
|
|
116
|
+
formatAppPortDriftWarning,
|
|
117
|
+
formatPortDriftStatusLine,
|
|
118
|
+
} = require("./app-port-drift");
|
|
108
119
|
// Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
|
|
109
120
|
// provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
|
|
110
121
|
// the watchdog see it within one poll cycle — without a manual fleet-vms.json
|
|
@@ -114,7 +125,7 @@ const { addToRoster, readRoster } = require("./fleet-roster");
|
|
|
114
125
|
// Tells a docker container-name conflict (a concurrent run already started this
|
|
115
126
|
// container) apart from a real `docker compose up -d` failure, so the abort path
|
|
116
127
|
// can converge instead of escalating to instance deletion.
|
|
117
|
-
const { classifyComposeUpFailure } = require("./compose-conflict");
|
|
128
|
+
const { classifyComposeUpFailure, isGcloudTransportFailure } = require("./compose-conflict");
|
|
118
129
|
// The two streamed bootstrap scripts, named once for this file's STREAMED_SCRIPTS
|
|
119
130
|
// and for the advance's provisioning pre-flight, which checks the same set.
|
|
120
131
|
const { BOOTSTRAP_SCRIPT, PROJECT_BOOTSTRAP_SCRIPT } = require("./provisioning-contract");
|
|
@@ -881,8 +892,8 @@ function parseEditorComposeState(stdout) {
|
|
|
881
892
|
}
|
|
882
893
|
|
|
883
894
|
/** Decide whether `cloud:up`'s abort handler may delete the instance it is
|
|
884
|
-
* unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive
|
|
885
|
-
* → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
|
|
895
|
+
* unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive,
|
|
896
|
+
* remoteCommandNeverRan } → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
|
|
886
897
|
* `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
|
|
887
898
|
* exhaustively testable without a VM.
|
|
888
899
|
*
|
|
@@ -904,9 +915,20 @@ function parseEditorComposeState(stdout) {
|
|
|
904
915
|
* SAFE: a failing probe is how we reach the abort handler in the first place,
|
|
905
916
|
* so it can never count as evidence the VM is dead. An orphan costs one
|
|
906
917
|
* `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
|
|
907
|
-
* any unpushed work on the VM.
|
|
908
|
-
|
|
918
|
+
* any unpushed work on the VM.
|
|
919
|
+
* - `remoteCommandNeverRan` — the error that brought us here was a transport
|
|
920
|
+
* failure: the gcloud client never reached the VM (an OS Login quota 429 on
|
|
921
|
+
* 2026-10-01), so the step it was running never executed. Same class as
|
|
922
|
+
* `probeInconclusive` — absence of evidence — and it must fail SAFE the same
|
|
923
|
+
* way. On that date the probes truthfully reported "container down, not
|
|
924
|
+
* rostered", but only BECAUSE compose-up never ran; deleting on that reading
|
|
925
|
+
* destroyed VM-8 after ~19 minutes of finished build. A run that never managed
|
|
926
|
+
* to execute its step has learned nothing about the VM, so it has not earned
|
|
927
|
+
* the right to delete one. Defense in depth: the compose-up retry should keep
|
|
928
|
+
* this from being reached at all. */
|
|
929
|
+
function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive, remoteCommandNeverRan } = {}) {
|
|
909
930
|
if (!createdThisRun) return "keep";
|
|
931
|
+
if (remoteCommandNeverRan) return "keep";
|
|
910
932
|
if (probeInconclusive) return "keep";
|
|
911
933
|
if (containerUp) return "keep";
|
|
912
934
|
if (rostered) return "keep";
|
|
@@ -1268,13 +1290,24 @@ function provisionFailureMessage(verb, instanceName, result) {
|
|
|
1268
1290
|
);
|
|
1269
1291
|
}
|
|
1270
1292
|
|
|
1271
|
-
// A bounded, pure classifier
|
|
1272
|
-
// for an ssh failure)
|
|
1293
|
+
// A bounded, pure classifier with two independent branches:
|
|
1294
|
+
// - status === 255 (gcloud-ssh's exit for an ssh failure) plus a signature
|
|
1295
|
+
// match against known transient SSH-transport strings;
|
|
1296
|
+
// - a gcloud CLIENT-SIDE API failure (an OS Login quota 429), at any status.
|
|
1297
|
+
// gcloud does not exit 255 for these, so the status code is evidence for the
|
|
1298
|
+
// first branch, never a precondition for reading the text — gating on it is
|
|
1299
|
+
// what kept the 2026-10-01 VM-8 quota 429 out of the retry path.
|
|
1273
1300
|
function isTransientSshFailure(result) {
|
|
1274
|
-
if (!result || result.status
|
|
1301
|
+
if (!result || result.status === 0 || result.status == null) {
|
|
1275
1302
|
return false;
|
|
1276
1303
|
}
|
|
1277
1304
|
const output = String(result.stderr || "") + String(result.stdout || "");
|
|
1305
|
+
if (isGcloudTransportFailure(output)) {
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
if (result.status !== 255) {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1278
1311
|
const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
|
|
1279
1312
|
return transientRegex.test(output);
|
|
1280
1313
|
}
|
|
@@ -2471,16 +2504,34 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
2471
2504
|
// stream and capture, so the operator's view is preserved by echoing the
|
|
2472
2505
|
// captured streams immediately below instead of live. Do NOT revert this to
|
|
2473
2506
|
// "inherit"; classification depends on it.
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2507
|
+
//
|
|
2508
|
+
// Through the retry wrapper: a transport failure (an OS Login quota 429 on
|
|
2509
|
+
// 2026-10-01) means `up -d` never ran, so the honest response is to try again.
|
|
2510
|
+
// Retrying is safe without a did-it-run probe — `up -d` is idempotent here, and
|
|
2511
|
+
// a retry that races a container into existence lands on the name-conflict
|
|
2512
|
+
// verdict below, which converges.
|
|
2513
|
+
const up = await runGcloudSshWithRetry(
|
|
2514
|
+
"ssh-probe",
|
|
2515
|
+
cfg,
|
|
2516
|
+
{ command: `cd ~/projects/${repoName} && ${composeCmd} up -d` },
|
|
2517
|
+
{ stdio: ["ignore", "pipe", "pipe"], label: "docker compose up -d" },
|
|
2479
2518
|
);
|
|
2480
2519
|
if (up.stdout) process.stderr.write(up.stdout);
|
|
2481
2520
|
if (up.stderr) process.stderr.write(up.stderr);
|
|
2482
2521
|
if (up.status !== 0) {
|
|
2483
2522
|
const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
|
|
2523
|
+
if (verdict === "transport-error") {
|
|
2524
|
+
// Retries exhausted and gcloud still never reached the VM. Say so, and mark
|
|
2525
|
+
// the error so the abort handler leaves the VM alone: nothing about the VM
|
|
2526
|
+
// has been learned, so it cannot be judged broken.
|
|
2527
|
+
const err = new Error(
|
|
2528
|
+
"could not reach the VM to run docker compose up -d: gcloud failed before connecting " +
|
|
2529
|
+
"(an OS Login API quota 429 — too many concurrent fleet ssh calls). The remote command " +
|
|
2530
|
+
"never ran; the VM itself was not shown to be broken.",
|
|
2531
|
+
);
|
|
2532
|
+
err.remoteCommandNeverRan = true;
|
|
2533
|
+
throw err;
|
|
2534
|
+
}
|
|
2484
2535
|
if (verdict !== "name-conflict") {
|
|
2485
2536
|
throw new Error("docker compose up -d failed on the VM");
|
|
2486
2537
|
}
|
|
@@ -2991,32 +3042,66 @@ function buildVmAppPortReadCommand(repoName) {
|
|
|
2991
3042
|
* `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
|
|
2992
3043
|
* on) and stays quiet. */
|
|
2993
3044
|
async function readVmAppPort(cfg) {
|
|
2994
|
-
const
|
|
2995
|
-
|
|
2996
|
-
buildGcloudArgs("ssh-probe", cfg, {
|
|
2997
|
-
command: buildVmAppPortReadCommand(repoName),
|
|
2998
|
-
}),
|
|
2999
|
-
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
3000
|
-
);
|
|
3001
|
-
if (read.status !== 0 || typeof read.stdout !== "string") {
|
|
3045
|
+
const { port, failure } = await probeVmAppPort(cfg);
|
|
3046
|
+
if (failure && failure.kind === "ssh") {
|
|
3002
3047
|
console.error(
|
|
3003
|
-
`[cloud] WARNING:
|
|
3048
|
+
`[cloud] WARNING: ${failure.what} — ` +
|
|
3004
3049
|
`falling back to ${VITE_PORT}. If the app does not serve on ` +
|
|
3005
3050
|
`${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
|
|
3006
3051
|
`Live Preview will show "localhost refused to connect".`,
|
|
3007
3052
|
);
|
|
3008
|
-
|
|
3009
|
-
}
|
|
3010
|
-
if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
|
|
3053
|
+
} else if (failure) {
|
|
3011
3054
|
console.error(
|
|
3012
|
-
`[cloud] WARNING:
|
|
3013
|
-
`VM (looked for '${repoName}', then any project dir) — falling back to ` +
|
|
3055
|
+
`[cloud] WARNING: ${failure.what} — falling back to ` +
|
|
3014
3056
|
`${VITE_PORT}. If the project is scaffolded under a different name and ` +
|
|
3015
3057
|
`serves on another port, the port-forward targets the wrong port and ` +
|
|
3016
3058
|
`the Live Preview will show "localhost refused to connect".`,
|
|
3017
3059
|
);
|
|
3018
|
-
return VITE_PORT;
|
|
3019
3060
|
}
|
|
3061
|
+
return port == null ? VITE_PORT : port;
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
/** The probe behind `readVmAppPort`, without the fallback: resolves to
|
|
3065
|
+
* `{ port, failure: null }` when the VM's config was read, or
|
|
3066
|
+
* `{ port: null, failure: { kind: "ssh" | "no-config", what } }` when it was
|
|
3067
|
+
* not. Callers that must not act on a guess — the ingress reconcile, where a
|
|
3068
|
+
* 5173 fallback would wire a preview NEG endpoint to a port nothing publishes —
|
|
3069
|
+
* use this to tell a resolved port from a fallback. Prints nothing; `what` is
|
|
3070
|
+
* the human description of the miss for the caller's own warning. */
|
|
3071
|
+
async function probeVmAppPort(cfg) {
|
|
3072
|
+
const repoName = resolveProject(cfg).name;
|
|
3073
|
+
const read = runGcloud(
|
|
3074
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
3075
|
+
command: buildVmAppPortReadCommand(repoName),
|
|
3076
|
+
}),
|
|
3077
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
3078
|
+
);
|
|
3079
|
+
if (read.status !== 0 || typeof read.stdout !== "string") {
|
|
3080
|
+
return {
|
|
3081
|
+
port: null,
|
|
3082
|
+
failure: { kind: "ssh", what: "could not read the VM's app port over SSH" },
|
|
3083
|
+
};
|
|
3084
|
+
}
|
|
3085
|
+
const { port, miss } = parseVmDeclaredAppPort(read.stdout);
|
|
3086
|
+
if (miss === "no-config") {
|
|
3087
|
+
return {
|
|
3088
|
+
port: null,
|
|
3089
|
+
failure: {
|
|
3090
|
+
kind: "no-config",
|
|
3091
|
+
what:
|
|
3092
|
+
`no .codeyam/editor.json found under ~/projects on the ` +
|
|
3093
|
+
`VM (looked for '${repoName}', then any project dir)`,
|
|
3094
|
+
},
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
return { port, failure: null };
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/** Resolve `buildVmAppPortReadCommand`'s output into `{ port, miss }` — shared
|
|
3101
|
+
* by `probeVmAppPort` and the port-drift probe, so both resolve the declared
|
|
3102
|
+
* port identically. A no-config VM is a miss with a null port, never 5173. */
|
|
3103
|
+
function parseVmDeclaredAppPort(stdout) {
|
|
3104
|
+
if (stdout.includes(VM_APP_PORT_NO_CONFIG)) return { port: null, miss: "no-config" };
|
|
3020
3105
|
const parse = (s) => {
|
|
3021
3106
|
try {
|
|
3022
3107
|
const v = JSON.parse(s);
|
|
@@ -3025,8 +3110,116 @@ async function readVmAppPort(cfg) {
|
|
|
3025
3110
|
return {};
|
|
3026
3111
|
}
|
|
3027
3112
|
};
|
|
3028
|
-
const [baseJson = "", localJson = ""] =
|
|
3029
|
-
return resolveConfiguredAppPort(parse(baseJson), parse(localJson));
|
|
3113
|
+
const [baseJson = "", localJson = ""] = stdout.split("<<<LOCAL>>>");
|
|
3114
|
+
return { port: resolveConfiguredAppPort(parse(baseJson), parse(localJson)), miss: null };
|
|
3115
|
+
}
|
|
3116
|
+
|
|
3117
|
+
/** Separates the declared-port read from the `docker ps` read in the combined
|
|
3118
|
+
* probe below. */
|
|
3119
|
+
const VM_PUBLISHED_PORT_MARKER = "<<<PUBLISHED>>>";
|
|
3120
|
+
|
|
3121
|
+
/** Read the declared app port AND the host port the VM's editor container
|
|
3122
|
+
* publishes (live `docker ps` bindings) in ONE SSH round-trip, so the roster
|
|
3123
|
+
* pass costs one extra probe per VM. Either side is null when unreadable. */
|
|
3124
|
+
function readVmAppPortsForDrift(cfg) {
|
|
3125
|
+
const read = runGcloud(
|
|
3126
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
3127
|
+
command:
|
|
3128
|
+
`${buildVmAppPortReadCommand(resolveProject(cfg).name)}; ` +
|
|
3129
|
+
`echo '${VM_PUBLISHED_PORT_MARKER}'; ${buildPublishedAppPortReadCommand()}`,
|
|
3130
|
+
}),
|
|
3131
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
3132
|
+
);
|
|
3133
|
+
if (read.status !== 0) return { declared: null, published: null };
|
|
3134
|
+
return parseVmAppPortsForDrift(read.stdout);
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
/** Split the combined probe's output into `{ declared, published }`. Output
|
|
3138
|
+
* without the marker — an SSH session that exited 0 having printed nothing, a
|
|
3139
|
+
* truncated read — is unreadable on BOTH sides. Resolving its empty config
|
|
3140
|
+
* half would land on the 5173 default and could report a drift that does not
|
|
3141
|
+
* exist. */
|
|
3142
|
+
function parseVmAppPortsForDrift(stdout) {
|
|
3143
|
+
if (typeof stdout !== "string" || !stdout.includes(VM_PUBLISHED_PORT_MARKER)) {
|
|
3144
|
+
return { declared: null, published: null };
|
|
3145
|
+
}
|
|
3146
|
+
const [declaredOut, publishedOut] = stdout.split(VM_PUBLISHED_PORT_MARKER);
|
|
3147
|
+
return {
|
|
3148
|
+
declared: declaredOut.trim() ? parseVmDeclaredAppPort(declaredOut).port : null,
|
|
3149
|
+
published: parsePublishedAppPort(publishedOut),
|
|
3150
|
+
};
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
/** Read the VM's `cloud-session-idle.sh` verdict (`idle` | `active` | null).
|
|
3154
|
+
* Unknown — including a VM whose checkout predates the script — is null, and
|
|
3155
|
+
* null is never treated as idle. */
|
|
3156
|
+
function readVmIdleVerdict(cfg) {
|
|
3157
|
+
const read = runGcloud(
|
|
3158
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
3159
|
+
command:
|
|
3160
|
+
`s="$HOME/codeyam-editor/scripts/cloud-session-idle.sh"; ` +
|
|
3161
|
+
`if [ -r "$s" ]; then bash "$s" 2>/dev/null; fi; true`,
|
|
3162
|
+
}),
|
|
3163
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
3164
|
+
);
|
|
3165
|
+
return read.status === 0 ? parseIdleVerdict(read.stdout) : null;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
/** Compare one VM's declared app port against its published host port over the
|
|
3169
|
+
* SSH probe. Returns the findings plus both raw reads (for the status line).
|
|
3170
|
+
* See npm/app-port-drift.js for the rule. */
|
|
3171
|
+
async function collectVmAppPortDrift(cfg, vmNumber, slug) {
|
|
3172
|
+
const { declared, published } = readVmAppPortsForDrift(cfg);
|
|
3173
|
+
const findings = await collectAppPortDrift(
|
|
3174
|
+
{ vmNumber, slug },
|
|
3175
|
+
{
|
|
3176
|
+
readDeclaredPort: async () => declared,
|
|
3177
|
+
readPublishedPort: async () => published,
|
|
3178
|
+
readIdleVerdict: async () => readVmIdleVerdict(cfg),
|
|
3179
|
+
},
|
|
3180
|
+
);
|
|
3181
|
+
return { findings, declared, published };
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
/** Port-drift pass over the whole roster, on the periodic reconcile that already
|
|
3185
|
+
* walks it. Reports, never repairs (the fix recreates the container or changes
|
|
3186
|
+
* the project's port — both the operator's call). Per-VM failures are logged
|
|
3187
|
+
* and swallowed, like the preview-origin heal beside it. */
|
|
3188
|
+
async function reportAppPortDriftAcrossRoster(cfg, roster, deps = {}) {
|
|
3189
|
+
const collect = deps.collectVmAppPortDrift || collectVmAppPortDrift;
|
|
3190
|
+
const drifted = [];
|
|
3191
|
+
for (const n of roster) {
|
|
3192
|
+
const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
|
|
3193
|
+
try {
|
|
3194
|
+
const { findings } = await collect(perVmCfg, n, slugFor(n));
|
|
3195
|
+
for (const f of findings) {
|
|
3196
|
+
console.error(formatAppPortDriftWarning(f));
|
|
3197
|
+
drifted.push(f);
|
|
3198
|
+
}
|
|
3199
|
+
} catch (e) {
|
|
3200
|
+
console.error(` VM-${n} ${slugFor(n) || `vm-${n}`}: port-drift check skipped (${e && e.message})`);
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
return drifted;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
/** `npm run cloud:app-port-drift -- --instance-name <name>` — one VM's
|
|
3207
|
+
* declared-vs-published comparison as the single `port-drift=` line
|
|
3208
|
+
* `vm-ingress.sh status` appends, plus the human warning on stderr. */
|
|
3209
|
+
async function cmdAppPortDrift(args, projectDir = rootDir) {
|
|
3210
|
+
const flags = parseFlags(args);
|
|
3211
|
+
const cfg = { ...(loadCloudConfig(projectDir) || {}), ...flags };
|
|
3212
|
+
const vmNumber = resolveFleetVmNumber(cfg.instanceName || "");
|
|
3213
|
+
if (vmNumber == null) {
|
|
3214
|
+
throw new Error("cloud:app-port-drift requires --instance-name <numbered fleet VM>.");
|
|
3215
|
+
}
|
|
3216
|
+
const { findings, declared, published } = await collectVmAppPortDrift(
|
|
3217
|
+
cfg,
|
|
3218
|
+
vmNumber,
|
|
3219
|
+
slugFor(vmNumber),
|
|
3220
|
+
);
|
|
3221
|
+
for (const f of findings) console.error(formatAppPortDriftWarning(f));
|
|
3222
|
+
console.log(formatPortDriftStatusLine(findings, { declared, published }));
|
|
3030
3223
|
}
|
|
3031
3224
|
|
|
3032
3225
|
/** Build the shell command that seeds a JSON patch into a host-side
|
|
@@ -3947,7 +4140,7 @@ function fleetInstancePrefix() {
|
|
|
3947
4140
|
* (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
|
|
3948
4141
|
* teardown still removes the instance. `vmAccess` (up only) is the Add's intended
|
|
3949
4142
|
* principal set, which the script's grant step uses in place of its sidecar read. */
|
|
3950
|
-
function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
|
|
4143
|
+
function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess, { appPortUnresolved = false } = {}) {
|
|
3951
4144
|
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3952
4145
|
const inv = vmIngressInvocation({
|
|
3953
4146
|
scriptPath,
|
|
@@ -3960,6 +4153,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
|
|
|
3960
4153
|
instancePrefix: fleetInstancePrefix(),
|
|
3961
4154
|
slug,
|
|
3962
4155
|
vmAccess,
|
|
4156
|
+
appPortUnresolved,
|
|
3963
4157
|
});
|
|
3964
4158
|
return spawnSync(inv.command, inv.args, {
|
|
3965
4159
|
stdio: "inherit",
|
|
@@ -4773,12 +4967,18 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
4773
4967
|
// VM is in right now, then let the pure decision rule — see
|
|
4774
4968
|
// abortTeardownDecision's doc comment for why each input is load-bearing.
|
|
4775
4969
|
const state = probeAbortTeardownState(cfg, createdThisRun);
|
|
4776
|
-
|
|
4970
|
+
// An error that never reached the VM proves nothing about it — see the
|
|
4971
|
+
// `remoteCommandNeverRan` paragraph of abortTeardownDecision.
|
|
4972
|
+
const remoteCommandNeverRan =
|
|
4973
|
+
Boolean(err && err.remoteCommandNeverRan) || isGcloudTransportFailure(err && err.message);
|
|
4974
|
+
const verdict = abortTeardownDecision({ createdThisRun, ...state, remoteCommandNeverRan });
|
|
4777
4975
|
if (createdThisRun && verdict === "keep") {
|
|
4778
4976
|
// Deliberately loud and specific. The old blanket "tearing down the VM
|
|
4779
4977
|
// cloud:up created this run (no orphan left behind)" was actively
|
|
4780
4978
|
// misleading here — it asserted no orphan while deleting a working VM.
|
|
4781
|
-
const why =
|
|
4979
|
+
const why = remoteCommandNeverRan
|
|
4980
|
+
? "the failing step never reached the VM (a gcloud transport/quota error), so nothing shows the VM is broken"
|
|
4981
|
+
: state.probeInconclusive
|
|
4782
4982
|
? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
|
|
4783
4983
|
: state.containerUp
|
|
4784
4984
|
? "its editor container is RUNNING — another run brought it up"
|
|
@@ -5071,6 +5271,25 @@ async function healPreviewOriginsAcrossRoster(cfg, roster) {
|
|
|
5071
5271
|
* resources already present and creates only the missing backend / host rule.
|
|
5072
5272
|
* A no-op in legacy quick-tunnel mode (no fleet domain). Invoked on demand and
|
|
5073
5273
|
* automatically at dashboard boot so a fleet that lost ingress self-heals. */
|
|
5274
|
+
/** Resolve one reconcile target's app port for `vm-ingress.sh up`, refusing to
|
|
5275
|
+
* guess. Resolves `{ appPort, appPortUnresolved: false }` on a real read, or
|
|
5276
|
+
* `{ appPort: null, appPortUnresolved: true }` — with a warning naming the VM —
|
|
5277
|
+
* when the probe failed, so the script leaves the preview NEG's endpoints alone
|
|
5278
|
+
* instead of wiring (and pruning to) a 5173 fallback over a working endpoint.
|
|
5279
|
+
* `deps.probeVmAppPort` is injectable so tests never reach for SSH. */
|
|
5280
|
+
async function resolveReconcileAppPort(perVmCfg, vmNumber, deps = {}) {
|
|
5281
|
+
const probe = deps.probeVmAppPort || probeVmAppPort;
|
|
5282
|
+
const { port, failure } = await probe(perVmCfg);
|
|
5283
|
+
if (!failure) return { appPort: port, appPortUnresolved: false };
|
|
5284
|
+
console.error(
|
|
5285
|
+
`[cloud] WARNING: VM-${vmNumber}: ${failure.what} — skipping the preview ` +
|
|
5286
|
+
`endpoint converge rather than wiring ${VITE_PORT} over a working endpoint. ` +
|
|
5287
|
+
`If the preview NEG targets the wrong port, Live Preview requests fail ` +
|
|
5288
|
+
`with "upstream connect error" until a later reconcile resolves it.`,
|
|
5289
|
+
);
|
|
5290
|
+
return { appPort: null, appPortUnresolved: true };
|
|
5291
|
+
}
|
|
5292
|
+
|
|
5074
5293
|
async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
5075
5294
|
ensureGcloud();
|
|
5076
5295
|
parseFlags(args);
|
|
@@ -5143,17 +5362,23 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
5143
5362
|
for (const t of targets) {
|
|
5144
5363
|
const label = t.slug || `vm-${t.vmNumber}`;
|
|
5145
5364
|
// Resolve the VM's real dev-server port so a re-created preview NEG targets
|
|
5146
|
-
// the right port
|
|
5147
|
-
//
|
|
5365
|
+
// the right port. NOT readVmAppPort: its VITE_PORT fallback on an SSH/parse
|
|
5366
|
+
// failure is indistinguishable from a VM genuinely serving 5173, and `up`
|
|
5367
|
+
// prunes the preview NEG to the port it is given — a fallback would detach the
|
|
5368
|
+
// working endpoint and wire one nothing publishes. Unresolved → the script
|
|
5369
|
+
// leaves the preview endpoints alone; every other resource still converges.
|
|
5148
5370
|
const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${t.vmNumber}` };
|
|
5149
|
-
const appPort = await
|
|
5150
|
-
const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug
|
|
5371
|
+
const { appPort, appPortUnresolved } = await resolveReconcileAppPort(perVmCfg, t.vmNumber);
|
|
5372
|
+
const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug, undefined, {
|
|
5373
|
+
appPortUnresolved,
|
|
5374
|
+
});
|
|
5151
5375
|
console.error(
|
|
5152
5376
|
` VM-${t.vmNumber} ${label}: ${t.verdict} → ${res.status === 0 ? "re-wired" : "re-wire FAILED"}`,
|
|
5153
5377
|
);
|
|
5154
5378
|
}
|
|
5155
5379
|
|
|
5156
5380
|
await healPreviewOriginsAcrossRoster(cfg, roster);
|
|
5381
|
+
await reportAppPortDriftAcrossRoster(cfg, roster);
|
|
5157
5382
|
|
|
5158
5383
|
// Converge each rostered backend-service's timeout to 86400 (24h). A fully-wired
|
|
5159
5384
|
// BES still at GCP's 30s default reports `wired` above and is skipped by the
|
|
@@ -5722,6 +5947,7 @@ const HANDLERS = {
|
|
|
5722
5947
|
status: cmdStatus,
|
|
5723
5948
|
"ingress-up": cmdIngressUp,
|
|
5724
5949
|
"reconcile-ingress": cmdReconcileIngress,
|
|
5950
|
+
"app-port-drift": cmdAppPortDrift,
|
|
5725
5951
|
"wire-add-tail": cmdWireAddTail,
|
|
5726
5952
|
"reconcile-credentials": cmdReconcileCredentials,
|
|
5727
5953
|
"reconcile-admin": cmdReconcileAdmin,
|
|
@@ -5816,6 +6042,8 @@ module.exports = {
|
|
|
5816
6042
|
seedPreviewOrigin,
|
|
5817
6043
|
healPreviewOriginAtReady,
|
|
5818
6044
|
healPreviewOriginsAcrossRoster,
|
|
6045
|
+
reportAppPortDriftAcrossRoster,
|
|
6046
|
+
cmdAppPortDrift,
|
|
5819
6047
|
buildGcloudArgs,
|
|
5820
6048
|
sshScriptCommand,
|
|
5821
6049
|
bootstrapRunnerScript,
|
|
@@ -5905,6 +6133,10 @@ module.exports = {
|
|
|
5905
6133
|
classifyGitRemoteError,
|
|
5906
6134
|
decideCredentialHeal,
|
|
5907
6135
|
readVmAppPort,
|
|
6136
|
+
probeVmAppPort,
|
|
6137
|
+
resolveReconcileAppPort,
|
|
6138
|
+
parseVmDeclaredAppPort,
|
|
6139
|
+
parseVmAppPortsForDrift,
|
|
5908
6140
|
buildVmAppPortReadCommand,
|
|
5909
6141
|
resolveLaptopGitIdentity,
|
|
5910
6142
|
gitBranchRefExists,
|
package/npm/compose-conflict.js
CHANGED
|
@@ -31,12 +31,44 @@
|
|
|
31
31
|
// whole captured stream rather than line- or anchor-based.
|
|
32
32
|
const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
|
|
33
33
|
|
|
34
|
+
// The stable fragments of a gcloud CLIENT-SIDE API failure — gcloud's own HTTP
|
|
35
|
+
// client failed before any ssh was attempted, so the remote command never ran.
|
|
36
|
+
// Observed verbatim in the 2026-10-01 VM-8 add log, when an OS Login
|
|
37
|
+
// loginProfile read hit the `ReadRequestsPerMinutePerUser` quota (limit 60):
|
|
38
|
+
//
|
|
39
|
+
// ERROR: (gcloud.compute.ssh) HttpError accessing <https://oslogin.googleapis.com/…
|
|
40
|
+
// /loginProfile?…>: response: <{'status': 429}>, content <{"error": {"code": 429,
|
|
41
|
+
// "message": "Quota exceeded for quota metric …", "status": "RESOURCE_EXHAUSTED",
|
|
42
|
+
// "details": [{"reason": "RATE_LIMIT_EXCEEDED"}]}}
|
|
43
|
+
//
|
|
44
|
+
// Same discipline as the conflict fragments, for the same reason — Google
|
|
45
|
+
// rephrases these: require gcloud's `HttpError accessing` wrapper AND at least
|
|
46
|
+
// one quota token, never a single token, so an app-level message that merely
|
|
47
|
+
// contains `429` is not swept in.
|
|
48
|
+
const GCLOUD_HTTP_ERROR_FRAGMENT = /HttpError accessing/;
|
|
49
|
+
const GCLOUD_QUOTA_FRAGMENTS = [/'status':\s*429\b/, /RESOURCE_EXHAUSTED/, /RATE_LIMIT_EXCEEDED/];
|
|
50
|
+
|
|
51
|
+
/** True when `text` carries a gcloud client-side API quota failure, i.e. the
|
|
52
|
+
* gcloud client never reached the VM. Shared by {@link classifyComposeUpFailure}
|
|
53
|
+
* and `cloud.js`'s transient-ssh predicate and abort handler, so the three
|
|
54
|
+
* cannot drift apart on what counts as "never reached the VM". */
|
|
55
|
+
function isGcloudTransportFailure(text) {
|
|
56
|
+
const s = String(text == null ? '' : text);
|
|
57
|
+
return GCLOUD_HTTP_ERROR_FRAGMENT.test(s) && GCLOUD_QUOTA_FRAGMENTS.some((re) => re.test(s));
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
/** Classify the outcome of `docker compose up -d`.
|
|
35
61
|
*
|
|
36
62
|
* Returns one of:
|
|
37
63
|
* - `'ok'` — the command succeeded; there is no failure to classify. Guards
|
|
38
64
|
* against a caller that classifies unconditionally, so a zero status can
|
|
39
65
|
* never be read as a conflict.
|
|
66
|
+
* - `'transport-error'` — the gcloud ssh client never reached the VM (an OS
|
|
67
|
+
* Login quota 429 on 2026-10-01), so `up -d` did not run and nothing has been
|
|
68
|
+
* learned about the box. Reading this as `'failed'` is what drove the abort
|
|
69
|
+
* handler into deleting VM-8 after it had already built its editor image.
|
|
70
|
+
* The caller retries; it must never treat this as a provisioning failure.
|
|
71
|
+
* Checked first so a transport failure can never be read as a daemon message.
|
|
40
72
|
* - `'name-conflict'` — the daemon refused because a container of that name
|
|
41
73
|
* already exists, i.e. a concurrent run already started it. The caller
|
|
42
74
|
* re-probes for a running container and converges instead of throwing.
|
|
@@ -50,10 +82,11 @@ const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already
|
|
|
50
82
|
function classifyComposeUpFailure({ status, stderr } = {}) {
|
|
51
83
|
if (status === 0) return 'ok';
|
|
52
84
|
const text = String(stderr == null ? '' : stderr);
|
|
85
|
+
if (isGcloudTransportFailure(text)) return 'transport-error';
|
|
53
86
|
if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
|
|
54
87
|
return 'name-conflict';
|
|
55
88
|
}
|
|
56
89
|
return 'failed';
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
module.exports = { classifyComposeUpFailure };
|
|
92
|
+
module.exports = { classifyComposeUpFailure, isGcloudTransportFailure };
|