@codeyam-editor/codeyam-editor 0.1.0-staging.g388918e → 0.1.0-staging.g83619a4
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 +729 -85
- package/npm/codesign-plan.js +68 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +166 -0
- package/npm/fleet-auth-health.js +95 -0
- package/npm/fleet-build-data.js +42 -3
- package/npm/fleet-cleanup.js +92 -1
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-plans.js +81 -3
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +33 -11
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +34 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-vm-access.js +75 -7
- package/npm/fleet-vm-probe-target.js +187 -0
- package/npm/fleet-vm-readiness.js +21 -4
- package/npm/ingress-invoke.js +240 -13
- package/npm/launch-config.js +115 -2
- package/npm/scenario-check.js +282 -10
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +286 -12
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-B3vO6op1.js → ContainerInspector-Di2c_yrg.js} +1 -1
- package/ui/dist/assets/EditorShell-BBjfN0wn.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-C5jktR8F.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BlYKgvU_.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CibLNYsH.js → ScenarioDataPanelFullPage-6-39HCFL.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-sh_-nNw8.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/{index-B14nyUpe.js → index-3Jd_sXhk.js} +4 -4
- package/ui/dist/assets/index-U81w-IPK.css +1 -0
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-CndrQhoL.js +0 -191
- package/ui/dist/assets/EditorShell-D7wGyetm.css +0 -1
- package/ui/dist/assets/ProjectLauncherScreen-Bq8CJo9M.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-DIAw2DpS.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-rbCbaiLe.js +0 -21
- package/ui/dist/assets/index-DK-ZOfVw.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
package/npm/cloud.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
const { spawn, spawnSync } = require("child_process");
|
|
13
13
|
const fs = require("fs");
|
|
14
|
+
const https = require("https");
|
|
14
15
|
const os = require("os");
|
|
15
16
|
const path = require("path");
|
|
16
17
|
const readline = require("readline");
|
|
@@ -75,12 +76,16 @@ const {
|
|
|
75
76
|
regionFromZone,
|
|
76
77
|
vmIngressInvocation,
|
|
77
78
|
vmIngressStatusInvocation,
|
|
79
|
+
vmIngressGrantAccessInvocation,
|
|
78
80
|
parseIngressStatusVerdict,
|
|
79
81
|
parseIngressTeardownVerdict,
|
|
80
82
|
selectIngressReconcileTargets,
|
|
83
|
+
selectIngressGrantOnlyTargets,
|
|
81
84
|
selectOrphanIngressTargets,
|
|
82
85
|
classifyOrphanDownResult,
|
|
83
86
|
backendServicesListInvocation,
|
|
87
|
+
negListInvocation,
|
|
88
|
+
instancesListInvocation,
|
|
84
89
|
ingressConvergeDecision,
|
|
85
90
|
detachedAddTailInvocation,
|
|
86
91
|
ingressInflightMarkerPath,
|
|
@@ -88,6 +93,11 @@ const {
|
|
|
88
93
|
iapAccessInvocation,
|
|
89
94
|
sharedIngressInvocation,
|
|
90
95
|
backendServiceTimeoutInvocation,
|
|
96
|
+
FLEET_FIREWALL_RULE_NAMES,
|
|
97
|
+
fleetFirewallDescribeInvocation,
|
|
98
|
+
fleetFirewallConvergeInvocation,
|
|
99
|
+
firewallNeedsConverge,
|
|
100
|
+
firewallAdmitsPort,
|
|
91
101
|
} = require("./ingress-invoke");
|
|
92
102
|
// Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
|
|
93
103
|
// provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
|
|
@@ -157,14 +167,34 @@ const KNOWN_FLAGS = [
|
|
|
157
167
|
// default is loud — a tokenless VM cannot sync plan claims and must never
|
|
158
168
|
// report healthy silently. Use only when push genuinely isn't wanted.
|
|
159
169
|
"allow-no-git-credentials",
|
|
170
|
+
// Provision the VM in admin mode: forward CODEYAM_ADMIN=1 into the container
|
|
171
|
+
// so the Home-tab Setup door renders. Equivalent to `CODEYAM_ADMIN=1 npm run
|
|
172
|
+
// cloud:up …`; absent = strict non-admin (the safe default). Survives
|
|
173
|
+
// re-provision because it rides the compose-render substitution, unlike a
|
|
174
|
+
// manual VM compose edit.
|
|
175
|
+
"admin",
|
|
176
|
+
// GCE machine type for the VM (e.g. n2-standard-4, e2-standard-4). Persisted
|
|
177
|
+
// in cloud.json so a later bare `cloud:up` keeps it. Defaults to
|
|
178
|
+
// DEFAULT_FLEET_MACHINE_TYPE. The whole point of exposing this is fleet
|
|
179
|
+
// scale-out: the region's E2_CPUS quota (72 in us-central1) caps the fleet at
|
|
180
|
+
// ~14 e2-standard-4 VMs, while N2_CPUS/N2D_CPUS (200, unused) leave room for
|
|
181
|
+
// 30 — so the fleet defaults to N2 and this flag lets a caller override.
|
|
182
|
+
"machine-type",
|
|
160
183
|
];
|
|
161
184
|
|
|
162
185
|
// Flags that take no value (presence = true). parseFlags must not demand a
|
|
163
186
|
// value for these or `--no-tunnel` would error "requires a value".
|
|
164
|
-
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
|
|
187
|
+
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials", "admin"];
|
|
165
188
|
|
|
166
189
|
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
167
190
|
const INSTANCE_TAG = "codeyam-editor";
|
|
191
|
+
// Default GCE machine type for fleet VMs. N2 (not E2) because the fleet must
|
|
192
|
+
// reach 30 VMs: us-central1 E2_CPUS quota is 72, so 30 × e2-standard-4 (120
|
|
193
|
+
// vCPU) + the operator host exceed it and cap the fleet at ~14 regardless of
|
|
194
|
+
// code — while N2_CPUS/N2D_CPUS are 200 and unused, leaving headroom for 30 ×
|
|
195
|
+
// n2-standard-4. Override per-VM with `--machine-type`. See the vm--scale-
|
|
196
|
+
// fleet-to-30-vms plan for the measured quota rationale.
|
|
197
|
+
const DEFAULT_FLEET_MACHINE_TYPE = "n2-standard-4";
|
|
168
198
|
const EDITOR_PORT = 14199;
|
|
169
199
|
const VITE_PORT = 5173;
|
|
170
200
|
|
|
@@ -548,6 +578,32 @@ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
|
|
|
548
578
|
return LOOPBACK_ORIGIN_RE.test(origin);
|
|
549
579
|
}
|
|
550
580
|
|
|
581
|
+
/** Decide whether a VM is presenting as HEALTHY while its Live Preview is
|
|
582
|
+
* silently dead — the VM-9 failure mode. The heal
|
|
583
|
+
* (`reconcilePreviewOriginToSlugHost`) fixes the mechanism; this predicate is
|
|
584
|
+
* the smoke detector that catches a FUTURE mechanism gap (a new stack shape, a
|
|
585
|
+
* new provisioning path) by observation instead of by a user noticing a blank
|
|
586
|
+
* iframe days later.
|
|
587
|
+
*
|
|
588
|
+
* Input is the editor's own `/api/config` payload — the effective truth table
|
|
589
|
+
* (`previewSameOriginSafe` + the resolved `previewOrigin`), not a
|
|
590
|
+
* re-derivation from stack.json over ssh. True iff the stack is NOT
|
|
591
|
+
* same-origin-safe AND its effective previewOrigin is one a remote browser can
|
|
592
|
+
* never load (the `/__codeyam_preview` sentinel, any relative path, or a
|
|
593
|
+
* loopback origin) — i.e. exactly `isWrongModePreviewOrigin`'s wrong-mode
|
|
594
|
+
* condition, evaluated against the live config rather than editor.local.json.
|
|
595
|
+
* Composing the two keeps ONE definition of "wrong-mode origin" in this file.
|
|
596
|
+
*
|
|
597
|
+
* Deliberately conservative: a missing/unparseable payload, an absent
|
|
598
|
+
* `previewSameOriginSafe`, or a legitimate same-origin-safe stack on the
|
|
599
|
+
* subpath all return false. Absence of evidence is never a false alarm — the
|
|
600
|
+
* caller renders "unknown" instead. Pure. */
|
|
601
|
+
function isSilentlyBrokenPreview(apiConfig) {
|
|
602
|
+
if (!apiConfig || typeof apiConfig !== "object") return false;
|
|
603
|
+
if (apiConfig.previewSameOriginSafe !== false) return false;
|
|
604
|
+
return isWrongModePreviewOrigin(apiConfig, false);
|
|
605
|
+
}
|
|
606
|
+
|
|
551
607
|
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
552
608
|
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
553
609
|
function readCloudflaredUrls() {
|
|
@@ -745,6 +801,28 @@ function buildEditorLocalEnvPatch(localEnv, agent) {
|
|
|
745
801
|
return { provider: agent, env: { [varName]: value } };
|
|
746
802
|
}
|
|
747
803
|
|
|
804
|
+
/** Build the editor.local.json patch that seeds a GREENFIELD project's
|
|
805
|
+
* `projectTitle` so its live identity resolves to the real project name from
|
|
806
|
+
* first boot. A greenfield project (`--new-project <name>`, no clientRepo) has
|
|
807
|
+
* no origin remote and is bind-mounted at /workspace on the VM, so the editor's
|
|
808
|
+
* identity resolver would otherwise fall back to the bare mount basename
|
|
809
|
+
* "workspace" — which the fleet dashboard then reads as a permanent false
|
|
810
|
+
* project-dir-drift. Seeding `projectTitle` (deep-merged over editor.json on the
|
|
811
|
+
* VM) makes `project_identity::read` prefer the real name.
|
|
812
|
+
*
|
|
813
|
+
* Returns `null` for a clone (a repo Add already labels from its origin
|
|
814
|
+
* basename, and re-seeding a clone's title would be wrong) or when no
|
|
815
|
+
* greenfield name is available, so the caller can no-op without an SSH round
|
|
816
|
+
* trip. Pure — mirrors buildEditorLocalEnvPatch so the decision is unit-tested
|
|
817
|
+
* without a live VM. */
|
|
818
|
+
function buildGreenfieldTitlePatch(cfg = {}) {
|
|
819
|
+
const p = resolveProject(cfg);
|
|
820
|
+
if (!p.greenfield) return null;
|
|
821
|
+
const name = typeof p.name === 'string' ? p.name.trim() : '';
|
|
822
|
+
if (!name) return null;
|
|
823
|
+
return { projectTitle: name };
|
|
824
|
+
}
|
|
825
|
+
|
|
748
826
|
/** Parse `docker compose ps --format json` (compose v2 — one JSON object per
|
|
749
827
|
* line) and return the editor service's `State` string, or null when the
|
|
750
828
|
* service is absent / output is unparseable. Two callers shared an identical
|
|
@@ -787,7 +865,7 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
787
865
|
return [
|
|
788
866
|
"compute", "instances", "create", instanceName,
|
|
789
867
|
"--project", project, "--zone", zone,
|
|
790
|
-
|
|
868
|
+
`--machine-type=${cfg.machineType || DEFAULT_FLEET_MACHINE_TYPE}`,
|
|
791
869
|
"--image-family=debian-12",
|
|
792
870
|
"--image-project=debian-cloud",
|
|
793
871
|
"--boot-disk-size=100GB",
|
|
@@ -1494,6 +1572,43 @@ function bootstrapLogTail(cfg, lines = 40) {
|
|
|
1494
1572
|
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1495
1573
|
}
|
|
1496
1574
|
|
|
1575
|
+
/** Assemble the extra provision-time env forwarded into the bootstrap shell,
|
|
1576
|
+
* where it feeds the compose-render substitution. Currently just the admin
|
|
1577
|
+
* toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
|
|
1578
|
+
* editor-server process env (handlers.rs::admin_enabled, strict literal "1").
|
|
1579
|
+
* The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
|
|
1580
|
+
* (which lands as cfg.admin); anything else forwards "" so a normal launch —
|
|
1581
|
+
* and a re-provision without the flag — is strict non-admin. Pure over its
|
|
1582
|
+
* inputs so cloud.test.js can pin it without touching gcloud. */
|
|
1583
|
+
function buildProvisionEnv(env = process.env, flags = {}) {
|
|
1584
|
+
const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
|
|
1585
|
+
return { CODEYAM_ADMIN: admin ? "1" : "" };
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/** Build the one-shot SSH shell command that reconciles a running VM's admin
|
|
1589
|
+
* mode without a full re-provision: rewrite the rendered compose's
|
|
1590
|
+
* CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
|
|
1591
|
+
* VM whose compose predates the placeholder — then recreate the editor service
|
|
1592
|
+
* so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
|
|
1593
|
+
* normalizes), so inlining it into sed is safe. Pure/string-only so
|
|
1594
|
+
* cloud.test.js can assert its shape without SSHing. */
|
|
1595
|
+
function buildReconcileAdminCommand(repoName, value) {
|
|
1596
|
+
const f = "docker-compose.cloud.yml";
|
|
1597
|
+
const entry = ` - CODEYAM_ADMIN=${value}`;
|
|
1598
|
+
// Capture only the leading indentation (\1) and re-emit the literal key, so a
|
|
1599
|
+
// value that starts with a digit (e.g. "1") never lands right after the
|
|
1600
|
+
// backreference — `\11` would be read by sed as group 11, not `\1` + "1".
|
|
1601
|
+
return (
|
|
1602
|
+
`cd ~/projects/${repoName} && ` +
|
|
1603
|
+
`if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
|
|
1604
|
+
`sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
|
|
1605
|
+
`else ` +
|
|
1606
|
+
`sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
|
|
1607
|
+
`fi && ` +
|
|
1608
|
+
`docker compose -f ${f} up -d editor`
|
|
1609
|
+
);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1497
1612
|
async function ensureProjectBootstrapped(cfg) {
|
|
1498
1613
|
const repoName = resolveProject(cfg).name;
|
|
1499
1614
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
@@ -1547,12 +1662,18 @@ async function ensureProjectBootstrapped(cfg) {
|
|
|
1547
1662
|
const ingressEnv = {};
|
|
1548
1663
|
const ingressPlan = ingressWiringPlan(cfg);
|
|
1549
1664
|
if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
|
|
1665
|
+
// Forward the admin-mode toggle (Home-tab Setup door) into the compose
|
|
1666
|
+
// render. cfg.admin carries the `--admin` flag; process.env.CODEYAM_ADMIN
|
|
1667
|
+
// carries the `CODEYAM_ADMIN=1 npm run cloud:up` form. Default "" =
|
|
1668
|
+
// non-admin, so this leaves the normal launch and re-provision unchanged.
|
|
1669
|
+
const provisionEnv = buildProvisionEnv(process.env, cfg);
|
|
1550
1670
|
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
1551
1671
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
1552
1672
|
CY_GIT_USER_NAME: laptopGit.name,
|
|
1553
1673
|
CY_GIT_USER_EMAIL: laptopGit.email,
|
|
1554
1674
|
...ingressEnv,
|
|
1555
1675
|
...forwardEnv,
|
|
1676
|
+
...provisionEnv,
|
|
1556
1677
|
});
|
|
1557
1678
|
const result = runGcloud(
|
|
1558
1679
|
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
@@ -1761,49 +1882,113 @@ function resolveGhcrPushToken() {
|
|
|
1761
1882
|
return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
|
|
1762
1883
|
}
|
|
1763
1884
|
|
|
1885
|
+
// Registry manifest media types we accept when probing image presence over the
|
|
1886
|
+
// GHCR HTTP API — Docker v2 + manifest lists and their OCI equivalents, so a
|
|
1887
|
+
// multi-arch index answers 200 the same as a single-arch manifest.
|
|
1888
|
+
const MANIFEST_ACCEPT = [
|
|
1889
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
1890
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
1891
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
1892
|
+
"application/vnd.oci.image.index.v1+json",
|
|
1893
|
+
].join(", ");
|
|
1894
|
+
const GHCR_PROBE_TIMEOUT_MS = 15000;
|
|
1895
|
+
|
|
1896
|
+
/** Default HTTPS client for the registry probe: a GET that resolves to
|
|
1897
|
+
* `{ statusCode }` and NEVER rejects — any socket / DNS / timeout error resolves
|
|
1898
|
+
* to `statusCode: 0` (the probe reads that as "undetermined"). Injected as the
|
|
1899
|
+
* `httpImpl` option so probeUsableEditorImage is unit-testable with no network
|
|
1900
|
+
* (and no Docker daemon). `requestImpl` (defaulting to `https.request`) is
|
|
1901
|
+
* injectable so the response / error / timeout mapping is itself unit-testable
|
|
1902
|
+
* without a real socket. */
|
|
1903
|
+
function httpsManifestGet({ hostname, path: reqPath, headers }, requestImpl = https.request) {
|
|
1904
|
+
return new Promise((resolve) => {
|
|
1905
|
+
const req = requestImpl(
|
|
1906
|
+
{ hostname, path: reqPath, method: "GET", headers },
|
|
1907
|
+
(res) => {
|
|
1908
|
+
res.resume(); // drain the body so the socket is freed
|
|
1909
|
+
res.on("end", () => resolve({ statusCode: res.statusCode }));
|
|
1910
|
+
},
|
|
1911
|
+
);
|
|
1912
|
+
req.on("error", () => resolve({ statusCode: 0 }));
|
|
1913
|
+
req.setTimeout(GHCR_PROBE_TIMEOUT_MS, () => req.destroy());
|
|
1914
|
+
req.end();
|
|
1915
|
+
});
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
/** Probe ONE fully-qualified image ref (`host/repo:tag`) over the registry HTTP
|
|
1919
|
+
* API — no docker daemon, so a docker-less operator host can still determine
|
|
1920
|
+
* presence. Returns true (200 present), false (404 absent), or null (auth /
|
|
1921
|
+
* network / malformed-ref → can't tell absent from forbidden). */
|
|
1922
|
+
async function probeManifestRef(fullRef, bearer, httpGet) {
|
|
1923
|
+
const colon = fullRef.lastIndexOf(":");
|
|
1924
|
+
if (colon < 0) return null;
|
|
1925
|
+
const ref = fullRef.slice(0, colon); // host/repo
|
|
1926
|
+
const tag = fullRef.slice(colon + 1); // slug-latest
|
|
1927
|
+
const slash = ref.indexOf("/");
|
|
1928
|
+
if (slash < 0) return null;
|
|
1929
|
+
const hostname = ref.slice(0, slash); // ghcr.io
|
|
1930
|
+
const repo = ref.slice(slash + 1); // codeyam-ai/codeyam-editor-cloud-smoke
|
|
1931
|
+
let res;
|
|
1932
|
+
try {
|
|
1933
|
+
res = await httpGet({
|
|
1934
|
+
hostname,
|
|
1935
|
+
path: `/v2/${repo}/manifests/${encodeURIComponent(tag)}`,
|
|
1936
|
+
headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
|
|
1937
|
+
});
|
|
1938
|
+
} catch (_e) {
|
|
1939
|
+
return null;
|
|
1940
|
+
}
|
|
1941
|
+
const code = res && res.statusCode;
|
|
1942
|
+
if (code === 200) return true;
|
|
1943
|
+
if (code === 404) return false;
|
|
1944
|
+
return null; // 401 / 403 / 5xx / 0 (network) → undetermined
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1764
1947
|
/** Read-only probe: does a usable prebuilt editor image already exist on the
|
|
1765
1948
|
* registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
|
|
1766
1949
|
* add to decide whether to pace one VM ahead of the rest (see
|
|
1767
|
-
* npm/fleet-prewarm.js::decideBatchPrewarm).
|
|
1768
|
-
*
|
|
1950
|
+
* npm/fleet-prewarm.js::decideBatchPrewarm).
|
|
1951
|
+
*
|
|
1952
|
+
* Queries the registry over its HTTP API (`GET /v2/<repo>/manifests/<tag>`, a
|
|
1953
|
+
* HEAD-equivalent that reads the manifest without pulling the multi-GB image)
|
|
1954
|
+
* instead of `docker login` / `docker manifest inspect`. This is the root fix
|
|
1955
|
+
* for the docker-less GCE operator host: the old docker path errored on the
|
|
1956
|
+
* first `docker login` spawn (the control plane has no docker daemon) and so
|
|
1957
|
+
* returned `null` forever, which decideBatchPrewarm read as "fan out" — the
|
|
1958
|
+
* image was therefore never published and every cold add source-built. Over
|
|
1959
|
+
* HTTP the probe returns a DEFINITIVE verdict with no daemon.
|
|
1769
1960
|
*
|
|
1770
1961
|
* Returns:
|
|
1771
1962
|
* true — at least one of the usable tags exists (a cold add can pull; no pacer)
|
|
1772
1963
|
* false — every tag was checked and none exists (known-absent; pacing pays)
|
|
1773
|
-
* null — undetermined: no read token,
|
|
1774
|
-
*
|
|
1775
|
-
* "fan out" (today's behavior, never worse) rather than risk a
|
|
1776
|
-
* 10-min serialization that might buy nothing.
|
|
1964
|
+
* null — undetermined: no read token, or an auth/network error that can't be
|
|
1965
|
+
* distinguished from "absent".
|
|
1777
1966
|
*
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
|
|
1967
|
+
* GHCR's private packages require auth; without a read token we can't tell
|
|
1968
|
+
* absent from forbidden, so → null. The read token is sent as the registry
|
|
1969
|
+
* bearer (GHCR accepts a base64-encoded PAT directly — no separate token-
|
|
1970
|
+
* exchange round-trip). `httpImpl` is injectable for unit tests; a legacy
|
|
1971
|
+
* `spawnImpl` option is still accepted (and ignored) for backward-compat — the
|
|
1972
|
+
* HTTP happy path needs no spawn. */
|
|
1973
|
+
async function probeUsableEditorImage({ branch, registry, token, httpImpl } = {}) {
|
|
1782
1974
|
const fp = require("./fleet-prewarm");
|
|
1783
|
-
const spawn = spawnImpl || spawnSync;
|
|
1784
1975
|
const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
|
|
1785
1976
|
const readToken = token || resolveGhcrToken();
|
|
1786
1977
|
if (!readToken) return null; // can't auth → can't distinguish absent from forbidden
|
|
1787
|
-
const
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
{ input: readToken, encoding: "utf8" },
|
|
1792
|
-
);
|
|
1793
|
-
// spawn error (docker missing) or a non-zero login → undetermined.
|
|
1794
|
-
if (!login || login.error || login.status !== 0) return null;
|
|
1978
|
+
const httpGet = httpImpl || httpsManifestGet;
|
|
1979
|
+
// GHCR accepts a base64-encoded PAT as the registry bearer — no token-exchange
|
|
1980
|
+
// round-trip needed.
|
|
1981
|
+
const bearer = Buffer.from(readToken).toString("base64");
|
|
1795
1982
|
const tags = fp.usableImageTags({ registry: reg, branch });
|
|
1796
1983
|
let allClean = true;
|
|
1797
|
-
for (const
|
|
1798
|
-
const
|
|
1799
|
-
if (
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
// Every tag inspected cleanly and none existed → definitively absent. If any
|
|
1806
|
-
// probe failed to run, we can't be sure → undetermined.
|
|
1984
|
+
for (const fullRef of tags) {
|
|
1985
|
+
const verdict = await probeManifestRef(fullRef, bearer, httpGet);
|
|
1986
|
+
if (verdict === true) return true; // a usable image exists
|
|
1987
|
+
if (verdict === null) allClean = false; // this tag's probe was inconclusive
|
|
1988
|
+
// verdict === false → this tag is absent; keep checking the rest
|
|
1989
|
+
}
|
|
1990
|
+
// Every tag probed cleanly and none existed → definitively absent. If any probe
|
|
1991
|
+
// was inconclusive we can't claim absence → undetermined.
|
|
1807
1992
|
return allClean ? false : null;
|
|
1808
1993
|
}
|
|
1809
1994
|
|
|
@@ -1840,6 +2025,15 @@ function classifyGitRemoteError(stderr) {
|
|
|
1840
2025
|
) {
|
|
1841
2026
|
return "network";
|
|
1842
2027
|
}
|
|
2028
|
+
// A greenfield project (`--new-project` scaffold) has no origin remote and an
|
|
2029
|
+
// unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
|
|
2030
|
+
// git repository". No token would fix this — there is simply nothing to push
|
|
2031
|
+
// yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
|
|
2032
|
+
// match narrow: `repository not found` is a genuine access failure against a
|
|
2033
|
+
// real remote and must NOT be swallowed here.
|
|
2034
|
+
if (lower.includes("does not appear to be a git repository")) {
|
|
2035
|
+
return "no-remote";
|
|
2036
|
+
}
|
|
1843
2037
|
return "other";
|
|
1844
2038
|
}
|
|
1845
2039
|
|
|
@@ -1877,6 +2071,14 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1877
2071
|
reason: "git binary failed to launch — not a credential gap, not re-seeding",
|
|
1878
2072
|
};
|
|
1879
2073
|
}
|
|
2074
|
+
if (category === "no-remote") {
|
|
2075
|
+
return {
|
|
2076
|
+
heal: false,
|
|
2077
|
+
category,
|
|
2078
|
+
reason:
|
|
2079
|
+
"no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
1880
2082
|
return {
|
|
1881
2083
|
heal: true,
|
|
1882
2084
|
category,
|
|
@@ -1894,6 +2096,16 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1894
2096
|
};
|
|
1895
2097
|
}
|
|
1896
2098
|
|
|
2099
|
+
/** The single GitHub credential line format shared by every seed path: the
|
|
2100
|
+
* host staging file, the durable on-disk `store` fallback, and the live
|
|
2101
|
+
* cache-daemon approve all expect `https://x-access-token:<token>@github.com`
|
|
2102
|
+
* (git's credential-URL form, matched by both the entrypoint parser and
|
|
2103
|
+
* git-credential-store). Centralized so the `x-access-token` shape has one
|
|
2104
|
+
* source of truth instead of being re-typed at each call site. */
|
|
2105
|
+
function githubCredentialLine(token) {
|
|
2106
|
+
return `https://x-access-token:${token}@github.com`;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
1897
2109
|
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1898
2110
|
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1899
2111
|
* without this, claude / codex / gemini inside the container can clone the
|
|
@@ -1919,21 +2131,28 @@ async function seedGithubCredentials(cfg, opts = {}) {
|
|
|
1919
2131
|
console.error(
|
|
1920
2132
|
`Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
|
|
1921
2133
|
);
|
|
1922
|
-
const credLine =
|
|
2134
|
+
const credLine = githubCredentialLine(token);
|
|
1923
2135
|
// Quote the token through one shell layer (gcloud ssh --command); the
|
|
1924
2136
|
// value is base64'd to keep it out of the shell history.
|
|
1925
2137
|
const b64 = Buffer.from(`${credLine}\n`).toString("base64");
|
|
1926
|
-
// Verify-before-
|
|
1927
|
-
// (`[ -s ]`), and only THEN
|
|
2138
|
+
// Verify-before-write: decode into a chmod-600 temp, assert it is non-empty
|
|
2139
|
+
// (`[ -s ]`), and only THEN copy it over the live staging file. An empty or
|
|
1928
2140
|
// unreadable decode must never clobber an existing good file — the entrypoint
|
|
1929
2141
|
// reads this file on every boot, and a silent empty write is exactly how a
|
|
1930
2142
|
// reprovision lands a VM that "could not read Username for github.com". The
|
|
1931
|
-
// trailing `echo OK` runs only on
|
|
2143
|
+
// trailing `echo OK` runs only on success, so an empty write can't mask
|
|
1932
2144
|
// success with a `&& echo` (the failure shape seen on the Claude creds sinks).
|
|
2145
|
+
//
|
|
2146
|
+
// Use `cat "$t" > "$f"` (in-place truncate), NOT `mv`: the staging file is
|
|
2147
|
+
// bind-mounted read-only into the running container, and `mv` mints a NEW
|
|
2148
|
+
// inode that the live `:ro` mount does not follow — so a `mv`-based re-seed
|
|
2149
|
+
// never reached the running container until a full recreate. Truncating the
|
|
2150
|
+
// existing file in place keeps its inode, so the durable write is visible to
|
|
2151
|
+
// the container immediately. Then remove the temp.
|
|
1933
2152
|
const remoteCmd =
|
|
1934
2153
|
`f=$HOME/.codeyam-cloud-git-credentials && t="$f.tmp.$$" && ` +
|
|
1935
2154
|
`echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
|
|
1936
|
-
`if [ -s "$t" ]; then
|
|
2155
|
+
`if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
|
|
1937
2156
|
`else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
|
|
1938
2157
|
const result = runGcloud(
|
|
1939
2158
|
buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
|
|
@@ -1944,26 +2163,76 @@ async function seedGithubCredentials(cfg, opts = {}) {
|
|
|
1944
2163
|
}
|
|
1945
2164
|
}
|
|
1946
2165
|
|
|
2166
|
+
/** Sentinel the remote read prints when it cannot find an `editor.json` under
|
|
2167
|
+
* any `~/projects/*` directory. Distinguishes "this VM has no scaffolded
|
|
2168
|
+
* project" (a real miss worth warning about) from "the project is scaffolded
|
|
2169
|
+
* but declares no port" (legitimately a `VITE_PORT` default). Without the
|
|
2170
|
+
* distinction both collapse to `{}` and the miss is silent. */
|
|
2171
|
+
const VM_APP_PORT_NO_CONFIG = "<<<NOCONFIG>>>";
|
|
2172
|
+
|
|
2173
|
+
/** Build the remote shell command that locates the project's `.codeyam` dir and
|
|
2174
|
+
* cats its two config files in one round-trip.
|
|
2175
|
+
*
|
|
2176
|
+
* Resolution is glob-based rather than `~/projects/<repoName>` alone: the host
|
|
2177
|
+
* directory is not guaranteed to be named after the repo (a renamed repo, a
|
|
2178
|
+
* manually-cloned dir, a fleet slug), and when the guess missed, the old code
|
|
2179
|
+
* silently returned `VITE_PORT` and forwarded the wrong port. The repo-named
|
|
2180
|
+
* path is still tried FIRST so a multi-project VM resolves deterministically;
|
|
2181
|
+
* the glob is the fallback. When nothing matches, print the sentinel so the
|
|
2182
|
+
* caller can warn instead of guessing. Pure string builder; no I/O. */
|
|
2183
|
+
function buildVmAppPortReadCommand(repoName) {
|
|
2184
|
+
return (
|
|
2185
|
+
`d=""; ` +
|
|
2186
|
+
`for c in ~/projects/${repoName}/.codeyam ~/projects/*/.codeyam; do ` +
|
|
2187
|
+
`if [ -f "$c/editor.json" ]; then d="$c"; break; fi; done; ` +
|
|
2188
|
+
`if [ -z "$d" ]; then echo '${VM_APP_PORT_NO_CONFIG}'; else ` +
|
|
2189
|
+
`cat "$d/editor.json" 2>/dev/null || echo '{}'; ` +
|
|
2190
|
+
`echo '<<<LOCAL>>>'; ` +
|
|
2191
|
+
`cat "$d/editor.local.json" 2>/dev/null || echo '{}'; fi`
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
1947
2195
|
/** Best-effort read of the VM's resolved app dev-server port so cloud:up's
|
|
1948
2196
|
* laptop `-L` forward, readiness probe, and previewOrigin seed all target the
|
|
1949
2197
|
* port the app actually serves on instead of a hardcoded 5173. Cats the VM's
|
|
1950
2198
|
* editor.json and editor.local.json in one SSH round-trip and resolves via
|
|
1951
|
-
* `resolveConfiguredAppPort`.
|
|
1952
|
-
*
|
|
1953
|
-
*
|
|
2199
|
+
* `resolveConfiguredAppPort`.
|
|
2200
|
+
*
|
|
2201
|
+
* Falls back to `VITE_PORT` on any failure — but fails LOUD, not silent. A
|
|
2202
|
+
* wrong port here is not a cosmetic default: it forwards the wrong `-L` port,
|
|
2203
|
+
* and if the preview ends up on a dedicated origin the viewer gets
|
|
2204
|
+
* "localhost refused to connect" with nothing pointing at the cause. So an SSH
|
|
2205
|
+
* failure or a VM with no scaffolded project warns on stderr naming that
|
|
2206
|
+
* symptom. A scaffolded project that simply declares no port is NOT a miss —
|
|
2207
|
+
* `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
|
|
2208
|
+
* on) and stays quiet. */
|
|
1954
2209
|
async function readVmAppPort(cfg) {
|
|
1955
2210
|
const repoName = resolveProject(cfg).name;
|
|
1956
|
-
const base = `~/projects/${repoName}/.codeyam`;
|
|
1957
2211
|
const read = runGcloud(
|
|
1958
2212
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
1959
|
-
command:
|
|
1960
|
-
`cat ${base}/editor.json 2>/dev/null || echo '{}'; ` +
|
|
1961
|
-
`echo '<<<LOCAL>>>'; ` +
|
|
1962
|
-
`cat ${base}/editor.local.json 2>/dev/null || echo '{}'`,
|
|
2213
|
+
command: buildVmAppPortReadCommand(repoName),
|
|
1963
2214
|
}),
|
|
1964
2215
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1965
2216
|
);
|
|
1966
|
-
if (read.status !== 0 || typeof read.stdout !== "string")
|
|
2217
|
+
if (read.status !== 0 || typeof read.stdout !== "string") {
|
|
2218
|
+
console.error(
|
|
2219
|
+
`[cloud] WARNING: could not read the VM's app port over SSH — ` +
|
|
2220
|
+
`falling back to ${VITE_PORT}. If the app does not serve on ` +
|
|
2221
|
+
`${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
|
|
2222
|
+
`Live Preview will show "localhost refused to connect".`,
|
|
2223
|
+
);
|
|
2224
|
+
return VITE_PORT;
|
|
2225
|
+
}
|
|
2226
|
+
if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
|
|
2227
|
+
console.error(
|
|
2228
|
+
`[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
|
|
2229
|
+
`VM (looked for '${repoName}', then any project dir) — falling back to ` +
|
|
2230
|
+
`${VITE_PORT}. If the project is scaffolded under a different name and ` +
|
|
2231
|
+
`serves on another port, the port-forward targets the wrong port and ` +
|
|
2232
|
+
`the Live Preview will show "localhost refused to connect".`,
|
|
2233
|
+
);
|
|
2234
|
+
return VITE_PORT;
|
|
2235
|
+
}
|
|
1967
2236
|
const parse = (s) => {
|
|
1968
2237
|
try {
|
|
1969
2238
|
const v = JSON.parse(s);
|
|
@@ -2287,6 +2556,66 @@ async function seedAgentApiKey(cfg) {
|
|
|
2287
2556
|
// vm--no-cli-install-before-container-started removed; do not re-add it.
|
|
2288
2557
|
}
|
|
2289
2558
|
|
|
2559
|
+
/** Pure: deep-merge `patch` over the VM's current editor.local.json content and
|
|
2560
|
+
* return the base64 of the pretty-printed result, ready for
|
|
2561
|
+
* buildEditorLocalSeedWriteCommand. `currentRaw` is the raw `cat` output of the
|
|
2562
|
+
* remote file (may be undefined, empty, or corrupt) — anything that doesn't
|
|
2563
|
+
* parse to a plain object is treated as `{}`, because overwriting a corrupt
|
|
2564
|
+
* override file with a known-good one is safer than refusing to seed. Extracted
|
|
2565
|
+
* so seedGreenfieldProjectTitle stays a thin I/O passthrough and the
|
|
2566
|
+
* parse+merge+encode is unit-tested without a live VM. */
|
|
2567
|
+
function mergeEditorLocalPatchB64(currentRaw, patch) {
|
|
2568
|
+
let current = {};
|
|
2569
|
+
try {
|
|
2570
|
+
const parsed = JSON.parse(currentRaw);
|
|
2571
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2572
|
+
current = parsed;
|
|
2573
|
+
}
|
|
2574
|
+
} catch {
|
|
2575
|
+
// Unparseable / missing remote content → start from {}.
|
|
2576
|
+
}
|
|
2577
|
+
const merged = deepMergeJson(current, patch);
|
|
2578
|
+
return Buffer.from(JSON.stringify(merged, null, 2), "utf8").toString("base64");
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/** Seed a GREENFIELD project's `projectTitle` into the VM's editor.local.json
|
|
2582
|
+
* BEFORE compose up, so the editor caches the real project name at startup and
|
|
2583
|
+
* the fleet dashboard renders the correct card label with no false
|
|
2584
|
+
* project-dir-drift. Like seedAgentApiKey, this MUST run before
|
|
2585
|
+
* ensureEditorContainerUp — the editor reads its config once at startup.
|
|
2586
|
+
*
|
|
2587
|
+
* No-op for a clone (buildGreenfieldTitlePatch returns null): a repo Add labels
|
|
2588
|
+
* from its origin basename, so there is nothing to seed and no SSH round-trip
|
|
2589
|
+
* is spent. Runs on every cloud:up, so a reconfigure re-seeds the title,
|
|
2590
|
+
* converging a VM provisioned before this fix onto the correct identity without
|
|
2591
|
+
* a destroy. Thin I/O passthrough: the parse+merge+encode lives in the pure
|
|
2592
|
+
* mergeEditorLocalPatchB64 helper. */
|
|
2593
|
+
async function seedGreenfieldProjectTitle(cfg) {
|
|
2594
|
+
const patch = buildGreenfieldTitlePatch(cfg);
|
|
2595
|
+
if (!patch) return; // clone (or no greenfield name) — nothing to seed
|
|
2596
|
+
|
|
2597
|
+
const repoName = resolveProject(cfg).name;
|
|
2598
|
+
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
2599
|
+
console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
|
|
2600
|
+
|
|
2601
|
+
const read = runGcloud(
|
|
2602
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2603
|
+
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
2604
|
+
}),
|
|
2605
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2606
|
+
);
|
|
2607
|
+
const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
|
|
2608
|
+
const write = runGcloud(
|
|
2609
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2610
|
+
command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
|
|
2611
|
+
}),
|
|
2612
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
2613
|
+
);
|
|
2614
|
+
if (write.status !== 0) {
|
|
2615
|
+
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2290
2619
|
/** Tri-state probe of the editor container's running state on the VM host.
|
|
2291
2620
|
* One ssh round-trip; returns `"running"`, `"not-running"`, or `"unknown"`.
|
|
2292
2621
|
*
|
|
@@ -2549,6 +2878,42 @@ function streamedScriptMergeBase(editorBranch) {
|
|
|
2549
2878
|
return mbResult.status === 0 ? mbResult.stdout.trim() : null;
|
|
2550
2879
|
}
|
|
2551
2880
|
|
|
2881
|
+
/** Fetch `editorBranch` from origin into a REAL remote-tracking ref
|
|
2882
|
+
* (`refs/remotes/origin/<branch>`) rather than only into `FETCH_HEAD`.
|
|
2883
|
+
*
|
|
2884
|
+
* Operator hosts are commonly shallow, single-branch clones that track only
|
|
2885
|
+
* `+refs/heads/main`, so `origin/<some-feature-branch>` is not a local ref and
|
|
2886
|
+
* every `git show origin/<branch>:<path>` against it fails. Fetching through an
|
|
2887
|
+
* explicit refspec creates the ref, which keeps the drift verdict and the bytes
|
|
2888
|
+
* actually streamed derived from the same object. Falls back to a plain fetch
|
|
2889
|
+
* (FETCH_HEAD only) when the refspec form is rejected — `readRemoteScriptBlob`
|
|
2890
|
+
* can still read through FETCH_HEAD in that case. */
|
|
2891
|
+
function fetchStreamedScriptRef(editorBranch) {
|
|
2892
|
+
const refspec = `${editorBranch}:refs/remotes/origin/${editorBranch}`;
|
|
2893
|
+
const withRef = spawnSync("git", ["fetch", "--force", "origin", refspec], { encoding: "utf8" });
|
|
2894
|
+
if (withRef.status === 0) return { ok: true, stderr: "" };
|
|
2895
|
+
|
|
2896
|
+
const plain = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
|
|
2897
|
+
if (plain.status === 0) return { ok: true, stderr: "" };
|
|
2898
|
+
return { ok: false, stderr: (plain.stderr || withRef.stderr || "").trim() };
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
/** Resolve one revision of a streamed script from the fetched remote, trying the
|
|
2902
|
+
* remote-tracking ref first and `FETCH_HEAD` second. `gitArg` is the git verb
|
|
2903
|
+
* (`rev-parse` for the blob hash, `show` for the bytes) so the drift facts and the
|
|
2904
|
+
* streamed bytes resolve through the SAME ref-preference order — a verdict of
|
|
2905
|
+
* "stream remote" can therefore never disagree with what actually got streamed.
|
|
2906
|
+
* Returns `{ ok: false }` only when the blob is unreadable through both refs. */
|
|
2907
|
+
function resolveRemoteScriptRev(gitArg, relPath, editorBranch) {
|
|
2908
|
+
for (const ref of [`origin/${editorBranch}`, "FETCH_HEAD"]) {
|
|
2909
|
+
const result = spawnSync("git", [gitArg, `${ref}:${relPath}`], { encoding: "utf8" });
|
|
2910
|
+
if (result.status === 0 && typeof result.stdout === "string") {
|
|
2911
|
+
return { ok: true, stdout: result.stdout, ref };
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
2914
|
+
return { ok: false, stdout: "", ref: null };
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2552
2917
|
/** Gather the drift facts for one streamed script relative to `origin/<editorBranch>`:
|
|
2553
2918
|
* its local (working-tree) blob hash, the remote-tip blob hash, the merge-base blob
|
|
2554
2919
|
* hash, and whether the working tree is dirty. Any git failure yields a null hash,
|
|
@@ -2560,11 +2925,8 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
|
|
|
2560
2925
|
const localHashResult = spawnSync("git", ["hash-object", relPath], { encoding: "utf8" });
|
|
2561
2926
|
const localHash = localHashResult.status === 0 ? localHashResult.stdout.trim() : null;
|
|
2562
2927
|
|
|
2563
|
-
|
|
2564
|
-
const
|
|
2565
|
-
if (remoteHashResult.status === 0) {
|
|
2566
|
-
remoteHash = remoteHashResult.stdout.trim();
|
|
2567
|
-
}
|
|
2928
|
+
const remoteRev = resolveRemoteScriptRev("rev-parse", relPath, editorBranch);
|
|
2929
|
+
const remoteHash = remoteRev.ok ? remoteRev.stdout.trim() : null;
|
|
2568
2930
|
|
|
2569
2931
|
let baseHash = null;
|
|
2570
2932
|
if (mergeBase) {
|
|
@@ -2587,7 +2949,15 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
|
|
|
2587
2949
|
* un-pulled dashboard can't add VMs" failure. Streams the on-disk copy instead
|
|
2588
2950
|
* when the operator authored local content the remote tip lacks (dirty edit or a
|
|
2589
2951
|
* committed local-ahead change — developer mode) or when the remote is unreachable
|
|
2590
|
-
* (offline fallback
|
|
2952
|
+
* (offline fallback — `remoteHash === null`, the only sanctioned on-disk path).
|
|
2953
|
+
*
|
|
2954
|
+
* A "remote" verdict NEVER falls back to the on-disk copy. On a clean-but-behind
|
|
2955
|
+
* checkout the on-disk bytes are precisely the stale bytes the drift guard exists
|
|
2956
|
+
* to keep off the VM, so substituting them silently defeats the guard: the verdict
|
|
2957
|
+
* said "stream the reviewed remote tip" and the VM would instead receive the old
|
|
2958
|
+
* script, with only a `[Warning]` to show for it. When the remote blob cannot be
|
|
2959
|
+
* read through either ref this throws, aborting the add.
|
|
2960
|
+
* `absScriptPath` is an absolute path under the repo root. */
|
|
2591
2961
|
function readStreamedScript(absScriptPath, cfg = {}) {
|
|
2592
2962
|
const relPath = path.relative(rootDir, absScriptPath) || absScriptPath;
|
|
2593
2963
|
const editorBranch = streamedScriptEditorBranch(cfg);
|
|
@@ -2596,15 +2966,18 @@ function readStreamedScript(absScriptPath, cfg = {}) {
|
|
|
2596
2966
|
|
|
2597
2967
|
const { streamedScriptSource } = require("./script-drift");
|
|
2598
2968
|
if (streamedScriptSource(facts) === "remote") {
|
|
2599
|
-
const remote =
|
|
2600
|
-
if (remote.
|
|
2601
|
-
console.error(` streaming canonical ${relPath} from
|
|
2969
|
+
const remote = resolveRemoteScriptRev("show", relPath, editorBranch);
|
|
2970
|
+
if (remote.ok) {
|
|
2971
|
+
console.error(` streaming canonical ${relPath} from ${remote.ref} (checkout behind remote is fine)`);
|
|
2602
2972
|
return remote.stdout;
|
|
2603
2973
|
}
|
|
2604
|
-
//
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2974
|
+
// The facts said "the remote tip is the canonical source" but its bytes are
|
|
2975
|
+
// unreadable. Streaming the on-disk copy here would provision unreviewed stale
|
|
2976
|
+
// bytes under the banner of a successful add — fail loud instead.
|
|
2977
|
+
throw new Error(
|
|
2978
|
+
`could not read ${relPath} from origin/${editorBranch} (or FETCH_HEAD); ` +
|
|
2979
|
+
`refusing to stream a possibly-stale on-disk copy. ` +
|
|
2980
|
+
`Fetch the branch first: git fetch origin ${editorBranch}:refs/remotes/origin/${editorBranch}`,
|
|
2608
2981
|
);
|
|
2609
2982
|
}
|
|
2610
2983
|
return fs.readFileSync(absScriptPath, "utf8");
|
|
@@ -2626,10 +2999,11 @@ function checkScriptDrift(cfg) {
|
|
|
2626
2999
|
|
|
2627
3000
|
console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
|
|
2628
3001
|
|
|
2629
|
-
// 1.
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
3002
|
+
// 1. Fetch into refs/remotes/origin/<editorBranch> so the ref resolves even on a
|
|
3003
|
+
// single-branch clone that tracks only +refs/heads/main.
|
|
3004
|
+
const fetchResult = fetchStreamedScriptRef(editorBranch);
|
|
3005
|
+
if (!fetchResult.ok) {
|
|
3006
|
+
console.error(` [Warning] Failed to fetch origin ${editorBranch} (${fetchResult.stderr || "unknown error"}). Skipping drift check.`);
|
|
2633
3007
|
return;
|
|
2634
3008
|
}
|
|
2635
3009
|
|
|
@@ -2742,6 +3116,29 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
|
|
|
2742
3116
|
});
|
|
2743
3117
|
}
|
|
2744
3118
|
|
|
3119
|
+
/** Grant-only pass: (re-)apply one VM's default IAP access grant by spawning
|
|
3120
|
+
* `scripts/gcp/vm-ingress.sh <N> grant-access` — the churn-free action that runs
|
|
3121
|
+
* `apply_default_iap_access` without touching any NEG/BES/host-rule resource.
|
|
3122
|
+
* Reconcile uses this for already-`wired` VMs its re-wire loop skips, so a healed
|
|
3123
|
+
* VM whose backends already exist still (re-)grants the automation SA the IAP
|
|
3124
|
+
* accessor. Returns the spawnSync result so the caller logs granted vs failed;
|
|
3125
|
+
* best-effort + loud, never fatal. */
|
|
3126
|
+
function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
|
|
3127
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3128
|
+
const inv = vmIngressGrantAccessInvocation({
|
|
3129
|
+
scriptPath,
|
|
3130
|
+
vmNumber,
|
|
3131
|
+
domain,
|
|
3132
|
+
project: cfg.project,
|
|
3133
|
+
zone: cfg.zone,
|
|
3134
|
+
slug,
|
|
3135
|
+
});
|
|
3136
|
+
return spawnSync(inv.command, inv.args, {
|
|
3137
|
+
stdio: "inherit",
|
|
3138
|
+
env: { ...process.env, ...inv.env },
|
|
3139
|
+
});
|
|
3140
|
+
}
|
|
3141
|
+
|
|
2745
3142
|
/** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
|
|
2746
3143
|
* Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
|
|
2747
3144
|
* (the VM is reachable operator-side over SSH regardless), so on failure it
|
|
@@ -3285,6 +3682,10 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
3285
3682
|
}
|
|
3286
3683
|
}
|
|
3287
3684
|
await seedAgentApiKey(cfg);
|
|
3685
|
+
// Greenfield only (no-op for a clone): seed projectTitle so the editor
|
|
3686
|
+
// resolves its identity to the real project name instead of the bare
|
|
3687
|
+
// /workspace mount basename — the source of the false project-dir-drift.
|
|
3688
|
+
await seedGreenfieldProjectTitle(cfg);
|
|
3288
3689
|
await ensureEditorContainerUp(cfg);
|
|
3289
3690
|
// Provision contract: a just-provisioned VM must actually reach GitHub.
|
|
3290
3691
|
// Verify in-container git auth now that the container (and its credential
|
|
@@ -3647,9 +4048,26 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3647
4048
|
});
|
|
3648
4049
|
const targets = selectIngressReconcileTargets(statuses);
|
|
3649
4050
|
const targetNums = new Set(targets.map((t) => t.vmNumber));
|
|
4051
|
+
// The complement of the re-wire loop: `wired` VMs whose backends already exist
|
|
4052
|
+
// so the re-wire loop skips them, yet still need the SA-accessor (re-)granted —
|
|
4053
|
+
// `up` is the only path that grants it, and it never runs for a wired VM. This
|
|
4054
|
+
// closes the VM-8 heal-path gap (a wired VM whose backends predate the SA-grant
|
|
4055
|
+
// fix would otherwise stay 403 forever). Disjoint from `targets` by construction.
|
|
4056
|
+
const grantOnlyNums = new Set(
|
|
4057
|
+
selectIngressGrantOnlyTargets(statuses).map((g) => g.vmNumber),
|
|
4058
|
+
);
|
|
3650
4059
|
for (const s of statuses) {
|
|
3651
|
-
if (
|
|
3652
|
-
|
|
4060
|
+
if (targetNums.has(s.vmNumber)) continue;
|
|
4061
|
+
const label = s.slug || `vm-${s.vmNumber}`;
|
|
4062
|
+
if (grantOnlyNums.has(s.vmNumber)) {
|
|
4063
|
+
// Churn-free grant-only pass: (re-)grant the accessor without re-wiring any
|
|
4064
|
+
// NEG/BES/host-rule resource. Idempotent (a no-op when the SA is already
|
|
4065
|
+
// bound) and best-effort-but-loud — a grant hiccup logs, never aborts.
|
|
4066
|
+
const gres = runVmIngressGrantAccess(cfg, s.vmNumber, domain, s.slug);
|
|
4067
|
+
console.error(
|
|
4068
|
+
` VM-${s.vmNumber} ${label}: wired → ok (accessor ${gres.status === 0 ? "granted" : "grant FAILED"})`,
|
|
4069
|
+
);
|
|
4070
|
+
} else {
|
|
3653
4071
|
console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
|
|
3654
4072
|
}
|
|
3655
4073
|
}
|
|
@@ -3689,6 +4107,12 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3689
4107
|
}
|
|
3690
4108
|
}
|
|
3691
4109
|
|
|
4110
|
+
// Converge the shared fleet firewall, then surface any app port it still fails to
|
|
4111
|
+
// admit. Both ride this same boot + periodic cadence so the running fleet
|
|
4112
|
+
// self-heals without a hand-run bootstrap.
|
|
4113
|
+
const firewallState = convergeFleetFirewall({ cfg });
|
|
4114
|
+
await reportFirewallPortGaps({ cfg, roster, firewallState });
|
|
4115
|
+
|
|
3692
4116
|
// Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): any
|
|
3693
4117
|
// backend service whose VM number is gone from the roster is a destroyed VM's
|
|
3694
4118
|
// leftover — cleaned here automatically with the verified `down`, so the
|
|
@@ -3697,22 +4121,131 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3697
4121
|
sweepOrphanIngress({ cfg, domain, roster, projectDir });
|
|
3698
4122
|
}
|
|
3699
4123
|
|
|
3700
|
-
/** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress):
|
|
3701
|
-
*
|
|
3702
|
-
* has no in-flight add. Enumerates the live
|
|
3703
|
-
* VM
|
|
3704
|
-
*
|
|
3705
|
-
*
|
|
3706
|
-
*
|
|
3707
|
-
*
|
|
3708
|
-
*
|
|
3709
|
-
* never
|
|
3710
|
-
*
|
|
3711
|
-
*
|
|
3712
|
-
*
|
|
4124
|
+
/** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): LB
|
|
4125
|
+
* resources whose owning VM number is gone from the roster (a destroyed VM) and
|
|
4126
|
+
* has no in-flight add. Enumerates the live backend services AND zonal NEGs,
|
|
4127
|
+
* UNIONs their owning VM numbers (so a partial teardown whose NEG outlived its
|
|
4128
|
+
* backend service — the VM-5/6 case — still surfaces its VM as a candidate),
|
|
4129
|
+
* maps each name back to its VM number, subtracts the roster + in-flight adds +
|
|
4130
|
+
* live instances (the false-orphan guards), and runs the VERIFIED
|
|
4131
|
+
* `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no, with retries) for
|
|
4132
|
+
* each orphan — so an orphan is genuinely cleared or surfaced as a real failure,
|
|
4133
|
+
* never a silent leak and never a manual command. Emits a machine-readable
|
|
4134
|
+
* `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to reconcile its
|
|
4135
|
+
* orphan-attention banner; the summary carries `stillPresent` (VM numbers that
|
|
4136
|
+
* still own ≥1 LB resource after the sweep) so the dashboard can clear a stale
|
|
4137
|
+
* banner for a VM that is now back in the roster or has no residual resources.
|
|
4138
|
+
* Best-effort per VM: one `down` failing never aborts the rest of the sweep.
|
|
4139
|
+
* Fail-safe: if the live-instance list can't be fetched, teardown is skipped for
|
|
4140
|
+
* this pass (a live-but-unrostered VM must never be torn down) while the banner
|
|
4141
|
+
* reconcile still runs. Returns the `{ cleaned, failed, skipped, stillPresent }`
|
|
4142
|
+
* summary (null when the backend-service enumeration itself failed) — the same
|
|
4143
|
+
* shape it prints — so the flow is directly assertable. `deps` is injectable so
|
|
4144
|
+
* the enumerate → select → down flow is testable without gcloud. */
|
|
4145
|
+
/** Converge the shared fleet firewall rule to all-tcp so the LB reaches whatever
|
|
4146
|
+
* port a project's app actually serves.
|
|
4147
|
+
*
|
|
4148
|
+
* A rule pinned to a hardcoded port list (`tcp:14199,tcp:5173,tcp:3000`) drops
|
|
4149
|
+
* LB→VM traffic for any VM serving outside it — the VM-6-on-8080 Live Preview
|
|
4150
|
+
* outage — while the preview backend service still reads HEALTHY, because its
|
|
4151
|
+
* health check runs on the always-admitted editor port. The failure is therefore
|
|
4152
|
+
* invisible until someone opens the preview. Converging here (not only in the
|
|
4153
|
+
* hand-run google-ingress.sh bootstrap) is what makes a drifted rule self-heal on
|
|
4154
|
+
* the running fleet.
|
|
4155
|
+
*
|
|
4156
|
+
* All-tcp is the right exposure, not a per-stack port list: the rule stays scoped
|
|
4157
|
+
* to Google's LB + health-check source ranges and the fleet instance tag either
|
|
4158
|
+
* way, so it admits any app port (Vite 5173, Next 3000, Astro 4321, Go 8080, …)
|
|
4159
|
+
* without enumeration. EVERY candidate name in FLEET_FIREWALL_RULE_NAMES is
|
|
4160
|
+
* converged: describing only one silently no-ops on a fleet wired under the other.
|
|
4161
|
+
*
|
|
4162
|
+
* Scope-safe by construction — `update --rules=tcp` rewrites only the allowed
|
|
4163
|
+
* protocols/ports, never a rule's source ranges or target tags, so this can never
|
|
4164
|
+
* change who the rule applies to. Best-effort throughout: a describe/update
|
|
4165
|
+
* failure logs and continues, never aborting the rest of the reconcile.
|
|
4166
|
+
*
|
|
4167
|
+
* Returns `[{ ruleName, rules, converged }]` for every rule that exists — `rules`
|
|
4168
|
+
* is the post-converge allowed value — so the flow is assertable without gcloud.
|
|
4169
|
+
* `deps.spawnSync` is injectable for the same reason.
|
|
4170
|
+
* See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
|
|
4171
|
+
function convergeFleetFirewall({ cfg } = {}, deps = {}) {
|
|
4172
|
+
const run = deps.spawnSync || spawnSync;
|
|
4173
|
+
const project = cfg && cfg.project;
|
|
4174
|
+
const state = [];
|
|
4175
|
+
for (const ruleName of FLEET_FIREWALL_RULE_NAMES) {
|
|
4176
|
+
const dinv = fleetFirewallDescribeInvocation({ ruleName, project });
|
|
4177
|
+
const dres = run(dinv.command, dinv.args, { encoding: "utf8" });
|
|
4178
|
+
// A non-zero describe means no rule under this name — expected, not an error:
|
|
4179
|
+
// only one of the candidate names is deployed on any given fleet.
|
|
4180
|
+
if (!dres || dres.status !== 0) continue;
|
|
4181
|
+
const current = ((dres.stdout || "") + "").trim();
|
|
4182
|
+
if (!firewallNeedsConverge(current)) {
|
|
4183
|
+
console.error(` firewall ${ruleName}: already all-tcp → ok`);
|
|
4184
|
+
state.push({ ruleName, rules: "tcp", converged: false });
|
|
4185
|
+
continue;
|
|
4186
|
+
}
|
|
4187
|
+
const cinv = fleetFirewallConvergeInvocation({ ruleName, project });
|
|
4188
|
+
const cres = run(cinv.command, cinv.args, { encoding: "utf8" });
|
|
4189
|
+
if (cres && cres.status === 0) {
|
|
4190
|
+
console.error(` firewall ${ruleName}: rules '${current}' → tcp (all-tcp)`);
|
|
4191
|
+
state.push({ ruleName, rules: "tcp", converged: true });
|
|
4192
|
+
} else {
|
|
4193
|
+
const tail = (
|
|
4194
|
+
(cres && (cres.stderr || (cres.error && cres.error.message))) || ""
|
|
4195
|
+
).toString().trim().split("\n").pop() || "error";
|
|
4196
|
+
console.error(` firewall ${ruleName}: converge skipped (${tail})`);
|
|
4197
|
+
state.push({ ruleName, rules: current, converged: false });
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
if (!state.length) {
|
|
4201
|
+
console.error(
|
|
4202
|
+
` firewall: no rule found under ${FLEET_FIREWALL_RULE_NAMES.join(" / ")} —` +
|
|
4203
|
+
" LB→VM traffic may be blocked; run scripts/gcp/google-ingress.sh",
|
|
4204
|
+
);
|
|
4205
|
+
}
|
|
4206
|
+
return state;
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
/** Surface any rostered VM whose app port the fleet firewall does not admit — the
|
|
4210
|
+
* tripwire for a masked "HEALTHY but unreachable" preview.
|
|
4211
|
+
*
|
|
4212
|
+
* The preview backend service health-checks the fixed editor port, so a rule that
|
|
4213
|
+
* admits that port but not the app's real one reads HEALTHY while every real
|
|
4214
|
+
* request is dropped. That masking is what made the VM-6 outage invisible. Emits a
|
|
4215
|
+
* machine-readable `CY_FIREWALL_PORT_GAP <json>` line per gap, mirroring the
|
|
4216
|
+
* `CY_ORPHAN_SWEEP` marker convention the dashboard already parses.
|
|
4217
|
+
*
|
|
4218
|
+
* Only runs when a rule is still port-pinned: a converged all-tcp rule admits every
|
|
4219
|
+
* port, so the healthy path cannot produce a gap and skipping it avoids an SSH
|
|
4220
|
+
* round-trip per rostered VM for a check that cannot fail. In practice this fires
|
|
4221
|
+
* only if the converge itself failed (e.g. a permissions regression).
|
|
4222
|
+
*
|
|
4223
|
+
* Returns the emitted `[{ vmNumber, appPort, rule, allowed }]` gaps — the same
|
|
4224
|
+
* shape it prints — so the marker contract is directly assertable.
|
|
4225
|
+
* `deps.readVmAppPort` is injectable so tests never reach for SSH. */
|
|
4226
|
+
async function reportFirewallPortGaps({ cfg, roster, firewallState } = {}, deps = {}) {
|
|
4227
|
+
const readPort = deps.readVmAppPort || readVmAppPort;
|
|
4228
|
+
const pinned = (firewallState || []).filter((f) => firewallNeedsConverge(f.rules));
|
|
4229
|
+
const gaps = [];
|
|
4230
|
+
if (!pinned.length) return gaps;
|
|
4231
|
+
for (const n of roster || []) {
|
|
4232
|
+
const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
|
|
4233
|
+
const appPort = await readPort(perVmCfg);
|
|
4234
|
+
for (const f of pinned) {
|
|
4235
|
+
if (firewallAdmitsPort(f.rules, appPort)) continue;
|
|
4236
|
+
const gap = { vmNumber: n, appPort, rule: f.ruleName, allowed: f.rules };
|
|
4237
|
+
console.error(` VM-${n}: app port ${appPort} NOT admitted by ${f.ruleName}`);
|
|
4238
|
+
console.log(`CY_FIREWALL_PORT_GAP ${JSON.stringify(gap)}`);
|
|
4239
|
+
gaps.push(gap);
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
return gaps;
|
|
4243
|
+
}
|
|
4244
|
+
|
|
3713
4245
|
function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {}, deps = {}) {
|
|
3714
4246
|
const gi = deps.googleIngress || require("./google-ingress.js");
|
|
3715
4247
|
const fcl = deps.fleetCleanup || require("./fleet-cleanup.js");
|
|
4248
|
+
const frecon = deps.fleetReconcile || require("./fleet-reconcile.js");
|
|
3716
4249
|
const run = deps.spawnSync || spawnSync;
|
|
3717
4250
|
|
|
3718
4251
|
const listInv = backendServicesListInvocation({ project: cfg && cfg.project });
|
|
@@ -3724,11 +4257,31 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3724
4257
|
console.error(` orphan sweep skipped — could not list backend services (${tail}).`);
|
|
3725
4258
|
return null;
|
|
3726
4259
|
}
|
|
3727
|
-
const
|
|
4260
|
+
const besVmNumbers = String(listRes.stdout || "")
|
|
3728
4261
|
.split("\n")
|
|
3729
4262
|
.map((line) => gi.vmNumberFromResourceName(line.trim()))
|
|
3730
4263
|
.filter((n) => n != null);
|
|
3731
4264
|
|
|
4265
|
+
// Also enumerate zonal NEGs and union their owners in: a partial teardown can
|
|
4266
|
+
// leave a `vm-<N>-<surface>-neg` behind after its backend service is already
|
|
4267
|
+
// gone, so a backend-service-only candidate set misses that residue. Best-effort
|
|
4268
|
+
// — a NEG-list failure never aborts the backend-service-driven sweep.
|
|
4269
|
+
let negVmNumbers = [];
|
|
4270
|
+
const negInv = negListInvocation({ project: cfg && cfg.project });
|
|
4271
|
+
const negRes = run(negInv.command, negInv.args, { encoding: "utf8" });
|
|
4272
|
+
if (negRes && negRes.status === 0) {
|
|
4273
|
+
negVmNumbers = String(negRes.stdout || "")
|
|
4274
|
+
.split("\n")
|
|
4275
|
+
.map((line) => gi.vmNumberFromResourceName(line.trim()))
|
|
4276
|
+
.filter((n) => n != null);
|
|
4277
|
+
} else {
|
|
4278
|
+
const tail = (
|
|
4279
|
+
(negRes && (negRes.stderr || (negRes.error && negRes.error.message))) || ""
|
|
4280
|
+
).toString().trim().split("\n").pop() || "error";
|
|
4281
|
+
console.error(` orphan sweep: could not list NEGs (${tail}) — sweeping backend services only.`);
|
|
4282
|
+
}
|
|
4283
|
+
const ingressVmNumbers = [...new Set([...besVmNumbers, ...negVmNumbers])].sort((a, b) => a - b);
|
|
4284
|
+
|
|
3732
4285
|
// A VM in the roster, or with an in-flight `add-<n>.json` sidecar (provisioning,
|
|
3733
4286
|
// not yet rostered), is live — never an orphan.
|
|
3734
4287
|
const pendingDir = process.env.PENDING_JOBS_DIR || path.join(projectDir, ".codeyam/logs/pending-jobs");
|
|
@@ -3736,9 +4289,34 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3736
4289
|
.listPendingJobs(pendingDir)
|
|
3737
4290
|
.filter((j) => j.action === "add")
|
|
3738
4291
|
.map((j) => j.n);
|
|
3739
|
-
const orphans = selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds });
|
|
3740
4292
|
|
|
3741
|
-
|
|
4293
|
+
// Defensive live-instance guard: never tear down a VM whose GCP instance is
|
|
4294
|
+
// actually running (a roster drift could leave a live VM unrostered; a teardown
|
|
4295
|
+
// that SUCCEEDED against it would knock it offline). `liveVmNumbers === null`
|
|
4296
|
+
// means the list couldn't be fetched — fail safe by skipping teardown this pass
|
|
4297
|
+
// (below) rather than risk a false teardown. The banner reconcile still runs.
|
|
4298
|
+
let liveVmNumbers = null;
|
|
4299
|
+
const instInv = instancesListInvocation({ project: cfg && cfg.project });
|
|
4300
|
+
const instRes = run(instInv.command, instInv.args, { encoding: "utf8" });
|
|
4301
|
+
if (instRes && instRes.status === 0) {
|
|
4302
|
+
liveVmNumbers = frecon.parseRunningFleetVms(instRes.stdout, fleetInstancePrefix());
|
|
4303
|
+
} else {
|
|
4304
|
+
const tail = (
|
|
4305
|
+
(instRes && (instRes.stderr || (instRes.error && instRes.error.message))) || ""
|
|
4306
|
+
).toString().trim().split("\n").pop() || "error";
|
|
4307
|
+
console.error(` orphan sweep: could not list instances (${tail}) — skipping teardown this pass (banners still reconciled).`);
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
const orphans = liveVmNumbers === null
|
|
4311
|
+
? []
|
|
4312
|
+
: selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers });
|
|
4313
|
+
|
|
4314
|
+
// stillPresent = every VM that owned an LB resource going in, minus the ones we
|
|
4315
|
+
// verifiably clean below. The dashboard clears a banner for any orphan VM not in
|
|
4316
|
+
// this set (or now back in the roster). Computed up front so the no-orphans and
|
|
4317
|
+
// fail-safe-skip paths report it too — that is what unfreezes a VM-3-style banner
|
|
4318
|
+
// (re-rostered, so never a sweep target) with no teardown at all.
|
|
4319
|
+
const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice(), stillPresent: ingressVmNumbers.slice() };
|
|
3742
4320
|
if (!orphans.length) {
|
|
3743
4321
|
console.error(" orphan sweep: no orphaned LB ingress found.");
|
|
3744
4322
|
console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
|
|
@@ -3775,6 +4353,11 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3775
4353
|
console.error(` VM-${n}: orphaned ingress → auto-clean FAILED (${tail.slice(-200)})`);
|
|
3776
4354
|
}
|
|
3777
4355
|
}
|
|
4356
|
+
// A verifiably-cleaned VM no longer owns any LB resource — drop it from
|
|
4357
|
+
// stillPresent so the dashboard clears its banner (failed VMs stay, keeping
|
|
4358
|
+
// their real attention item).
|
|
4359
|
+
const cleanedSet = new Set(summary.cleaned);
|
|
4360
|
+
summary.stillPresent = ingressVmNumbers.filter((n) => !cleanedSet.has(n));
|
|
3778
4361
|
console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
|
|
3779
4362
|
return summary;
|
|
3780
4363
|
}
|
|
@@ -3828,20 +4411,33 @@ async function probeGitCredentials(cfg) {
|
|
|
3828
4411
|
/** Seed the LIVE in-memory git credential-cache daemon over its socket — the
|
|
3829
4412
|
* same `git credential approve` shape the entrypoint uses
|
|
3830
4413
|
* (scripts/cloud-editor-entrypoint.sh) — so push works immediately with no
|
|
3831
|
-
* container restart.
|
|
3832
|
-
* the
|
|
3833
|
-
*
|
|
4414
|
+
* container restart. ALSO writes the container-local on-disk `store` fallback
|
|
4415
|
+
* file (the fill-only second helper baked into the image) directly, so a
|
|
4416
|
+
* reconcile leaves the VM durable against the cache daemon dying mid-life, not
|
|
4417
|
+
* just healthy for the moment. base64 keeps the token out of the shell history
|
|
4418
|
+
* across the gcloud-ssh → docker layers, and the store write is an in-place
|
|
4419
|
+
* `cat > file` truncate (chmod 600), never touched by git's approve path.
|
|
4420
|
+
* Throws if either write fails. Shared by {@link cmdReconcileCredentials} and
|
|
4421
|
+
* {@link assertGitPushCredentials}. */
|
|
3834
4422
|
async function seedLiveCredentialCache(cfg, token) {
|
|
3835
4423
|
const repoName = resolveProject(cfg).name;
|
|
3836
4424
|
const projectCd = `cd ~/projects/${repoName}`;
|
|
3837
4425
|
const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
|
|
3838
4426
|
const socket = "/run/codeyam/git-credential-cache.socket";
|
|
4427
|
+
const store = "/run/codeyam/git-credentials-store";
|
|
3839
4428
|
const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
|
|
3840
4429
|
const b64 = Buffer.from(approveStdin).toString("base64");
|
|
4430
|
+
// The store-file line is the same one-URL-per-line format as the host staging
|
|
4431
|
+
// file; the image's fill-only `store` helper reads it on a cache miss.
|
|
4432
|
+
const storeB64 = Buffer.from(`${githubCredentialLine(token)}\n`).toString("base64");
|
|
3841
4433
|
const seedCmd =
|
|
3842
|
-
`${projectCd} &&
|
|
4434
|
+
`${projectCd} && ` +
|
|
4435
|
+
`echo ${b64} | base64 -d | ${composeExec} ` +
|
|
3843
4436
|
`git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
|
|
3844
|
-
`credential approve &&
|
|
4437
|
+
`credential approve && ` +
|
|
4438
|
+
`echo ${storeB64} | base64 -d | ${composeExec} ` +
|
|
4439
|
+
`bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
|
|
4440
|
+
`echo APPROVED`;
|
|
3845
4441
|
const seed = runGcloud(
|
|
3846
4442
|
buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
|
|
3847
4443
|
{ stdio: ["ignore", "pipe", "inherit"] },
|
|
@@ -3880,6 +4476,39 @@ async function cmdReconcileCredentials(args, projectDir = rootDir) {
|
|
|
3880
4476
|
);
|
|
3881
4477
|
}
|
|
3882
4478
|
|
|
4479
|
+
/** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
|
|
4480
|
+
* WITHOUT a full re-provision. Resolves the desired value from this invocation
|
|
4481
|
+
* (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
|
|
4482
|
+
* CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
|
|
4483
|
+
* compose edit + `docker compose up -d editor` does today, so the durable admin
|
|
4484
|
+
* setting no longer requires a full re-provision to change. */
|
|
4485
|
+
async function cmdReconcileAdmin(args, projectDir = rootDir) {
|
|
4486
|
+
ensureGcloud();
|
|
4487
|
+
const flags = parseFlags(args);
|
|
4488
|
+
const cfg = loadCloudConfig(projectDir);
|
|
4489
|
+
if (!cfg) {
|
|
4490
|
+
throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
4491
|
+
}
|
|
4492
|
+
const desired =
|
|
4493
|
+
buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
|
|
4494
|
+
const repoName = resolveProject(cfg).name;
|
|
4495
|
+
console.error(
|
|
4496
|
+
`Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
|
|
4497
|
+
);
|
|
4498
|
+
const result = runGcloud(
|
|
4499
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
4500
|
+
command: buildReconcileAdminCommand(repoName, desired),
|
|
4501
|
+
}),
|
|
4502
|
+
{ stdio: "inherit" },
|
|
4503
|
+
);
|
|
4504
|
+
if (result.status !== 0) {
|
|
4505
|
+
throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
|
|
4506
|
+
}
|
|
4507
|
+
console.error(
|
|
4508
|
+
` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
|
|
4509
|
+
);
|
|
4510
|
+
}
|
|
4511
|
+
|
|
3883
4512
|
/** Provision-time guarantee that a just-provisioned VM can actually reach
|
|
3884
4513
|
* GitHub. Probes the staging file + in-container `git ls-remote origin` and
|
|
3885
4514
|
* routes the outcome through the SAME {@link decideCredentialHeal} classifier
|
|
@@ -4011,6 +4640,7 @@ const HANDLERS = {
|
|
|
4011
4640
|
"reconcile-ingress": cmdReconcileIngress,
|
|
4012
4641
|
"wire-add-tail": cmdWireAddTail,
|
|
4013
4642
|
"reconcile-credentials": cmdReconcileCredentials,
|
|
4643
|
+
"reconcile-admin": cmdReconcileAdmin,
|
|
4014
4644
|
access: cmdAccess,
|
|
4015
4645
|
};
|
|
4016
4646
|
|
|
@@ -4048,6 +4678,7 @@ module.exports = {
|
|
|
4048
4678
|
KNOWN_FLAGS,
|
|
4049
4679
|
FIREWALL_RULE_NAME,
|
|
4050
4680
|
INSTANCE_TAG,
|
|
4681
|
+
DEFAULT_FLEET_MACHINE_TYPE,
|
|
4051
4682
|
EDITOR_PORT,
|
|
4052
4683
|
VITE_PORT,
|
|
4053
4684
|
parseFlags,
|
|
@@ -4066,6 +4697,7 @@ module.exports = {
|
|
|
4066
4697
|
previewSameOriginSafeFromStack,
|
|
4067
4698
|
isRottedManagedPreviewOrigin,
|
|
4068
4699
|
isWrongModePreviewOrigin,
|
|
4700
|
+
isSilentlyBrokenPreview,
|
|
4069
4701
|
resolveEditorConfig,
|
|
4070
4702
|
validateEditorVariant,
|
|
4071
4703
|
assertVariantProvisionable,
|
|
@@ -4074,13 +4706,19 @@ module.exports = {
|
|
|
4074
4706
|
resolveGhcrToken,
|
|
4075
4707
|
resolveGhcrPushToken,
|
|
4076
4708
|
probeUsableEditorImage,
|
|
4709
|
+
httpsManifestGet,
|
|
4077
4710
|
resolveProject,
|
|
4078
4711
|
clientProjectBootstrapArgs,
|
|
4079
4712
|
cloudConfigPath,
|
|
4080
4713
|
loadCloudConfig,
|
|
4081
4714
|
saveCloudConfig,
|
|
4082
4715
|
loadOrInitCloudConfig,
|
|
4716
|
+
buildProvisionEnv,
|
|
4717
|
+
buildReconcileAdminCommand,
|
|
4718
|
+
cmdReconcileAdmin,
|
|
4083
4719
|
buildEditorLocalEnvPatch,
|
|
4720
|
+
buildGreenfieldTitlePatch,
|
|
4721
|
+
mergeEditorLocalPatchB64,
|
|
4084
4722
|
buildEditorLocalSeedWriteCommand,
|
|
4085
4723
|
seedPreviewOrigin,
|
|
4086
4724
|
healPreviewOriginAtReady,
|
|
@@ -4108,6 +4746,8 @@ module.exports = {
|
|
|
4108
4746
|
checkScriptDrift,
|
|
4109
4747
|
streamedScriptEditorBranch,
|
|
4110
4748
|
streamedScriptMergeBase,
|
|
4749
|
+
fetchStreamedScriptRef,
|
|
4750
|
+
resolveRemoteScriptRev,
|
|
4111
4751
|
gatherScriptDriftFacts,
|
|
4112
4752
|
readStreamedScript,
|
|
4113
4753
|
STREAMED_SCRIPTS,
|
|
@@ -4134,6 +4774,8 @@ module.exports = {
|
|
|
4134
4774
|
cmdIngressUp,
|
|
4135
4775
|
cmdReconcileIngress,
|
|
4136
4776
|
sweepOrphanIngress,
|
|
4777
|
+
convergeFleetFirewall,
|
|
4778
|
+
reportFirewallPortGaps,
|
|
4137
4779
|
cmdReconcileCredentials,
|
|
4138
4780
|
cmdAccess,
|
|
4139
4781
|
cloudMain,
|
|
@@ -4141,12 +4783,14 @@ module.exports = {
|
|
|
4141
4783
|
seedLiveCredentialCache,
|
|
4142
4784
|
assertGitPushCredentials,
|
|
4143
4785
|
seedGithubCredentials,
|
|
4786
|
+
githubCredentialLine,
|
|
4144
4787
|
resolveGithubToken,
|
|
4145
4788
|
resolveGithubTokenWithSource,
|
|
4146
4789
|
MissingGitPushCredentialsError,
|
|
4147
4790
|
classifyGitRemoteError,
|
|
4148
4791
|
decideCredentialHeal,
|
|
4149
4792
|
readVmAppPort,
|
|
4793
|
+
buildVmAppPortReadCommand,
|
|
4150
4794
|
resolveLaptopGitIdentity,
|
|
4151
4795
|
};
|
|
4152
4796
|
|