@codeyam-editor/codeyam-editor 0.1.0-staging.gac4ca32 → 0.1.0-staging.gbaebe71
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 +679 -70
- package/npm/codesign-plan.js +68 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +207 -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-destroy-safety.js +48 -0
- 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 +87 -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 +174 -0
- 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-urDZ9zOC.js → ContainerInspector-qJy9NyEZ.js} +1 -1
- package/ui/dist/assets/EditorShell-BM92Hqc1.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-BLHSXp_H.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BbkUcHum.js +5 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-DlAIUrnk.js → ScenarioDataPanelFullPage-CrNwRIZM.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-mB7opGvg.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-B7_KRKaQ.js → index-Mui6rJUD.js} +4 -4
- 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/assets/useServerIdentity-COXGxKUT.js +1 -0
- 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-D7wGyetm.css +0 -1
- package/ui/dist/assets/EditorShell-eZm_8CsG.js +0 -230
- package/ui/dist/assets/ProjectLauncherScreen-Bq8CJo9M.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-DuU3_hdh.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-od1vF8-p.js +0 -21
- package/ui/dist/assets/index-D3csSagX.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,9 +76,11 @@ 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,
|
|
@@ -90,6 +93,11 @@ const {
|
|
|
90
93
|
iapAccessInvocation,
|
|
91
94
|
sharedIngressInvocation,
|
|
92
95
|
backendServiceTimeoutInvocation,
|
|
96
|
+
FLEET_FIREWALL_RULE_NAMES,
|
|
97
|
+
fleetFirewallDescribeInvocation,
|
|
98
|
+
fleetFirewallConvergeInvocation,
|
|
99
|
+
firewallNeedsConverge,
|
|
100
|
+
firewallAdmitsPort,
|
|
93
101
|
} = require("./ingress-invoke");
|
|
94
102
|
// Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
|
|
95
103
|
// provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
|
|
@@ -159,14 +167,34 @@ const KNOWN_FLAGS = [
|
|
|
159
167
|
// default is loud — a tokenless VM cannot sync plan claims and must never
|
|
160
168
|
// report healthy silently. Use only when push genuinely isn't wanted.
|
|
161
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",
|
|
162
183
|
];
|
|
163
184
|
|
|
164
185
|
// Flags that take no value (presence = true). parseFlags must not demand a
|
|
165
186
|
// value for these or `--no-tunnel` would error "requires a value".
|
|
166
|
-
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"];
|
|
167
188
|
|
|
168
189
|
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
169
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";
|
|
170
198
|
const EDITOR_PORT = 14199;
|
|
171
199
|
const VITE_PORT = 5173;
|
|
172
200
|
|
|
@@ -550,6 +578,32 @@ function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
|
|
|
550
578
|
return LOOPBACK_ORIGIN_RE.test(origin);
|
|
551
579
|
}
|
|
552
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
|
+
|
|
553
607
|
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
554
608
|
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
555
609
|
function readCloudflaredUrls() {
|
|
@@ -747,6 +801,28 @@ function buildEditorLocalEnvPatch(localEnv, agent) {
|
|
|
747
801
|
return { provider: agent, env: { [varName]: value } };
|
|
748
802
|
}
|
|
749
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
|
+
|
|
750
826
|
/** Parse `docker compose ps --format json` (compose v2 — one JSON object per
|
|
751
827
|
* line) and return the editor service's `State` string, or null when the
|
|
752
828
|
* service is absent / output is unparseable. Two callers shared an identical
|
|
@@ -789,7 +865,7 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
789
865
|
return [
|
|
790
866
|
"compute", "instances", "create", instanceName,
|
|
791
867
|
"--project", project, "--zone", zone,
|
|
792
|
-
|
|
868
|
+
`--machine-type=${cfg.machineType || DEFAULT_FLEET_MACHINE_TYPE}`,
|
|
793
869
|
"--image-family=debian-12",
|
|
794
870
|
"--image-project=debian-cloud",
|
|
795
871
|
"--boot-disk-size=100GB",
|
|
@@ -889,11 +965,25 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
889
965
|
// real use. Unit tests pin their exports so a refactor cannot drop them.
|
|
890
966
|
// =============================================================================
|
|
891
967
|
|
|
968
|
+
// Per-call wall-clock budget for a single gcloud / SSH invocation. A hung
|
|
969
|
+
// `gcloud describe` (metadata / IAP hiccup) used to block the whole
|
|
970
|
+
// `cloud:reconcile-ingress` sweep forever — the only timeout in the reconcile
|
|
971
|
+
// path was the outer 10-minute exec budget in the dashboard, and that never
|
|
972
|
+
// cascaded to the orphaned descendants. Bounding every per-call spawn well under
|
|
973
|
+
// that outer budget means a single hang trips HERE first: the call returns a
|
|
974
|
+
// timed-out result (formatGcloudFailure renders it), the sweep skips that VM,
|
|
975
|
+
// and no descendant is left hanging to pile up. Shared by runGcloud and the two
|
|
976
|
+
// reconcile spawnSync sites (status probe, per-BES timeout converge) so all three
|
|
977
|
+
// reference one value. 2 min is generous for any single describe/update/SSH probe
|
|
978
|
+
// yet comfortably under the 600000 ms outer reconcile budget.
|
|
979
|
+
const GCLOUD_CALL_TIMEOUT_MS = 120000;
|
|
980
|
+
|
|
892
981
|
function runGcloud(args, opts = {}) {
|
|
893
982
|
return spawnSync("gcloud", args, {
|
|
894
983
|
stdio: opts.stdio || "inherit",
|
|
895
984
|
input: opts.input,
|
|
896
985
|
encoding: opts.encoding || "utf8",
|
|
986
|
+
timeout: opts.timeout != null ? opts.timeout : GCLOUD_CALL_TIMEOUT_MS,
|
|
897
987
|
});
|
|
898
988
|
}
|
|
899
989
|
|
|
@@ -1496,6 +1586,43 @@ function bootstrapLogTail(cfg, lines = 40) {
|
|
|
1496
1586
|
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1497
1587
|
}
|
|
1498
1588
|
|
|
1589
|
+
/** Assemble the extra provision-time env forwarded into the bootstrap shell,
|
|
1590
|
+
* where it feeds the compose-render substitution. Currently just the admin
|
|
1591
|
+
* toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
|
|
1592
|
+
* editor-server process env (handlers.rs::admin_enabled, strict literal "1").
|
|
1593
|
+
* The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
|
|
1594
|
+
* (which lands as cfg.admin); anything else forwards "" so a normal launch —
|
|
1595
|
+
* and a re-provision without the flag — is strict non-admin. Pure over its
|
|
1596
|
+
* inputs so cloud.test.js can pin it without touching gcloud. */
|
|
1597
|
+
function buildProvisionEnv(env = process.env, flags = {}) {
|
|
1598
|
+
const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
|
|
1599
|
+
return { CODEYAM_ADMIN: admin ? "1" : "" };
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** Build the one-shot SSH shell command that reconciles a running VM's admin
|
|
1603
|
+
* mode without a full re-provision: rewrite the rendered compose's
|
|
1604
|
+
* CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
|
|
1605
|
+
* VM whose compose predates the placeholder — then recreate the editor service
|
|
1606
|
+
* so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
|
|
1607
|
+
* normalizes), so inlining it into sed is safe. Pure/string-only so
|
|
1608
|
+
* cloud.test.js can assert its shape without SSHing. */
|
|
1609
|
+
function buildReconcileAdminCommand(repoName, value) {
|
|
1610
|
+
const f = "docker-compose.cloud.yml";
|
|
1611
|
+
const entry = ` - CODEYAM_ADMIN=${value}`;
|
|
1612
|
+
// Capture only the leading indentation (\1) and re-emit the literal key, so a
|
|
1613
|
+
// value that starts with a digit (e.g. "1") never lands right after the
|
|
1614
|
+
// backreference — `\11` would be read by sed as group 11, not `\1` + "1".
|
|
1615
|
+
return (
|
|
1616
|
+
`cd ~/projects/${repoName} && ` +
|
|
1617
|
+
`if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
|
|
1618
|
+
`sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
|
|
1619
|
+
`else ` +
|
|
1620
|
+
`sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
|
|
1621
|
+
`fi && ` +
|
|
1622
|
+
`docker compose -f ${f} up -d editor`
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1499
1626
|
async function ensureProjectBootstrapped(cfg) {
|
|
1500
1627
|
const repoName = resolveProject(cfg).name;
|
|
1501
1628
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
@@ -1549,12 +1676,18 @@ async function ensureProjectBootstrapped(cfg) {
|
|
|
1549
1676
|
const ingressEnv = {};
|
|
1550
1677
|
const ingressPlan = ingressWiringPlan(cfg);
|
|
1551
1678
|
if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
|
|
1679
|
+
// Forward the admin-mode toggle (Home-tab Setup door) into the compose
|
|
1680
|
+
// render. cfg.admin carries the `--admin` flag; process.env.CODEYAM_ADMIN
|
|
1681
|
+
// carries the `CODEYAM_ADMIN=1 npm run cloud:up` form. Default "" =
|
|
1682
|
+
// non-admin, so this leaves the normal launch and re-provision unchanged.
|
|
1683
|
+
const provisionEnv = buildProvisionEnv(process.env, cfg);
|
|
1552
1684
|
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
1553
1685
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
1554
1686
|
CY_GIT_USER_NAME: laptopGit.name,
|
|
1555
1687
|
CY_GIT_USER_EMAIL: laptopGit.email,
|
|
1556
1688
|
...ingressEnv,
|
|
1557
1689
|
...forwardEnv,
|
|
1690
|
+
...provisionEnv,
|
|
1558
1691
|
});
|
|
1559
1692
|
const result = runGcloud(
|
|
1560
1693
|
buildGcloudArgs("ssh-script", cfg, { command }),
|
|
@@ -1763,49 +1896,113 @@ function resolveGhcrPushToken() {
|
|
|
1763
1896
|
return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
|
|
1764
1897
|
}
|
|
1765
1898
|
|
|
1899
|
+
// Registry manifest media types we accept when probing image presence over the
|
|
1900
|
+
// GHCR HTTP API — Docker v2 + manifest lists and their OCI equivalents, so a
|
|
1901
|
+
// multi-arch index answers 200 the same as a single-arch manifest.
|
|
1902
|
+
const MANIFEST_ACCEPT = [
|
|
1903
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
1904
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
1905
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
1906
|
+
"application/vnd.oci.image.index.v1+json",
|
|
1907
|
+
].join(", ");
|
|
1908
|
+
const GHCR_PROBE_TIMEOUT_MS = 15000;
|
|
1909
|
+
|
|
1910
|
+
/** Default HTTPS client for the registry probe: a GET that resolves to
|
|
1911
|
+
* `{ statusCode }` and NEVER rejects — any socket / DNS / timeout error resolves
|
|
1912
|
+
* to `statusCode: 0` (the probe reads that as "undetermined"). Injected as the
|
|
1913
|
+
* `httpImpl` option so probeUsableEditorImage is unit-testable with no network
|
|
1914
|
+
* (and no Docker daemon). `requestImpl` (defaulting to `https.request`) is
|
|
1915
|
+
* injectable so the response / error / timeout mapping is itself unit-testable
|
|
1916
|
+
* without a real socket. */
|
|
1917
|
+
function httpsManifestGet({ hostname, path: reqPath, headers }, requestImpl = https.request) {
|
|
1918
|
+
return new Promise((resolve) => {
|
|
1919
|
+
const req = requestImpl(
|
|
1920
|
+
{ hostname, path: reqPath, method: "GET", headers },
|
|
1921
|
+
(res) => {
|
|
1922
|
+
res.resume(); // drain the body so the socket is freed
|
|
1923
|
+
res.on("end", () => resolve({ statusCode: res.statusCode }));
|
|
1924
|
+
},
|
|
1925
|
+
);
|
|
1926
|
+
req.on("error", () => resolve({ statusCode: 0 }));
|
|
1927
|
+
req.setTimeout(GHCR_PROBE_TIMEOUT_MS, () => req.destroy());
|
|
1928
|
+
req.end();
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/** Probe ONE fully-qualified image ref (`host/repo:tag`) over the registry HTTP
|
|
1933
|
+
* API — no docker daemon, so a docker-less operator host can still determine
|
|
1934
|
+
* presence. Returns true (200 present), false (404 absent), or null (auth /
|
|
1935
|
+
* network / malformed-ref → can't tell absent from forbidden). */
|
|
1936
|
+
async function probeManifestRef(fullRef, bearer, httpGet) {
|
|
1937
|
+
const colon = fullRef.lastIndexOf(":");
|
|
1938
|
+
if (colon < 0) return null;
|
|
1939
|
+
const ref = fullRef.slice(0, colon); // host/repo
|
|
1940
|
+
const tag = fullRef.slice(colon + 1); // slug-latest
|
|
1941
|
+
const slash = ref.indexOf("/");
|
|
1942
|
+
if (slash < 0) return null;
|
|
1943
|
+
const hostname = ref.slice(0, slash); // ghcr.io
|
|
1944
|
+
const repo = ref.slice(slash + 1); // codeyam-ai/codeyam-editor-cloud-smoke
|
|
1945
|
+
let res;
|
|
1946
|
+
try {
|
|
1947
|
+
res = await httpGet({
|
|
1948
|
+
hostname,
|
|
1949
|
+
path: `/v2/${repo}/manifests/${encodeURIComponent(tag)}`,
|
|
1950
|
+
headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
|
|
1951
|
+
});
|
|
1952
|
+
} catch (_e) {
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
const code = res && res.statusCode;
|
|
1956
|
+
if (code === 200) return true;
|
|
1957
|
+
if (code === 404) return false;
|
|
1958
|
+
return null; // 401 / 403 / 5xx / 0 (network) → undetermined
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1766
1961
|
/** Read-only probe: does a usable prebuilt editor image already exist on the
|
|
1767
1962
|
* registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
|
|
1768
1963
|
* add to decide whether to pace one VM ahead of the rest (see
|
|
1769
|
-
* npm/fleet-prewarm.js::decideBatchPrewarm).
|
|
1770
|
-
*
|
|
1964
|
+
* npm/fleet-prewarm.js::decideBatchPrewarm).
|
|
1965
|
+
*
|
|
1966
|
+
* Queries the registry over its HTTP API (`GET /v2/<repo>/manifests/<tag>`, a
|
|
1967
|
+
* HEAD-equivalent that reads the manifest without pulling the multi-GB image)
|
|
1968
|
+
* instead of `docker login` / `docker manifest inspect`. This is the root fix
|
|
1969
|
+
* for the docker-less GCE operator host: the old docker path errored on the
|
|
1970
|
+
* first `docker login` spawn (the control plane has no docker daemon) and so
|
|
1971
|
+
* returned `null` forever, which decideBatchPrewarm read as "fan out" — the
|
|
1972
|
+
* image was therefore never published and every cold add source-built. Over
|
|
1973
|
+
* HTTP the probe returns a DEFINITIVE verdict with no daemon.
|
|
1771
1974
|
*
|
|
1772
1975
|
* Returns:
|
|
1773
1976
|
* true — at least one of the usable tags exists (a cold add can pull; no pacer)
|
|
1774
1977
|
* false — every tag was checked and none exists (known-absent; pacing pays)
|
|
1775
|
-
* null — undetermined: no read token,
|
|
1776
|
-
*
|
|
1777
|
-
* "fan out" (today's behavior, never worse) rather than risk a
|
|
1778
|
-
* 10-min serialization that might buy nothing.
|
|
1978
|
+
* null — undetermined: no read token, or an auth/network error that can't be
|
|
1979
|
+
* distinguished from "absent".
|
|
1779
1980
|
*
|
|
1780
|
-
*
|
|
1781
|
-
*
|
|
1782
|
-
*
|
|
1783
|
-
|
|
1981
|
+
* GHCR's private packages require auth; without a read token we can't tell
|
|
1982
|
+
* absent from forbidden, so → null. The read token is sent as the registry
|
|
1983
|
+
* bearer (GHCR accepts a base64-encoded PAT directly — no separate token-
|
|
1984
|
+
* exchange round-trip). `httpImpl` is injectable for unit tests; a legacy
|
|
1985
|
+
* `spawnImpl` option is still accepted (and ignored) for backward-compat — the
|
|
1986
|
+
* HTTP happy path needs no spawn. */
|
|
1987
|
+
async function probeUsableEditorImage({ branch, registry, token, httpImpl } = {}) {
|
|
1784
1988
|
const fp = require("./fleet-prewarm");
|
|
1785
|
-
const spawn = spawnImpl || spawnSync;
|
|
1786
1989
|
const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
|
|
1787
1990
|
const readToken = token || resolveGhcrToken();
|
|
1788
1991
|
if (!readToken) return null; // can't auth → can't distinguish absent from forbidden
|
|
1789
|
-
const
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
{ input: readToken, encoding: "utf8" },
|
|
1794
|
-
);
|
|
1795
|
-
// spawn error (docker missing) or a non-zero login → undetermined.
|
|
1796
|
-
if (!login || login.error || login.status !== 0) return null;
|
|
1992
|
+
const httpGet = httpImpl || httpsManifestGet;
|
|
1993
|
+
// GHCR accepts a base64-encoded PAT as the registry bearer — no token-exchange
|
|
1994
|
+
// round-trip needed.
|
|
1995
|
+
const bearer = Buffer.from(readToken).toString("base64");
|
|
1797
1996
|
const tags = fp.usableImageTags({ registry: reg, branch });
|
|
1798
1997
|
let allClean = true;
|
|
1799
|
-
for (const
|
|
1800
|
-
const
|
|
1801
|
-
if (
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
// Every tag inspected cleanly and none existed → definitively absent. If any
|
|
1808
|
-
// probe failed to run, we can't be sure → undetermined.
|
|
1998
|
+
for (const fullRef of tags) {
|
|
1999
|
+
const verdict = await probeManifestRef(fullRef, bearer, httpGet);
|
|
2000
|
+
if (verdict === true) return true; // a usable image exists
|
|
2001
|
+
if (verdict === null) allClean = false; // this tag's probe was inconclusive
|
|
2002
|
+
// verdict === false → this tag is absent; keep checking the rest
|
|
2003
|
+
}
|
|
2004
|
+
// Every tag probed cleanly and none existed → definitively absent. If any probe
|
|
2005
|
+
// was inconclusive we can't claim absence → undetermined.
|
|
1809
2006
|
return allClean ? false : null;
|
|
1810
2007
|
}
|
|
1811
2008
|
|
|
@@ -1842,6 +2039,15 @@ function classifyGitRemoteError(stderr) {
|
|
|
1842
2039
|
) {
|
|
1843
2040
|
return "network";
|
|
1844
2041
|
}
|
|
2042
|
+
// A greenfield project (`--new-project` scaffold) has no origin remote and an
|
|
2043
|
+
// unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
|
|
2044
|
+
// git repository". No token would fix this — there is simply nothing to push
|
|
2045
|
+
// yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
|
|
2046
|
+
// match narrow: `repository not found` is a genuine access failure against a
|
|
2047
|
+
// real remote and must NOT be swallowed here.
|
|
2048
|
+
if (lower.includes("does not appear to be a git repository")) {
|
|
2049
|
+
return "no-remote";
|
|
2050
|
+
}
|
|
1845
2051
|
return "other";
|
|
1846
2052
|
}
|
|
1847
2053
|
|
|
@@ -1879,6 +2085,14 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1879
2085
|
reason: "git binary failed to launch — not a credential gap, not re-seeding",
|
|
1880
2086
|
};
|
|
1881
2087
|
}
|
|
2088
|
+
if (category === "no-remote") {
|
|
2089
|
+
return {
|
|
2090
|
+
heal: false,
|
|
2091
|
+
category,
|
|
2092
|
+
reason:
|
|
2093
|
+
"no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
|
|
2094
|
+
};
|
|
2095
|
+
}
|
|
1882
2096
|
return {
|
|
1883
2097
|
heal: true,
|
|
1884
2098
|
category,
|
|
@@ -1896,6 +2110,16 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1896
2110
|
};
|
|
1897
2111
|
}
|
|
1898
2112
|
|
|
2113
|
+
/** The single GitHub credential line format shared by every seed path: the
|
|
2114
|
+
* host staging file, the durable on-disk `store` fallback, and the live
|
|
2115
|
+
* cache-daemon approve all expect `https://x-access-token:<token>@github.com`
|
|
2116
|
+
* (git's credential-URL form, matched by both the entrypoint parser and
|
|
2117
|
+
* git-credential-store). Centralized so the `x-access-token` shape has one
|
|
2118
|
+
* source of truth instead of being re-typed at each call site. */
|
|
2119
|
+
function githubCredentialLine(token) {
|
|
2120
|
+
return `https://x-access-token:${token}@github.com`;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1899
2123
|
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1900
2124
|
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1901
2125
|
* without this, claude / codex / gemini inside the container can clone the
|
|
@@ -1921,21 +2145,28 @@ async function seedGithubCredentials(cfg, opts = {}) {
|
|
|
1921
2145
|
console.error(
|
|
1922
2146
|
`Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
|
|
1923
2147
|
);
|
|
1924
|
-
const credLine =
|
|
2148
|
+
const credLine = githubCredentialLine(token);
|
|
1925
2149
|
// Quote the token through one shell layer (gcloud ssh --command); the
|
|
1926
2150
|
// value is base64'd to keep it out of the shell history.
|
|
1927
2151
|
const b64 = Buffer.from(`${credLine}\n`).toString("base64");
|
|
1928
|
-
// Verify-before-
|
|
1929
|
-
// (`[ -s ]`), and only THEN
|
|
2152
|
+
// Verify-before-write: decode into a chmod-600 temp, assert it is non-empty
|
|
2153
|
+
// (`[ -s ]`), and only THEN copy it over the live staging file. An empty or
|
|
1930
2154
|
// unreadable decode must never clobber an existing good file — the entrypoint
|
|
1931
2155
|
// reads this file on every boot, and a silent empty write is exactly how a
|
|
1932
2156
|
// reprovision lands a VM that "could not read Username for github.com". The
|
|
1933
|
-
// trailing `echo OK` runs only on
|
|
2157
|
+
// trailing `echo OK` runs only on success, so an empty write can't mask
|
|
1934
2158
|
// success with a `&& echo` (the failure shape seen on the Claude creds sinks).
|
|
2159
|
+
//
|
|
2160
|
+
// Use `cat "$t" > "$f"` (in-place truncate), NOT `mv`: the staging file is
|
|
2161
|
+
// bind-mounted read-only into the running container, and `mv` mints a NEW
|
|
2162
|
+
// inode that the live `:ro` mount does not follow — so a `mv`-based re-seed
|
|
2163
|
+
// never reached the running container until a full recreate. Truncating the
|
|
2164
|
+
// existing file in place keeps its inode, so the durable write is visible to
|
|
2165
|
+
// the container immediately. Then remove the temp.
|
|
1935
2166
|
const remoteCmd =
|
|
1936
2167
|
`f=$HOME/.codeyam-cloud-git-credentials && t="$f.tmp.$$" && ` +
|
|
1937
2168
|
`echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
|
|
1938
|
-
`if [ -s "$t" ]; then
|
|
2169
|
+
`if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
|
|
1939
2170
|
`else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
|
|
1940
2171
|
const result = runGcloud(
|
|
1941
2172
|
buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
|
|
@@ -1946,26 +2177,76 @@ async function seedGithubCredentials(cfg, opts = {}) {
|
|
|
1946
2177
|
}
|
|
1947
2178
|
}
|
|
1948
2179
|
|
|
2180
|
+
/** Sentinel the remote read prints when it cannot find an `editor.json` under
|
|
2181
|
+
* any `~/projects/*` directory. Distinguishes "this VM has no scaffolded
|
|
2182
|
+
* project" (a real miss worth warning about) from "the project is scaffolded
|
|
2183
|
+
* but declares no port" (legitimately a `VITE_PORT` default). Without the
|
|
2184
|
+
* distinction both collapse to `{}` and the miss is silent. */
|
|
2185
|
+
const VM_APP_PORT_NO_CONFIG = "<<<NOCONFIG>>>";
|
|
2186
|
+
|
|
2187
|
+
/** Build the remote shell command that locates the project's `.codeyam` dir and
|
|
2188
|
+
* cats its two config files in one round-trip.
|
|
2189
|
+
*
|
|
2190
|
+
* Resolution is glob-based rather than `~/projects/<repoName>` alone: the host
|
|
2191
|
+
* directory is not guaranteed to be named after the repo (a renamed repo, a
|
|
2192
|
+
* manually-cloned dir, a fleet slug), and when the guess missed, the old code
|
|
2193
|
+
* silently returned `VITE_PORT` and forwarded the wrong port. The repo-named
|
|
2194
|
+
* path is still tried FIRST so a multi-project VM resolves deterministically;
|
|
2195
|
+
* the glob is the fallback. When nothing matches, print the sentinel so the
|
|
2196
|
+
* caller can warn instead of guessing. Pure string builder; no I/O. */
|
|
2197
|
+
function buildVmAppPortReadCommand(repoName) {
|
|
2198
|
+
return (
|
|
2199
|
+
`d=""; ` +
|
|
2200
|
+
`for c in ~/projects/${repoName}/.codeyam ~/projects/*/.codeyam; do ` +
|
|
2201
|
+
`if [ -f "$c/editor.json" ]; then d="$c"; break; fi; done; ` +
|
|
2202
|
+
`if [ -z "$d" ]; then echo '${VM_APP_PORT_NO_CONFIG}'; else ` +
|
|
2203
|
+
`cat "$d/editor.json" 2>/dev/null || echo '{}'; ` +
|
|
2204
|
+
`echo '<<<LOCAL>>>'; ` +
|
|
2205
|
+
`cat "$d/editor.local.json" 2>/dev/null || echo '{}'; fi`
|
|
2206
|
+
);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1949
2209
|
/** Best-effort read of the VM's resolved app dev-server port so cloud:up's
|
|
1950
2210
|
* laptop `-L` forward, readiness probe, and previewOrigin seed all target the
|
|
1951
2211
|
* port the app actually serves on instead of a hardcoded 5173. Cats the VM's
|
|
1952
2212
|
* editor.json and editor.local.json in one SSH round-trip and resolves via
|
|
1953
|
-
* `resolveConfiguredAppPort`.
|
|
1954
|
-
*
|
|
1955
|
-
*
|
|
2213
|
+
* `resolveConfiguredAppPort`.
|
|
2214
|
+
*
|
|
2215
|
+
* Falls back to `VITE_PORT` on any failure — but fails LOUD, not silent. A
|
|
2216
|
+
* wrong port here is not a cosmetic default: it forwards the wrong `-L` port,
|
|
2217
|
+
* and if the preview ends up on a dedicated origin the viewer gets
|
|
2218
|
+
* "localhost refused to connect" with nothing pointing at the cause. So an SSH
|
|
2219
|
+
* failure or a VM with no scaffolded project warns on stderr naming that
|
|
2220
|
+
* symptom. A scaffolded project that simply declares no port is NOT a miss —
|
|
2221
|
+
* `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
|
|
2222
|
+
* on) and stays quiet. */
|
|
1956
2223
|
async function readVmAppPort(cfg) {
|
|
1957
2224
|
const repoName = resolveProject(cfg).name;
|
|
1958
|
-
const base = `~/projects/${repoName}/.codeyam`;
|
|
1959
2225
|
const read = runGcloud(
|
|
1960
2226
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
1961
|
-
command:
|
|
1962
|
-
`cat ${base}/editor.json 2>/dev/null || echo '{}'; ` +
|
|
1963
|
-
`echo '<<<LOCAL>>>'; ` +
|
|
1964
|
-
`cat ${base}/editor.local.json 2>/dev/null || echo '{}'`,
|
|
2227
|
+
command: buildVmAppPortReadCommand(repoName),
|
|
1965
2228
|
}),
|
|
1966
2229
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1967
2230
|
);
|
|
1968
|
-
if (read.status !== 0 || typeof read.stdout !== "string")
|
|
2231
|
+
if (read.status !== 0 || typeof read.stdout !== "string") {
|
|
2232
|
+
console.error(
|
|
2233
|
+
`[cloud] WARNING: could not read the VM's app port over SSH — ` +
|
|
2234
|
+
`falling back to ${VITE_PORT}. If the app does not serve on ` +
|
|
2235
|
+
`${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
|
|
2236
|
+
`Live Preview will show "localhost refused to connect".`,
|
|
2237
|
+
);
|
|
2238
|
+
return VITE_PORT;
|
|
2239
|
+
}
|
|
2240
|
+
if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
|
|
2241
|
+
console.error(
|
|
2242
|
+
`[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
|
|
2243
|
+
`VM (looked for '${repoName}', then any project dir) — falling back to ` +
|
|
2244
|
+
`${VITE_PORT}. If the project is scaffolded under a different name and ` +
|
|
2245
|
+
`serves on another port, the port-forward targets the wrong port and ` +
|
|
2246
|
+
`the Live Preview will show "localhost refused to connect".`,
|
|
2247
|
+
);
|
|
2248
|
+
return VITE_PORT;
|
|
2249
|
+
}
|
|
1969
2250
|
const parse = (s) => {
|
|
1970
2251
|
try {
|
|
1971
2252
|
const v = JSON.parse(s);
|
|
@@ -2289,6 +2570,66 @@ async function seedAgentApiKey(cfg) {
|
|
|
2289
2570
|
// vm--no-cli-install-before-container-started removed; do not re-add it.
|
|
2290
2571
|
}
|
|
2291
2572
|
|
|
2573
|
+
/** Pure: deep-merge `patch` over the VM's current editor.local.json content and
|
|
2574
|
+
* return the base64 of the pretty-printed result, ready for
|
|
2575
|
+
* buildEditorLocalSeedWriteCommand. `currentRaw` is the raw `cat` output of the
|
|
2576
|
+
* remote file (may be undefined, empty, or corrupt) — anything that doesn't
|
|
2577
|
+
* parse to a plain object is treated as `{}`, because overwriting a corrupt
|
|
2578
|
+
* override file with a known-good one is safer than refusing to seed. Extracted
|
|
2579
|
+
* so seedGreenfieldProjectTitle stays a thin I/O passthrough and the
|
|
2580
|
+
* parse+merge+encode is unit-tested without a live VM. */
|
|
2581
|
+
function mergeEditorLocalPatchB64(currentRaw, patch) {
|
|
2582
|
+
let current = {};
|
|
2583
|
+
try {
|
|
2584
|
+
const parsed = JSON.parse(currentRaw);
|
|
2585
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2586
|
+
current = parsed;
|
|
2587
|
+
}
|
|
2588
|
+
} catch {
|
|
2589
|
+
// Unparseable / missing remote content → start from {}.
|
|
2590
|
+
}
|
|
2591
|
+
const merged = deepMergeJson(current, patch);
|
|
2592
|
+
return Buffer.from(JSON.stringify(merged, null, 2), "utf8").toString("base64");
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
/** Seed a GREENFIELD project's `projectTitle` into the VM's editor.local.json
|
|
2596
|
+
* BEFORE compose up, so the editor caches the real project name at startup and
|
|
2597
|
+
* the fleet dashboard renders the correct card label with no false
|
|
2598
|
+
* project-dir-drift. Like seedAgentApiKey, this MUST run before
|
|
2599
|
+
* ensureEditorContainerUp — the editor reads its config once at startup.
|
|
2600
|
+
*
|
|
2601
|
+
* No-op for a clone (buildGreenfieldTitlePatch returns null): a repo Add labels
|
|
2602
|
+
* from its origin basename, so there is nothing to seed and no SSH round-trip
|
|
2603
|
+
* is spent. Runs on every cloud:up, so a reconfigure re-seeds the title,
|
|
2604
|
+
* converging a VM provisioned before this fix onto the correct identity without
|
|
2605
|
+
* a destroy. Thin I/O passthrough: the parse+merge+encode lives in the pure
|
|
2606
|
+
* mergeEditorLocalPatchB64 helper. */
|
|
2607
|
+
async function seedGreenfieldProjectTitle(cfg) {
|
|
2608
|
+
const patch = buildGreenfieldTitlePatch(cfg);
|
|
2609
|
+
if (!patch) return; // clone (or no greenfield name) — nothing to seed
|
|
2610
|
+
|
|
2611
|
+
const repoName = resolveProject(cfg).name;
|
|
2612
|
+
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
2613
|
+
console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
|
|
2614
|
+
|
|
2615
|
+
const read = runGcloud(
|
|
2616
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2617
|
+
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
2618
|
+
}),
|
|
2619
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2620
|
+
);
|
|
2621
|
+
const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
|
|
2622
|
+
const write = runGcloud(
|
|
2623
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2624
|
+
command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
|
|
2625
|
+
}),
|
|
2626
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
2627
|
+
);
|
|
2628
|
+
if (write.status !== 0) {
|
|
2629
|
+
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2292
2633
|
/** Tri-state probe of the editor container's running state on the VM host.
|
|
2293
2634
|
* One ssh round-trip; returns `"running"`, `"not-running"`, or `"unknown"`.
|
|
2294
2635
|
*
|
|
@@ -2551,6 +2892,42 @@ function streamedScriptMergeBase(editorBranch) {
|
|
|
2551
2892
|
return mbResult.status === 0 ? mbResult.stdout.trim() : null;
|
|
2552
2893
|
}
|
|
2553
2894
|
|
|
2895
|
+
/** Fetch `editorBranch` from origin into a REAL remote-tracking ref
|
|
2896
|
+
* (`refs/remotes/origin/<branch>`) rather than only into `FETCH_HEAD`.
|
|
2897
|
+
*
|
|
2898
|
+
* Operator hosts are commonly shallow, single-branch clones that track only
|
|
2899
|
+
* `+refs/heads/main`, so `origin/<some-feature-branch>` is not a local ref and
|
|
2900
|
+
* every `git show origin/<branch>:<path>` against it fails. Fetching through an
|
|
2901
|
+
* explicit refspec creates the ref, which keeps the drift verdict and the bytes
|
|
2902
|
+
* actually streamed derived from the same object. Falls back to a plain fetch
|
|
2903
|
+
* (FETCH_HEAD only) when the refspec form is rejected — `readRemoteScriptBlob`
|
|
2904
|
+
* can still read through FETCH_HEAD in that case. */
|
|
2905
|
+
function fetchStreamedScriptRef(editorBranch) {
|
|
2906
|
+
const refspec = `${editorBranch}:refs/remotes/origin/${editorBranch}`;
|
|
2907
|
+
const withRef = spawnSync("git", ["fetch", "--force", "origin", refspec], { encoding: "utf8" });
|
|
2908
|
+
if (withRef.status === 0) return { ok: true, stderr: "" };
|
|
2909
|
+
|
|
2910
|
+
const plain = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
|
|
2911
|
+
if (plain.status === 0) return { ok: true, stderr: "" };
|
|
2912
|
+
return { ok: false, stderr: (plain.stderr || withRef.stderr || "").trim() };
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
/** Resolve one revision of a streamed script from the fetched remote, trying the
|
|
2916
|
+
* remote-tracking ref first and `FETCH_HEAD` second. `gitArg` is the git verb
|
|
2917
|
+
* (`rev-parse` for the blob hash, `show` for the bytes) so the drift facts and the
|
|
2918
|
+
* streamed bytes resolve through the SAME ref-preference order — a verdict of
|
|
2919
|
+
* "stream remote" can therefore never disagree with what actually got streamed.
|
|
2920
|
+
* Returns `{ ok: false }` only when the blob is unreadable through both refs. */
|
|
2921
|
+
function resolveRemoteScriptRev(gitArg, relPath, editorBranch) {
|
|
2922
|
+
for (const ref of [`origin/${editorBranch}`, "FETCH_HEAD"]) {
|
|
2923
|
+
const result = spawnSync("git", [gitArg, `${ref}:${relPath}`], { encoding: "utf8" });
|
|
2924
|
+
if (result.status === 0 && typeof result.stdout === "string") {
|
|
2925
|
+
return { ok: true, stdout: result.stdout, ref };
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
return { ok: false, stdout: "", ref: null };
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2554
2931
|
/** Gather the drift facts for one streamed script relative to `origin/<editorBranch>`:
|
|
2555
2932
|
* its local (working-tree) blob hash, the remote-tip blob hash, the merge-base blob
|
|
2556
2933
|
* hash, and whether the working tree is dirty. Any git failure yields a null hash,
|
|
@@ -2562,11 +2939,8 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
|
|
|
2562
2939
|
const localHashResult = spawnSync("git", ["hash-object", relPath], { encoding: "utf8" });
|
|
2563
2940
|
const localHash = localHashResult.status === 0 ? localHashResult.stdout.trim() : null;
|
|
2564
2941
|
|
|
2565
|
-
|
|
2566
|
-
const
|
|
2567
|
-
if (remoteHashResult.status === 0) {
|
|
2568
|
-
remoteHash = remoteHashResult.stdout.trim();
|
|
2569
|
-
}
|
|
2942
|
+
const remoteRev = resolveRemoteScriptRev("rev-parse", relPath, editorBranch);
|
|
2943
|
+
const remoteHash = remoteRev.ok ? remoteRev.stdout.trim() : null;
|
|
2570
2944
|
|
|
2571
2945
|
let baseHash = null;
|
|
2572
2946
|
if (mergeBase) {
|
|
@@ -2589,7 +2963,15 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
|
|
|
2589
2963
|
* un-pulled dashboard can't add VMs" failure. Streams the on-disk copy instead
|
|
2590
2964
|
* when the operator authored local content the remote tip lacks (dirty edit or a
|
|
2591
2965
|
* committed local-ahead change — developer mode) or when the remote is unreachable
|
|
2592
|
-
* (offline fallback
|
|
2966
|
+
* (offline fallback — `remoteHash === null`, the only sanctioned on-disk path).
|
|
2967
|
+
*
|
|
2968
|
+
* A "remote" verdict NEVER falls back to the on-disk copy. On a clean-but-behind
|
|
2969
|
+
* checkout the on-disk bytes are precisely the stale bytes the drift guard exists
|
|
2970
|
+
* to keep off the VM, so substituting them silently defeats the guard: the verdict
|
|
2971
|
+
* said "stream the reviewed remote tip" and the VM would instead receive the old
|
|
2972
|
+
* script, with only a `[Warning]` to show for it. When the remote blob cannot be
|
|
2973
|
+
* read through either ref this throws, aborting the add.
|
|
2974
|
+
* `absScriptPath` is an absolute path under the repo root. */
|
|
2593
2975
|
function readStreamedScript(absScriptPath, cfg = {}) {
|
|
2594
2976
|
const relPath = path.relative(rootDir, absScriptPath) || absScriptPath;
|
|
2595
2977
|
const editorBranch = streamedScriptEditorBranch(cfg);
|
|
@@ -2598,15 +2980,18 @@ function readStreamedScript(absScriptPath, cfg = {}) {
|
|
|
2598
2980
|
|
|
2599
2981
|
const { streamedScriptSource } = require("./script-drift");
|
|
2600
2982
|
if (streamedScriptSource(facts) === "remote") {
|
|
2601
|
-
const remote =
|
|
2602
|
-
if (remote.
|
|
2603
|
-
console.error(` streaming canonical ${relPath} from
|
|
2983
|
+
const remote = resolveRemoteScriptRev("show", relPath, editorBranch);
|
|
2984
|
+
if (remote.ok) {
|
|
2985
|
+
console.error(` streaming canonical ${relPath} from ${remote.ref} (checkout behind remote is fine)`);
|
|
2604
2986
|
return remote.stdout;
|
|
2605
2987
|
}
|
|
2606
|
-
//
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2988
|
+
// The facts said "the remote tip is the canonical source" but its bytes are
|
|
2989
|
+
// unreadable. Streaming the on-disk copy here would provision unreviewed stale
|
|
2990
|
+
// bytes under the banner of a successful add — fail loud instead.
|
|
2991
|
+
throw new Error(
|
|
2992
|
+
`could not read ${relPath} from origin/${editorBranch} (or FETCH_HEAD); ` +
|
|
2993
|
+
`refusing to stream a possibly-stale on-disk copy. ` +
|
|
2994
|
+
`Fetch the branch first: git fetch origin ${editorBranch}:refs/remotes/origin/${editorBranch}`,
|
|
2610
2995
|
);
|
|
2611
2996
|
}
|
|
2612
2997
|
return fs.readFileSync(absScriptPath, "utf8");
|
|
@@ -2628,10 +3013,11 @@ function checkScriptDrift(cfg) {
|
|
|
2628
3013
|
|
|
2629
3014
|
console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
|
|
2630
3015
|
|
|
2631
|
-
// 1.
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
3016
|
+
// 1. Fetch into refs/remotes/origin/<editorBranch> so the ref resolves even on a
|
|
3017
|
+
// single-branch clone that tracks only +refs/heads/main.
|
|
3018
|
+
const fetchResult = fetchStreamedScriptRef(editorBranch);
|
|
3019
|
+
if (!fetchResult.ok) {
|
|
3020
|
+
console.error(` [Warning] Failed to fetch origin ${editorBranch} (${fetchResult.stderr || "unknown error"}). Skipping drift check.`);
|
|
2635
3021
|
return;
|
|
2636
3022
|
}
|
|
2637
3023
|
|
|
@@ -2744,6 +3130,29 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
|
|
|
2744
3130
|
});
|
|
2745
3131
|
}
|
|
2746
3132
|
|
|
3133
|
+
/** Grant-only pass: (re-)apply one VM's default IAP access grant by spawning
|
|
3134
|
+
* `scripts/gcp/vm-ingress.sh <N> grant-access` — the churn-free action that runs
|
|
3135
|
+
* `apply_default_iap_access` without touching any NEG/BES/host-rule resource.
|
|
3136
|
+
* Reconcile uses this for already-`wired` VMs its re-wire loop skips, so a healed
|
|
3137
|
+
* VM whose backends already exist still (re-)grants the automation SA the IAP
|
|
3138
|
+
* accessor. Returns the spawnSync result so the caller logs granted vs failed;
|
|
3139
|
+
* best-effort + loud, never fatal. */
|
|
3140
|
+
function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
|
|
3141
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3142
|
+
const inv = vmIngressGrantAccessInvocation({
|
|
3143
|
+
scriptPath,
|
|
3144
|
+
vmNumber,
|
|
3145
|
+
domain,
|
|
3146
|
+
project: cfg.project,
|
|
3147
|
+
zone: cfg.zone,
|
|
3148
|
+
slug,
|
|
3149
|
+
});
|
|
3150
|
+
return spawnSync(inv.command, inv.args, {
|
|
3151
|
+
stdio: "inherit",
|
|
3152
|
+
env: { ...process.env, ...inv.env },
|
|
3153
|
+
});
|
|
3154
|
+
}
|
|
3155
|
+
|
|
2747
3156
|
/** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
|
|
2748
3157
|
* Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
|
|
2749
3158
|
* (the VM is reachable operator-side over SSH regardless), so on failure it
|
|
@@ -3287,6 +3696,10 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
3287
3696
|
}
|
|
3288
3697
|
}
|
|
3289
3698
|
await seedAgentApiKey(cfg);
|
|
3699
|
+
// Greenfield only (no-op for a clone): seed projectTitle so the editor
|
|
3700
|
+
// resolves its identity to the real project name instead of the bare
|
|
3701
|
+
// /workspace mount basename — the source of the false project-dir-drift.
|
|
3702
|
+
await seedGreenfieldProjectTitle(cfg);
|
|
3290
3703
|
await ensureEditorContainerUp(cfg);
|
|
3291
3704
|
// Provision contract: a just-provisioned VM must actually reach GitHub.
|
|
3292
3705
|
// Verify in-container git auth now that the container (and its credential
|
|
@@ -3641,17 +4054,40 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3641
4054
|
zone: cfg.zone,
|
|
3642
4055
|
slug,
|
|
3643
4056
|
});
|
|
4057
|
+
// Bound the per-VM status probe: a hung `bash vm-ingress.sh <N> status`
|
|
4058
|
+
// (its gcloud describes stalling on an IAP/metadata hiccup) must not stall the
|
|
4059
|
+
// whole sweep. On timeout res.stdout is empty, so parseIngressStatusVerdict
|
|
4060
|
+
// returns null → an `unknown` verdict → selectIngressReconcileTargets SKIPS
|
|
4061
|
+
// this VM and the sweep continues to the next one.
|
|
3644
4062
|
const res = spawnSync(inv.command, inv.args, {
|
|
3645
4063
|
env: { ...process.env, ...inv.env },
|
|
3646
4064
|
encoding: "utf8",
|
|
4065
|
+
timeout: GCLOUD_CALL_TIMEOUT_MS,
|
|
3647
4066
|
});
|
|
3648
4067
|
return { vmNumber: n, slug, verdict: parseIngressStatusVerdict(res.stdout || "") };
|
|
3649
4068
|
});
|
|
3650
4069
|
const targets = selectIngressReconcileTargets(statuses);
|
|
3651
4070
|
const targetNums = new Set(targets.map((t) => t.vmNumber));
|
|
4071
|
+
// The complement of the re-wire loop: `wired` VMs whose backends already exist
|
|
4072
|
+
// so the re-wire loop skips them, yet still need the SA-accessor (re-)granted —
|
|
4073
|
+
// `up` is the only path that grants it, and it never runs for a wired VM. This
|
|
4074
|
+
// closes the VM-8 heal-path gap (a wired VM whose backends predate the SA-grant
|
|
4075
|
+
// fix would otherwise stay 403 forever). Disjoint from `targets` by construction.
|
|
4076
|
+
const grantOnlyNums = new Set(
|
|
4077
|
+
selectIngressGrantOnlyTargets(statuses).map((g) => g.vmNumber),
|
|
4078
|
+
);
|
|
3652
4079
|
for (const s of statuses) {
|
|
3653
|
-
if (
|
|
3654
|
-
|
|
4080
|
+
if (targetNums.has(s.vmNumber)) continue;
|
|
4081
|
+
const label = s.slug || `vm-${s.vmNumber}`;
|
|
4082
|
+
if (grantOnlyNums.has(s.vmNumber)) {
|
|
4083
|
+
// Churn-free grant-only pass: (re-)grant the accessor without re-wiring any
|
|
4084
|
+
// NEG/BES/host-rule resource. Idempotent (a no-op when the SA is already
|
|
4085
|
+
// bound) and best-effort-but-loud — a grant hiccup logs, never aborts.
|
|
4086
|
+
const gres = runVmIngressGrantAccess(cfg, s.vmNumber, domain, s.slug);
|
|
4087
|
+
console.error(
|
|
4088
|
+
` VM-${s.vmNumber} ${label}: wired → ok (accessor ${gres.status === 0 ? "granted" : "grant FAILED"})`,
|
|
4089
|
+
);
|
|
4090
|
+
} else {
|
|
3655
4091
|
console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
|
|
3656
4092
|
}
|
|
3657
4093
|
}
|
|
@@ -3681,7 +4117,13 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3681
4117
|
for (const surface of gi.SURFACES) {
|
|
3682
4118
|
const bes = gi.backendServiceName(n, surface);
|
|
3683
4119
|
const inv = backendServiceTimeoutInvocation({ bes, project: cfg.project });
|
|
3684
|
-
|
|
4120
|
+
// Bound the per-BES converge too: a hung `gcloud backend-services update`
|
|
4121
|
+
// returns a non-zero (timed-out) result, so it falls into the skip branch
|
|
4122
|
+
// below and the sweep moves on to the next BES rather than stalling.
|
|
4123
|
+
const res = spawnSync(inv.command, inv.args, {
|
|
4124
|
+
encoding: "utf8",
|
|
4125
|
+
timeout: GCLOUD_CALL_TIMEOUT_MS,
|
|
4126
|
+
});
|
|
3685
4127
|
if (res.status === 0) {
|
|
3686
4128
|
console.error(` ${bes}: timeout → 86400`);
|
|
3687
4129
|
} else {
|
|
@@ -3691,6 +4133,12 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3691
4133
|
}
|
|
3692
4134
|
}
|
|
3693
4135
|
|
|
4136
|
+
// Converge the shared fleet firewall, then surface any app port it still fails to
|
|
4137
|
+
// admit. Both ride this same boot + periodic cadence so the running fleet
|
|
4138
|
+
// self-heals without a hand-run bootstrap.
|
|
4139
|
+
const firewallState = convergeFleetFirewall({ cfg });
|
|
4140
|
+
await reportFirewallPortGaps({ cfg, roster, firewallState });
|
|
4141
|
+
|
|
3694
4142
|
// Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): any
|
|
3695
4143
|
// backend service whose VM number is gone from the roster is a destroyed VM's
|
|
3696
4144
|
// leftover — cleaned here automatically with the verified `down`, so the
|
|
@@ -3720,6 +4168,106 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3720
4168
|
* summary (null when the backend-service enumeration itself failed) — the same
|
|
3721
4169
|
* shape it prints — so the flow is directly assertable. `deps` is injectable so
|
|
3722
4170
|
* the enumerate → select → down flow is testable without gcloud. */
|
|
4171
|
+
/** Converge the shared fleet firewall rule to all-tcp so the LB reaches whatever
|
|
4172
|
+
* port a project's app actually serves.
|
|
4173
|
+
*
|
|
4174
|
+
* A rule pinned to a hardcoded port list (`tcp:14199,tcp:5173,tcp:3000`) drops
|
|
4175
|
+
* LB→VM traffic for any VM serving outside it — the VM-6-on-8080 Live Preview
|
|
4176
|
+
* outage — while the preview backend service still reads HEALTHY, because its
|
|
4177
|
+
* health check runs on the always-admitted editor port. The failure is therefore
|
|
4178
|
+
* invisible until someone opens the preview. Converging here (not only in the
|
|
4179
|
+
* hand-run google-ingress.sh bootstrap) is what makes a drifted rule self-heal on
|
|
4180
|
+
* the running fleet.
|
|
4181
|
+
*
|
|
4182
|
+
* All-tcp is the right exposure, not a per-stack port list: the rule stays scoped
|
|
4183
|
+
* to Google's LB + health-check source ranges and the fleet instance tag either
|
|
4184
|
+
* way, so it admits any app port (Vite 5173, Next 3000, Astro 4321, Go 8080, …)
|
|
4185
|
+
* without enumeration. EVERY candidate name in FLEET_FIREWALL_RULE_NAMES is
|
|
4186
|
+
* converged: describing only one silently no-ops on a fleet wired under the other.
|
|
4187
|
+
*
|
|
4188
|
+
* Scope-safe by construction — `update --rules=tcp` rewrites only the allowed
|
|
4189
|
+
* protocols/ports, never a rule's source ranges or target tags, so this can never
|
|
4190
|
+
* change who the rule applies to. Best-effort throughout: a describe/update
|
|
4191
|
+
* failure logs and continues, never aborting the rest of the reconcile.
|
|
4192
|
+
*
|
|
4193
|
+
* Returns `[{ ruleName, rules, converged }]` for every rule that exists — `rules`
|
|
4194
|
+
* is the post-converge allowed value — so the flow is assertable without gcloud.
|
|
4195
|
+
* `deps.spawnSync` is injectable for the same reason.
|
|
4196
|
+
* See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
|
|
4197
|
+
function convergeFleetFirewall({ cfg } = {}, deps = {}) {
|
|
4198
|
+
const run = deps.spawnSync || spawnSync;
|
|
4199
|
+
const project = cfg && cfg.project;
|
|
4200
|
+
const state = [];
|
|
4201
|
+
for (const ruleName of FLEET_FIREWALL_RULE_NAMES) {
|
|
4202
|
+
const dinv = fleetFirewallDescribeInvocation({ ruleName, project });
|
|
4203
|
+
const dres = run(dinv.command, dinv.args, { encoding: "utf8" });
|
|
4204
|
+
// A non-zero describe means no rule under this name — expected, not an error:
|
|
4205
|
+
// only one of the candidate names is deployed on any given fleet.
|
|
4206
|
+
if (!dres || dres.status !== 0) continue;
|
|
4207
|
+
const current = ((dres.stdout || "") + "").trim();
|
|
4208
|
+
if (!firewallNeedsConverge(current)) {
|
|
4209
|
+
console.error(` firewall ${ruleName}: already all-tcp → ok`);
|
|
4210
|
+
state.push({ ruleName, rules: "tcp", converged: false });
|
|
4211
|
+
continue;
|
|
4212
|
+
}
|
|
4213
|
+
const cinv = fleetFirewallConvergeInvocation({ ruleName, project });
|
|
4214
|
+
const cres = run(cinv.command, cinv.args, { encoding: "utf8" });
|
|
4215
|
+
if (cres && cres.status === 0) {
|
|
4216
|
+
console.error(` firewall ${ruleName}: rules '${current}' → tcp (all-tcp)`);
|
|
4217
|
+
state.push({ ruleName, rules: "tcp", converged: true });
|
|
4218
|
+
} else {
|
|
4219
|
+
const tail = (
|
|
4220
|
+
(cres && (cres.stderr || (cres.error && cres.error.message))) || ""
|
|
4221
|
+
).toString().trim().split("\n").pop() || "error";
|
|
4222
|
+
console.error(` firewall ${ruleName}: converge skipped (${tail})`);
|
|
4223
|
+
state.push({ ruleName, rules: current, converged: false });
|
|
4224
|
+
}
|
|
4225
|
+
}
|
|
4226
|
+
if (!state.length) {
|
|
4227
|
+
console.error(
|
|
4228
|
+
` firewall: no rule found under ${FLEET_FIREWALL_RULE_NAMES.join(" / ")} —` +
|
|
4229
|
+
" LB→VM traffic may be blocked; run scripts/gcp/google-ingress.sh",
|
|
4230
|
+
);
|
|
4231
|
+
}
|
|
4232
|
+
return state;
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
/** Surface any rostered VM whose app port the fleet firewall does not admit — the
|
|
4236
|
+
* tripwire for a masked "HEALTHY but unreachable" preview.
|
|
4237
|
+
*
|
|
4238
|
+
* The preview backend service health-checks the fixed editor port, so a rule that
|
|
4239
|
+
* admits that port but not the app's real one reads HEALTHY while every real
|
|
4240
|
+
* request is dropped. That masking is what made the VM-6 outage invisible. Emits a
|
|
4241
|
+
* machine-readable `CY_FIREWALL_PORT_GAP <json>` line per gap, mirroring the
|
|
4242
|
+
* `CY_ORPHAN_SWEEP` marker convention the dashboard already parses.
|
|
4243
|
+
*
|
|
4244
|
+
* Only runs when a rule is still port-pinned: a converged all-tcp rule admits every
|
|
4245
|
+
* port, so the healthy path cannot produce a gap and skipping it avoids an SSH
|
|
4246
|
+
* round-trip per rostered VM for a check that cannot fail. In practice this fires
|
|
4247
|
+
* only if the converge itself failed (e.g. a permissions regression).
|
|
4248
|
+
*
|
|
4249
|
+
* Returns the emitted `[{ vmNumber, appPort, rule, allowed }]` gaps — the same
|
|
4250
|
+
* shape it prints — so the marker contract is directly assertable.
|
|
4251
|
+
* `deps.readVmAppPort` is injectable so tests never reach for SSH. */
|
|
4252
|
+
async function reportFirewallPortGaps({ cfg, roster, firewallState } = {}, deps = {}) {
|
|
4253
|
+
const readPort = deps.readVmAppPort || readVmAppPort;
|
|
4254
|
+
const pinned = (firewallState || []).filter((f) => firewallNeedsConverge(f.rules));
|
|
4255
|
+
const gaps = [];
|
|
4256
|
+
if (!pinned.length) return gaps;
|
|
4257
|
+
for (const n of roster || []) {
|
|
4258
|
+
const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
|
|
4259
|
+
const appPort = await readPort(perVmCfg);
|
|
4260
|
+
for (const f of pinned) {
|
|
4261
|
+
if (firewallAdmitsPort(f.rules, appPort)) continue;
|
|
4262
|
+
const gap = { vmNumber: n, appPort, rule: f.ruleName, allowed: f.rules };
|
|
4263
|
+
console.error(` VM-${n}: app port ${appPort} NOT admitted by ${f.ruleName}`);
|
|
4264
|
+
console.log(`CY_FIREWALL_PORT_GAP ${JSON.stringify(gap)}`);
|
|
4265
|
+
gaps.push(gap);
|
|
4266
|
+
}
|
|
4267
|
+
}
|
|
4268
|
+
return gaps;
|
|
4269
|
+
}
|
|
4270
|
+
|
|
3723
4271
|
function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {}, deps = {}) {
|
|
3724
4272
|
const gi = deps.googleIngress || require("./google-ingress.js");
|
|
3725
4273
|
const fcl = deps.fleetCleanup || require("./fleet-cleanup.js");
|
|
@@ -3889,20 +4437,33 @@ async function probeGitCredentials(cfg) {
|
|
|
3889
4437
|
/** Seed the LIVE in-memory git credential-cache daemon over its socket — the
|
|
3890
4438
|
* same `git credential approve` shape the entrypoint uses
|
|
3891
4439
|
* (scripts/cloud-editor-entrypoint.sh) — so push works immediately with no
|
|
3892
|
-
* container restart.
|
|
3893
|
-
* the
|
|
3894
|
-
*
|
|
4440
|
+
* container restart. ALSO writes the container-local on-disk `store` fallback
|
|
4441
|
+
* file (the fill-only second helper baked into the image) directly, so a
|
|
4442
|
+
* reconcile leaves the VM durable against the cache daemon dying mid-life, not
|
|
4443
|
+
* just healthy for the moment. base64 keeps the token out of the shell history
|
|
4444
|
+
* across the gcloud-ssh → docker layers, and the store write is an in-place
|
|
4445
|
+
* `cat > file` truncate (chmod 600), never touched by git's approve path.
|
|
4446
|
+
* Throws if either write fails. Shared by {@link cmdReconcileCredentials} and
|
|
4447
|
+
* {@link assertGitPushCredentials}. */
|
|
3895
4448
|
async function seedLiveCredentialCache(cfg, token) {
|
|
3896
4449
|
const repoName = resolveProject(cfg).name;
|
|
3897
4450
|
const projectCd = `cd ~/projects/${repoName}`;
|
|
3898
4451
|
const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
|
|
3899
4452
|
const socket = "/run/codeyam/git-credential-cache.socket";
|
|
4453
|
+
const store = "/run/codeyam/git-credentials-store";
|
|
3900
4454
|
const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
|
|
3901
4455
|
const b64 = Buffer.from(approveStdin).toString("base64");
|
|
4456
|
+
// The store-file line is the same one-URL-per-line format as the host staging
|
|
4457
|
+
// file; the image's fill-only `store` helper reads it on a cache miss.
|
|
4458
|
+
const storeB64 = Buffer.from(`${githubCredentialLine(token)}\n`).toString("base64");
|
|
3902
4459
|
const seedCmd =
|
|
3903
|
-
`${projectCd} &&
|
|
4460
|
+
`${projectCd} && ` +
|
|
4461
|
+
`echo ${b64} | base64 -d | ${composeExec} ` +
|
|
3904
4462
|
`git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
|
|
3905
|
-
`credential approve &&
|
|
4463
|
+
`credential approve && ` +
|
|
4464
|
+
`echo ${storeB64} | base64 -d | ${composeExec} ` +
|
|
4465
|
+
`bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
|
|
4466
|
+
`echo APPROVED`;
|
|
3906
4467
|
const seed = runGcloud(
|
|
3907
4468
|
buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
|
|
3908
4469
|
{ stdio: ["ignore", "pipe", "inherit"] },
|
|
@@ -3941,6 +4502,39 @@ async function cmdReconcileCredentials(args, projectDir = rootDir) {
|
|
|
3941
4502
|
);
|
|
3942
4503
|
}
|
|
3943
4504
|
|
|
4505
|
+
/** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
|
|
4506
|
+
* WITHOUT a full re-provision. Resolves the desired value from this invocation
|
|
4507
|
+
* (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
|
|
4508
|
+
* CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
|
|
4509
|
+
* compose edit + `docker compose up -d editor` does today, so the durable admin
|
|
4510
|
+
* setting no longer requires a full re-provision to change. */
|
|
4511
|
+
async function cmdReconcileAdmin(args, projectDir = rootDir) {
|
|
4512
|
+
ensureGcloud();
|
|
4513
|
+
const flags = parseFlags(args);
|
|
4514
|
+
const cfg = loadCloudConfig(projectDir);
|
|
4515
|
+
if (!cfg) {
|
|
4516
|
+
throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
4517
|
+
}
|
|
4518
|
+
const desired =
|
|
4519
|
+
buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
|
|
4520
|
+
const repoName = resolveProject(cfg).name;
|
|
4521
|
+
console.error(
|
|
4522
|
+
`Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
|
|
4523
|
+
);
|
|
4524
|
+
const result = runGcloud(
|
|
4525
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
4526
|
+
command: buildReconcileAdminCommand(repoName, desired),
|
|
4527
|
+
}),
|
|
4528
|
+
{ stdio: "inherit" },
|
|
4529
|
+
);
|
|
4530
|
+
if (result.status !== 0) {
|
|
4531
|
+
throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
|
|
4532
|
+
}
|
|
4533
|
+
console.error(
|
|
4534
|
+
` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
|
|
4535
|
+
);
|
|
4536
|
+
}
|
|
4537
|
+
|
|
3944
4538
|
/** Provision-time guarantee that a just-provisioned VM can actually reach
|
|
3945
4539
|
* GitHub. Probes the staging file + in-container `git ls-remote origin` and
|
|
3946
4540
|
* routes the outcome through the SAME {@link decideCredentialHeal} classifier
|
|
@@ -4072,6 +4666,7 @@ const HANDLERS = {
|
|
|
4072
4666
|
"reconcile-ingress": cmdReconcileIngress,
|
|
4073
4667
|
"wire-add-tail": cmdWireAddTail,
|
|
4074
4668
|
"reconcile-credentials": cmdReconcileCredentials,
|
|
4669
|
+
"reconcile-admin": cmdReconcileAdmin,
|
|
4075
4670
|
access: cmdAccess,
|
|
4076
4671
|
};
|
|
4077
4672
|
|
|
@@ -4109,6 +4704,7 @@ module.exports = {
|
|
|
4109
4704
|
KNOWN_FLAGS,
|
|
4110
4705
|
FIREWALL_RULE_NAME,
|
|
4111
4706
|
INSTANCE_TAG,
|
|
4707
|
+
DEFAULT_FLEET_MACHINE_TYPE,
|
|
4112
4708
|
EDITOR_PORT,
|
|
4113
4709
|
VITE_PORT,
|
|
4114
4710
|
parseFlags,
|
|
@@ -4127,6 +4723,7 @@ module.exports = {
|
|
|
4127
4723
|
previewSameOriginSafeFromStack,
|
|
4128
4724
|
isRottedManagedPreviewOrigin,
|
|
4129
4725
|
isWrongModePreviewOrigin,
|
|
4726
|
+
isSilentlyBrokenPreview,
|
|
4130
4727
|
resolveEditorConfig,
|
|
4131
4728
|
validateEditorVariant,
|
|
4132
4729
|
assertVariantProvisionable,
|
|
@@ -4135,13 +4732,19 @@ module.exports = {
|
|
|
4135
4732
|
resolveGhcrToken,
|
|
4136
4733
|
resolveGhcrPushToken,
|
|
4137
4734
|
probeUsableEditorImage,
|
|
4735
|
+
httpsManifestGet,
|
|
4138
4736
|
resolveProject,
|
|
4139
4737
|
clientProjectBootstrapArgs,
|
|
4140
4738
|
cloudConfigPath,
|
|
4141
4739
|
loadCloudConfig,
|
|
4142
4740
|
saveCloudConfig,
|
|
4143
4741
|
loadOrInitCloudConfig,
|
|
4742
|
+
buildProvisionEnv,
|
|
4743
|
+
buildReconcileAdminCommand,
|
|
4744
|
+
cmdReconcileAdmin,
|
|
4144
4745
|
buildEditorLocalEnvPatch,
|
|
4746
|
+
buildGreenfieldTitlePatch,
|
|
4747
|
+
mergeEditorLocalPatchB64,
|
|
4145
4748
|
buildEditorLocalSeedWriteCommand,
|
|
4146
4749
|
seedPreviewOrigin,
|
|
4147
4750
|
healPreviewOriginAtReady,
|
|
@@ -4169,6 +4772,8 @@ module.exports = {
|
|
|
4169
4772
|
checkScriptDrift,
|
|
4170
4773
|
streamedScriptEditorBranch,
|
|
4171
4774
|
streamedScriptMergeBase,
|
|
4775
|
+
fetchStreamedScriptRef,
|
|
4776
|
+
resolveRemoteScriptRev,
|
|
4172
4777
|
gatherScriptDriftFacts,
|
|
4173
4778
|
readStreamedScript,
|
|
4174
4779
|
STREAMED_SCRIPTS,
|
|
@@ -4195,6 +4800,8 @@ module.exports = {
|
|
|
4195
4800
|
cmdIngressUp,
|
|
4196
4801
|
cmdReconcileIngress,
|
|
4197
4802
|
sweepOrphanIngress,
|
|
4803
|
+
convergeFleetFirewall,
|
|
4804
|
+
reportFirewallPortGaps,
|
|
4198
4805
|
cmdReconcileCredentials,
|
|
4199
4806
|
cmdAccess,
|
|
4200
4807
|
cloudMain,
|
|
@@ -4202,12 +4809,14 @@ module.exports = {
|
|
|
4202
4809
|
seedLiveCredentialCache,
|
|
4203
4810
|
assertGitPushCredentials,
|
|
4204
4811
|
seedGithubCredentials,
|
|
4812
|
+
githubCredentialLine,
|
|
4205
4813
|
resolveGithubToken,
|
|
4206
4814
|
resolveGithubTokenWithSource,
|
|
4207
4815
|
MissingGitPushCredentialsError,
|
|
4208
4816
|
classifyGitRemoteError,
|
|
4209
4817
|
decideCredentialHeal,
|
|
4210
4818
|
readVmAppPort,
|
|
4819
|
+
buildVmAppPortReadCommand,
|
|
4211
4820
|
resolveLaptopGitIdentity,
|
|
4212
4821
|
};
|
|
4213
4822
|
|