@codeyam-editor/codeyam-editor 0.1.0-staging.g6631ba2 → 0.1.0-staging.ga747ed2
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 +1169 -124
- package/npm/codesign-plan.js +68 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-auth-health.js +121 -5
- package/npm/fleet-build-data.js +51 -4
- package/npm/fleet-cleanup.js +92 -1
- package/npm/fleet-destroy-safety.js +230 -13
- 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-memory.js +237 -8
- 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 +127 -7
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability.js +80 -0
- 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 +114 -7
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +21 -4
- package/npm/ingress-invoke.js +310 -21
- 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-DgM0pTAx.js} +1 -1
- package/ui/dist/assets/EditorShell-B7Q06uJ_.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-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CibLNYsH.js → ScenarioDataPanelFullPage-BLqRCxQX.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.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-B14nyUpe.js → index-DCHqRrwO.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-B7rI0u3O.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-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",
|
|
@@ -887,11 +965,61 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
|
|
|
887
965
|
// real use. Unit tests pin their exports so a refactor cannot drop them.
|
|
888
966
|
// =============================================================================
|
|
889
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
|
+
|
|
981
|
+
// The project bootstrap is NOT a probe: it runs `git clone` + `npm install` in
|
|
982
|
+
// the repo root and every `apps[].dir`, each package's `postinstall`, then
|
|
983
|
+
// `npm run setup` — minutes of remote work on a heavy repo. It once ran as ONE
|
|
984
|
+
// blocking `runGcloud("ssh-script")` call, so it inherited GCLOUD_CALL_TIMEOUT_MS
|
|
985
|
+
// (a budget whose own comment scopes it to "any single describe/update/SSH
|
|
986
|
+
// probe"). That SIGTERMd the gcloud child mid-clone; `spawnSync` then returned
|
|
987
|
+
// `status: null`, which `!== 0`, so the timeout was indistinguishable from a
|
|
988
|
+
// genuine remote failure and the caller's abort path DELETED A COMPLETELY
|
|
989
|
+
// HEALTHY VM.
|
|
990
|
+
//
|
|
991
|
+
// Observed twice: 2026-07-28 on client repo `lets-go-compost-portal` (a root
|
|
992
|
+
// postinstall of `playwright install chromium`, ~293 MB), and 2026-07-29
|
|
993
|
+
// provisioning six self-host `codeyam-editor` VMs, whose clone alone exceeds the
|
|
994
|
+
// probe budget — all six were built, torn down mid-clone, and reported as
|
|
995
|
+
// "scripts/gcp-project-bootstrap.sh failed on the VM".
|
|
996
|
+
//
|
|
997
|
+
// The install is now DETACHED (projectBootstrapLaunchCommand + a re-attaching
|
|
998
|
+
// follow loop, outcome read from VM-side state), so no single call carries the
|
|
999
|
+
// whole install any more. This constant is therefore the OVERALL deadline across
|
|
1000
|
+
// re-attaches — the point at which we stop waiting on a still-healthy VM and say
|
|
1001
|
+
// exactly that. Deliberately well above the outer reconcile budget: a long remote
|
|
1002
|
+
// install is not a step in a sweep, so the sweep-relative ceiling that governs
|
|
1003
|
+
// GCLOUD_CALL_TIMEOUT_MS does not apply to it.
|
|
1004
|
+
const PROJECT_BOOTSTRAP_TIMEOUT_MS = 40 * 60 * 1000;
|
|
1005
|
+
|
|
1006
|
+
/** Whether a `spawnSync` result is a LOCAL timeout kill rather than a remote
|
|
1007
|
+
* failure. `spawnSync` reports a timeout as `status: null` plus an ETIMEDOUT
|
|
1008
|
+
* error, so a bare `status !== 0` check conflates the two — which is what made
|
|
1009
|
+
* a timed-out bootstrap look like a broken one. Pure. */
|
|
1010
|
+
function isSpawnTimeout(result) {
|
|
1011
|
+
if (!result) return false;
|
|
1012
|
+
if (result.status !== null && result.status !== undefined) return false;
|
|
1013
|
+
const code = result.error && result.error.code;
|
|
1014
|
+
return code === "ETIMEDOUT" || result.signal === "SIGTERM";
|
|
1015
|
+
}
|
|
1016
|
+
|
|
890
1017
|
function runGcloud(args, opts = {}) {
|
|
891
1018
|
return spawnSync("gcloud", args, {
|
|
892
1019
|
stdio: opts.stdio || "inherit",
|
|
893
1020
|
input: opts.input,
|
|
894
1021
|
encoding: opts.encoding || "utf8",
|
|
1022
|
+
timeout: opts.timeout != null ? opts.timeout : GCLOUD_CALL_TIMEOUT_MS,
|
|
895
1023
|
});
|
|
896
1024
|
}
|
|
897
1025
|
|
|
@@ -1494,7 +1622,305 @@ function bootstrapLogTail(cfg, lines = 40) {
|
|
|
1494
1622
|
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1495
1623
|
}
|
|
1496
1624
|
|
|
1497
|
-
|
|
1625
|
+
// ─── Detached PROJECT bootstrap ──────────────────────────────────────────────
|
|
1626
|
+
//
|
|
1627
|
+
// The VM bootstrap above solved a problem the PROJECT bootstrap still had: read
|
|
1628
|
+
// success and failure from VM-SIDE STATE, never from the SSH exit code, so a
|
|
1629
|
+
// dropped follow-session over a healthy VM is not misreported as "failed on the
|
|
1630
|
+
// VM". The project bootstrap ran instead as ONE blocking ssh-script call, so a
|
|
1631
|
+
// local budget elapsing (or an SSH pipe breaking) was indistinguishable from a
|
|
1632
|
+
// genuine remote failure and the caller's abort path DELETED A HEALTHY VM.
|
|
1633
|
+
// Raising the budget alone just moves the cliff; these helpers remove it by
|
|
1634
|
+
// mirroring the VM-bootstrap contract — detached launch, re-attaching follow
|
|
1635
|
+
// loop, VM-side outcome probe.
|
|
1636
|
+
//
|
|
1637
|
+
// All VM-side paths are keyed by repo name so two projects on one VM never
|
|
1638
|
+
// collide, and they sit beside the `.done` marker projectBootstrapDoneMarker
|
|
1639
|
+
// already defines. Pure string builders (like their VM-bootstrap counterparts)
|
|
1640
|
+
// so the whole grammar is unit-testable without a live VM.
|
|
1641
|
+
function projectBootstrapScriptPath(repoName) {
|
|
1642
|
+
return `$HOME/.codeyam-project-bootstrap-${repoName}.sh`;
|
|
1643
|
+
}
|
|
1644
|
+
function projectBootstrapRunnerPath(repoName) {
|
|
1645
|
+
return `$HOME/.codeyam-project-bootstrap-${repoName}-run.sh`;
|
|
1646
|
+
}
|
|
1647
|
+
function projectBootstrapLogPath(repoName) {
|
|
1648
|
+
return `$HOME/.codeyam-project-bootstrap-${repoName}.log`;
|
|
1649
|
+
}
|
|
1650
|
+
function projectBootstrapPidPath(repoName) {
|
|
1651
|
+
return `$HOME/.codeyam-project-bootstrap-${repoName}.pid`;
|
|
1652
|
+
}
|
|
1653
|
+
// The runner's recorded EXIT STATUS. Deliberately `.status`, NOT `.done`:
|
|
1654
|
+
// `.done` is already taken by scripts/gcp-project-bootstrap.sh's own terminal
|
|
1655
|
+
// SUCCESS marker (a timestamp, written only after `set -euo pipefail` carried
|
|
1656
|
+
// every step through — see projectBootstrapDoneMarker). Reusing `.done` for an
|
|
1657
|
+
// exit code would overwrite the success signal that both improve40's false-skip
|
|
1658
|
+
// fix and the follow loop below depend on. Two files, two distinct meanings:
|
|
1659
|
+
// `.done` = "the script reached its last line", `.status` = "the runner exited,
|
|
1660
|
+
// here is its code".
|
|
1661
|
+
function projectBootstrapStatusPath(repoName) {
|
|
1662
|
+
return `$HOME/.codeyam-project-bootstrap-${repoName}.status`;
|
|
1663
|
+
}
|
|
1664
|
+
const PROJECT_BOOTSTRAP_LAUNCHED_MARKER = "CODEYAM_PROJECT_BOOTSTRAP_LAUNCHED";
|
|
1665
|
+
const PROJECT_BOOTSTRAP_STATUS_MARKER = "CYPROJ";
|
|
1666
|
+
// One follow session caps at 15 min; the overall wait caps at
|
|
1667
|
+
// PROJECT_BOOTSTRAP_TIMEOUT_MS across re-attaches before we bail loudly with
|
|
1668
|
+
// the VM-side log location. Unlike the VM-bootstrap follow loop — whose follow
|
|
1669
|
+
// calls inherit the 120 s probe budget and so re-attach every two minutes over
|
|
1670
|
+
// a ~14-min build — the project follow call below is given an explicit local
|
|
1671
|
+
// budget just above this remote deadline, so a long install streams in one
|
|
1672
|
+
// session instead of churning re-attaches.
|
|
1673
|
+
const PROJECT_BOOTSTRAP_FOLLOW_SECS = 900;
|
|
1674
|
+
const PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS = (PROJECT_BOOTSTRAP_FOLLOW_SECS + 60) * 1000;
|
|
1675
|
+
|
|
1676
|
+
// The tiny VM-side wrapper the detached launch execs: run the real bootstrap,
|
|
1677
|
+
// redirect all output to the per-repo log, and record the exit status in the
|
|
1678
|
+
// `.status` sentinel — the single source of truth for "did the runner exit, and
|
|
1679
|
+
// with what?" once the SSH follow-session is gone. Contains NO secrets (tokens
|
|
1680
|
+
// arrive via the launch env prefix, never written to disk).
|
|
1681
|
+
function projectBootstrapRunnerScript(repoName) {
|
|
1682
|
+
return [
|
|
1683
|
+
"#!/usr/bin/env bash",
|
|
1684
|
+
"# Detached project-bootstrap runner (written by npm/cloud.js",
|
|
1685
|
+
"# ensureProjectBootstrapped). Survives SSH disconnect: the laptop only",
|
|
1686
|
+
"# *follows* the log below; this process keeps running under its own session",
|
|
1687
|
+
"# if the follow drops. Records its own PID so the status probe can check",
|
|
1688
|
+
"# liveness via `kill -0` (a `pgrep` on the script path would also match the",
|
|
1689
|
+
"# probe command itself).",
|
|
1690
|
+
`echo "$$" > "${projectBootstrapPidPath(repoName)}"`,
|
|
1691
|
+
`bash "${projectBootstrapScriptPath(repoName)}" "$@" > "${projectBootstrapLogPath(repoName)}" 2>&1`,
|
|
1692
|
+
"status=$?",
|
|
1693
|
+
`echo "$status" > "${projectBootstrapStatusPath(repoName)}"`,
|
|
1694
|
+
`rm -f "${projectBootstrapPidPath(repoName)}"`,
|
|
1695
|
+
"",
|
|
1696
|
+
].join("\n");
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// Build the remote command that (1) materializes the bootstrap script + runner
|
|
1700
|
+
// on the VM, (2) clears the stale sentinels, (3) launches the runner DETACHED
|
|
1701
|
+
// (`nohup setsid`, new session, stdio to /dev/null) so it outlives the SSH
|
|
1702
|
+
// session, and (4) prints a launched marker. The detach is the durable fix: the
|
|
1703
|
+
// install no longer dies when the laptop's SSH pipe breaks, and the VM is no
|
|
1704
|
+
// longer torn down because it did.
|
|
1705
|
+
//
|
|
1706
|
+
// BOTH sentinels are cleared, `.status` and `.done`. Clearing `.done` is what
|
|
1707
|
+
// makes the outcome probe honest: after a launch, a present `.done` can only
|
|
1708
|
+
// mean THIS run's script reached its terminal line, never a leftover from a
|
|
1709
|
+
// previous half-wired attempt.
|
|
1710
|
+
function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName) {
|
|
1711
|
+
const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
|
|
1712
|
+
const runnerB64 = Buffer.from(projectBootstrapRunnerScript(repoName), "utf8").toString("base64");
|
|
1713
|
+
const argsString = scriptArgs.map(shQuote).join(" ");
|
|
1714
|
+
const envPrefix = envAssignmentPrefix(envVars);
|
|
1715
|
+
// Writes run synchronously (fast, before any backgrounding) so a decode error
|
|
1716
|
+
// aborts before launch; then the runner is detached. The env prefix is on the
|
|
1717
|
+
// setsid command so the tokens are inherited through nohup→setsid→bash but
|
|
1718
|
+
// never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
|
|
1719
|
+
// launch in the background while still confirming it fired.
|
|
1720
|
+
const writes = [
|
|
1721
|
+
`echo '${scriptB64}' | base64 -d > ${projectBootstrapScriptPath(repoName)}`,
|
|
1722
|
+
`echo '${runnerB64}' | base64 -d > ${projectBootstrapRunnerPath(repoName)}`,
|
|
1723
|
+
`rm -f ${projectBootstrapStatusPath(repoName)} ${projectBootstrapDoneMarker(repoName)}`,
|
|
1724
|
+
].join(" && ");
|
|
1725
|
+
const launch =
|
|
1726
|
+
`${envPrefix}nohup setsid bash ${projectBootstrapRunnerPath(repoName)} ${argsString}`.trimEnd() +
|
|
1727
|
+
` </dev/null >/dev/null 2>&1 &`;
|
|
1728
|
+
return `${writes}; ${launch} echo ${PROJECT_BOOTSTRAP_LAUNCHED_MARKER}`;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
// Build the remote command that FOLLOWS the detached install: tail the log live
|
|
1732
|
+
// for the operator, polling for the `.status` sentinel, and exit when the runner
|
|
1733
|
+
// finishes OR a per-session deadline elapses. If the SSH session drops
|
|
1734
|
+
// mid-follow this command dies but the detached install keeps going — the caller
|
|
1735
|
+
// then re-attaches.
|
|
1736
|
+
function projectBootstrapFollowCommand(repoName, deadlineSecs = PROJECT_BOOTSTRAP_FOLLOW_SECS) {
|
|
1737
|
+
const log = projectBootstrapLogPath(repoName);
|
|
1738
|
+
const status = projectBootstrapStatusPath(repoName);
|
|
1739
|
+
return [
|
|
1740
|
+
`touch ${log}`,
|
|
1741
|
+
`tail -n +1 -F ${log} 2>/dev/null & TP=$!`,
|
|
1742
|
+
`waited=0`,
|
|
1743
|
+
`while [ ! -f ${status} ]; do ` +
|
|
1744
|
+
`sleep 3; waited=$((waited+3)); ` +
|
|
1745
|
+
`if [ "$waited" -ge ${deadlineSecs} ]; then kill "$TP" 2>/dev/null; exit 0; fi; ` +
|
|
1746
|
+
`done`,
|
|
1747
|
+
`sleep 1`,
|
|
1748
|
+
`kill "$TP" 2>/dev/null`,
|
|
1749
|
+
].join("; ");
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Build the remote command that reports terminal VM state in one parseable
|
|
1753
|
+
// line: whether the script's terminal `.done` marker exists, the runner's
|
|
1754
|
+
// recorded exit status (or `none`), and whether the runner is still alive.
|
|
1755
|
+
// Cheap — runs over ssh-probe.
|
|
1756
|
+
function projectBootstrapStatusCommand(repoName) {
|
|
1757
|
+
const pid = projectBootstrapPidPath(repoName);
|
|
1758
|
+
const status = projectBootstrapStatusPath(repoName);
|
|
1759
|
+
// Liveness via the runner's PID file + `kill -0`, NOT pgrep: the status
|
|
1760
|
+
// command's own argv contains the bootstrap path, so a `pgrep -f` on it would
|
|
1761
|
+
// match this very probe and always report running. A stale PID file from a
|
|
1762
|
+
// killed runner reads as not-running because `kill -0` fails.
|
|
1763
|
+
return [
|
|
1764
|
+
`done=no; [ -f ${projectBootstrapDoneMarker(repoName)} ] && done=yes`,
|
|
1765
|
+
`st=none; [ -f ${status} ] && st=$(cat ${status} 2>/dev/null)`,
|
|
1766
|
+
`run=no; if [ -f ${pid} ]; then p=$(cat ${pid} 2>/dev/null); ` +
|
|
1767
|
+
`{ [ -n "$p" ] && kill -0 "$p" 2>/dev/null; } && run=yes; fi`,
|
|
1768
|
+
`echo "${PROJECT_BOOTSTRAP_STATUS_MARKER} done=$done status=$st running=$run"`,
|
|
1769
|
+
].join("; ");
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// Parse the single status line emitted by projectBootstrapStatusCommand.
|
|
1773
|
+
// Returns `{ done, status, running }` or null when no status line is present
|
|
1774
|
+
// (e.g. the probe ssh itself failed and produced no stdout).
|
|
1775
|
+
function parseProjectBootstrapStatus(stdout) {
|
|
1776
|
+
const m = String(stdout || "").match(
|
|
1777
|
+
new RegExp(`${PROJECT_BOOTSTRAP_STATUS_MARKER} done=(\\S+) status=(\\S+) running=(\\S+)`),
|
|
1778
|
+
);
|
|
1779
|
+
if (!m) return null;
|
|
1780
|
+
return { done: m[1], status: m[2], running: m[3] };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Decide the project-bootstrap outcome from VM-side state alone — never the SSH
|
|
1784
|
+
// exit code. The `.done` marker is the real artifact (the script writes it only
|
|
1785
|
+
// after `set -euo pipefail` carried every step to success), so its presence is
|
|
1786
|
+
// success regardless of how the follow session ended — the same reasoning that
|
|
1787
|
+
// makes the docker image authoritative for the VM bootstrap. A live runner means
|
|
1788
|
+
// "re-attach". A recorded exit status without the marker means the install ran
|
|
1789
|
+
// and failed. Nothing at all means it was never started (or the probe could not
|
|
1790
|
+
// see it).
|
|
1791
|
+
function decideProjectBootstrapOutcome(status) {
|
|
1792
|
+
if (!status) return "absent";
|
|
1793
|
+
const { done, status: exitStatus, running } = status;
|
|
1794
|
+
if (done === "yes") return "success";
|
|
1795
|
+
if (running === "yes") return "running";
|
|
1796
|
+
if (exitStatus && exitStatus !== "none") return "failed";
|
|
1797
|
+
return "absent";
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Reduce one follow-loop iteration to an action: "success" | "failed" |
|
|
1801
|
+
// "timeout" | "reattach". Extracted as a pure function because its PRECEDENCE is
|
|
1802
|
+
// load-bearing — it decides whether an operator is told a VM is broken:
|
|
1803
|
+
//
|
|
1804
|
+
// • success and failed outrank the deadline. Otherwise the final poll of a
|
|
1805
|
+
// slow-but-successful bootstrap reports a timeout, and the operator is sent
|
|
1806
|
+
// to reconcile a VM that actually finished.
|
|
1807
|
+
// • a still-running install past the deadline is a TIMEOUT, never a failure.
|
|
1808
|
+
// That is the VM-8 case: healthy VM, still installing, and saying so is what
|
|
1809
|
+
// gets the add classified retryable instead of destroyed.
|
|
1810
|
+
// • an unseen runner ("absent") before the deadline re-attaches rather than
|
|
1811
|
+
// failing — "I could not see it" is not "it broke" — but is still bounded by
|
|
1812
|
+
// the deadline so the loop cannot spin forever.
|
|
1813
|
+
function decideProjectBootstrapFollowAction({ outcome, now, deadline } = {}) {
|
|
1814
|
+
if (outcome === "success") return "success";
|
|
1815
|
+
if (outcome === "failed") return "failed";
|
|
1816
|
+
if (now >= deadline) return "timeout";
|
|
1817
|
+
return "reattach";
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Probe the VM for the detached project bootstrap's terminal state and reduce it
|
|
1821
|
+
// to a single outcome string ("success" | "failed" | "running" | "absent").
|
|
1822
|
+
// Used both for the idempotent re-attach decision and after each follow session.
|
|
1823
|
+
function probeProjectBootstrapOutcome(cfg, repoName) {
|
|
1824
|
+
const r = runGcloud(
|
|
1825
|
+
buildGcloudArgs("ssh-probe", cfg, { command: projectBootstrapStatusCommand(repoName) }),
|
|
1826
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1827
|
+
);
|
|
1828
|
+
return decideProjectBootstrapOutcome(parseProjectBootstrapStatus(r.stdout));
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/** Assemble the env forwarded into the project-bootstrap shell on the VM.
|
|
1832
|
+
* Extracted from ensureProjectBootstrapped so its CONDITIONAL keys are testable
|
|
1833
|
+
* without a VM — previously you could not assert "FLEET_DOMAIN is omitted for a
|
|
1834
|
+
* non-fleet project" or "a re-provision without --admin stays non-admin"
|
|
1835
|
+
* without invoking the whole gcloud-touching bootstrap.
|
|
1836
|
+
*
|
|
1837
|
+
* Keys, and why each is here:
|
|
1838
|
+
* GITHUB_TOKEN — clone a private client repo.
|
|
1839
|
+
* CY_GIT_USER_{NAME,EMAIL} — rendered into the compose file, then written
|
|
1840
|
+
* to `git config --global` by the entrypoint so
|
|
1841
|
+
* plan_claim_refs::create_claim's commit-tree
|
|
1842
|
+
* call succeeds. Empty is legitimate (a CI
|
|
1843
|
+
* runner with no author); the entrypoint warns
|
|
1844
|
+
* and proceeds, so the keys stay present.
|
|
1845
|
+
* FLEET_DOMAIN — ONLY for a numbered fleet VM with a domain
|
|
1846
|
+
* configured, so the compose render publishes
|
|
1847
|
+
* on 0.0.0.0 for the LB NEG. Omitted entirely
|
|
1848
|
+
* otherwise, preserving the loopback-only
|
|
1849
|
+
* publish a plain client project expects.
|
|
1850
|
+
* <forwarded launch-form keys> — DATABASE_URL et al, whitelisted by the
|
|
1851
|
+
* dashboard via CY_FORWARD_ENV_KEYS, so the
|
|
1852
|
+
* bootstrap-time `npm run setup` sees them on
|
|
1853
|
+
* the first try. Empty on plain `cloud:up`.
|
|
1854
|
+
* CODEYAM_ADMIN — the Home-tab Setup door. Defaults to the
|
|
1855
|
+
* strict non-admin "".
|
|
1856
|
+
*
|
|
1857
|
+
* Later spreads win, matching the original inline assembly exactly. `deps` lets
|
|
1858
|
+
* a test supply the token and git identity instead of reading the real
|
|
1859
|
+
* environment and shelling out to git. */
|
|
1860
|
+
function projectBootstrapEnv(cfg = {}, { env = process.env, githubToken, gitIdentity } = {}) {
|
|
1861
|
+
const git = gitIdentity || resolveLaptopGitIdentity();
|
|
1862
|
+
const out = {
|
|
1863
|
+
GITHUB_TOKEN: githubToken !== undefined ? githubToken : resolveGithubToken(),
|
|
1864
|
+
CY_GIT_USER_NAME: git.name,
|
|
1865
|
+
CY_GIT_USER_EMAIL: git.email,
|
|
1866
|
+
};
|
|
1867
|
+
const ingressPlan = ingressWiringPlan(cfg, env);
|
|
1868
|
+
if (ingressPlan.wire) out.FLEET_DOMAIN = ingressPlan.domain;
|
|
1869
|
+
Object.assign(out, resolveSetupForwardEnv(env));
|
|
1870
|
+
Object.assign(out, buildProvisionEnv(env, cfg));
|
|
1871
|
+
return out;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// Fetch the tail of the VM-side project-bootstrap log for an actionable failure
|
|
1875
|
+
// message — the operator sees WHY the install failed, not a bare exit code.
|
|
1876
|
+
function projectBootstrapLogTail(cfg, repoName, lines = 40) {
|
|
1877
|
+
const r = runGcloud(
|
|
1878
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1879
|
+
command: `tail -n ${lines} ${projectBootstrapLogPath(repoName)} 2>/dev/null`,
|
|
1880
|
+
}),
|
|
1881
|
+
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
1882
|
+
);
|
|
1883
|
+
return String(r.stdout || "").trim() || "(log unavailable)";
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
/** Assemble the extra provision-time env forwarded into the bootstrap shell,
|
|
1887
|
+
* where it feeds the compose-render substitution. Currently just the admin
|
|
1888
|
+
* toggle: CODEYAM_ADMIN gates the Home-tab Setup door and is read from the
|
|
1889
|
+
* editor-server process env (handlers.rs::admin_enabled, strict literal "1").
|
|
1890
|
+
* The operator opts in with `CODEYAM_ADMIN=1 npm run cloud:up …` or `--admin`
|
|
1891
|
+
* (which lands as cfg.admin); anything else forwards "" so a normal launch —
|
|
1892
|
+
* and a re-provision without the flag — is strict non-admin. Pure over its
|
|
1893
|
+
* inputs so cloud.test.js can pin it without touching gcloud. */
|
|
1894
|
+
function buildProvisionEnv(env = process.env, flags = {}) {
|
|
1895
|
+
const admin = flags.admin === true || env.CODEYAM_ADMIN === "1";
|
|
1896
|
+
return { CODEYAM_ADMIN: admin ? "1" : "" };
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
/** Build the one-shot SSH shell command that reconciles a running VM's admin
|
|
1900
|
+
* mode without a full re-provision: rewrite the rendered compose's
|
|
1901
|
+
* CODEYAM_ADMIN line to `value` — or insert it under `environment:` on a legacy
|
|
1902
|
+
* VM whose compose predates the placeholder — then recreate the editor service
|
|
1903
|
+
* so admin_enabled() re-reads it. `value` is always "1" or "" (the caller
|
|
1904
|
+
* normalizes), so inlining it into sed is safe. Pure/string-only so
|
|
1905
|
+
* cloud.test.js can assert its shape without SSHing. */
|
|
1906
|
+
function buildReconcileAdminCommand(repoName, value) {
|
|
1907
|
+
const f = "docker-compose.cloud.yml";
|
|
1908
|
+
const entry = ` - CODEYAM_ADMIN=${value}`;
|
|
1909
|
+
// Capture only the leading indentation (\1) and re-emit the literal key, so a
|
|
1910
|
+
// value that starts with a digit (e.g. "1") never lands right after the
|
|
1911
|
+
// backreference — `\11` would be read by sed as group 11, not `\1` + "1".
|
|
1912
|
+
return (
|
|
1913
|
+
`cd ~/projects/${repoName} && ` +
|
|
1914
|
+
`if grep -qE '^[[:space:]]*- CODEYAM_ADMIN=' ${f}; then ` +
|
|
1915
|
+
`sed -i -E 's#^([[:space:]]*)- CODEYAM_ADMIN=.*#\\1- CODEYAM_ADMIN=${value}#' ${f}; ` +
|
|
1916
|
+
`else ` +
|
|
1917
|
+
`sed -i -E 's#^([[:space:]]*environment:[[:space:]]*)$#\\1\\n${entry}#' ${f}; ` +
|
|
1918
|
+
`fi && ` +
|
|
1919
|
+
`docker compose -f ${f} up -d editor`
|
|
1920
|
+
);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
|
|
1498
1924
|
const repoName = resolveProject(cfg).name;
|
|
1499
1925
|
console.error(`Checking project bootstrap state for ${repoName}...`);
|
|
1500
1926
|
// Gate on the TERMINAL completion marker the script writes as its very last
|
|
@@ -1524,42 +1950,102 @@ async function ensureProjectBootstrapped(cfg) {
|
|
|
1524
1950
|
const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
|
|
1525
1951
|
const scriptBody = readStreamedScript(scriptPath, cfg);
|
|
1526
1952
|
const scriptArgs = clientProjectBootstrapArgs(cfg);
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1535
|
-
//
|
|
1536
|
-
//
|
|
1537
|
-
// the
|
|
1538
|
-
//
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1953
|
+
// Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
|
|
1954
|
+
// toggle — see projectBootstrapEnv for what each key is for and when it is
|
|
1955
|
+
// omitted. Extracted so those conditionals are testable without a VM.
|
|
1956
|
+
const bootstrapEnv = projectBootstrapEnv(cfg);
|
|
1957
|
+
|
|
1958
|
+
// Idempotent re-attach: a prior cloud:up whose follow-session dropped may have
|
|
1959
|
+
// left a detached install still running. Converge on it instead of starting a
|
|
1960
|
+
// second `npm install` on top of the first.
|
|
1961
|
+
//
|
|
1962
|
+
// NOTE the deliberate asymmetry with ensureBootstrapped, which DOES return
|
|
1963
|
+
// early on an outcome of "success". There, the done-probe and the outcome probe
|
|
1964
|
+
// read the same artifact (the docker image), so they always agree. Here the
|
|
1965
|
+
// done-probe above is a strictly STRONGER three-way AND (`.done` marker +
|
|
1966
|
+
// ~/projects/<repo>/.codeyam + docker-compose.cloud.yml) than this probe's
|
|
1967
|
+
// `.done`-alone success, and it has already said no. Honoring a bare `.done`
|
|
1968
|
+
// here would re-open the improve40 VM-7 false-skip from the other side, so only
|
|
1969
|
+
// "running" short-circuits the launch.
|
|
1970
|
+
const initial = probeProjectBootstrapOutcome(cfg, repoName);
|
|
1971
|
+
if (initial === "running") {
|
|
1972
|
+
console.error(" ↺ a detached project bootstrap is already in progress — re-attaching to its log...");
|
|
1973
|
+
} else {
|
|
1974
|
+
// "absent" (never started), "failed" (a prior run died), or "success" (a bare
|
|
1975
|
+
// marker the stricter done-probe rejected) → (re)launch a fresh detached
|
|
1976
|
+
// install that will survive an SSH disconnect. Every step of
|
|
1977
|
+
// gcp-project-bootstrap.sh is skip-on-exists, so re-entering is cheap.
|
|
1978
|
+
console.error(" launching detached project bootstrap on the VM (survives SSH disconnect)...");
|
|
1979
|
+
const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName);
|
|
1980
|
+
const launched = await runGcloudSshWithRetry(
|
|
1981
|
+
"ssh-script",
|
|
1982
|
+
cfg,
|
|
1983
|
+
{ command: launch },
|
|
1984
|
+
{ stdio: ["ignore", "pipe", "pipe"], label: "project bootstrap launch", sleepFn },
|
|
1985
|
+
);
|
|
1986
|
+
if (isSpawnTimeout(launched)) {
|
|
1987
|
+
// The LOCAL budget elapsed and we SIGTERMd the gcloud child. Say so:
|
|
1988
|
+
// the launch is a short write-and-detach, so this is an SSH-reachability
|
|
1989
|
+
// problem, NOT a broken bootstrap script — and the VM may be perfectly
|
|
1990
|
+
// healthy. Conflating the two is what destroyed VM-8.
|
|
1991
|
+
throw new Error(
|
|
1992
|
+
`launching the detached project bootstrap exceeded the local gcloud call budget ` +
|
|
1993
|
+
`and was SIGTERMd locally — the VM may be healthy and simply unreachable over SSH ` +
|
|
1994
|
+
`right now. Re-run to re-attach (each bootstrap step is skip-on-exists).`,
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
if (launched.status !== 0) {
|
|
1998
|
+
throw new Error(
|
|
1999
|
+
`failed to launch the detached project bootstrap on the VM (gcloud ssh exit ${launched.status}).\n` +
|
|
2000
|
+
formatGcloudFailure(launched),
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// Follow the live log, re-attaching across any follow-session drops, until the
|
|
2006
|
+
// VM reports a terminal state or the overall deadline elapses. Success and
|
|
2007
|
+
// failure are read from VM state (the `.done` marker + the `.status` sentinel),
|
|
2008
|
+
// never the SSH exit code — so "my laptop's SSH dropped" or "a heavy npm
|
|
2009
|
+
// install outran a local budget" no longer masquerades as a broken bootstrap
|
|
2010
|
+
// and no longer gets a healthy VM deleted.
|
|
2011
|
+
const deadline = Date.now() + PROJECT_BOOTSTRAP_TIMEOUT_MS;
|
|
2012
|
+
for (;;) {
|
|
2013
|
+
runGcloud(
|
|
2014
|
+
buildGcloudArgs("ssh-script", cfg, { command: projectBootstrapFollowCommand(repoName) }),
|
|
2015
|
+
{ stdio: "inherit", timeout: PROJECT_BOOTSTRAP_FOLLOW_TIMEOUT_MS },
|
|
2016
|
+
);
|
|
2017
|
+
const outcome = probeProjectBootstrapOutcome(cfg, repoName);
|
|
2018
|
+
const action = decideProjectBootstrapFollowAction({
|
|
2019
|
+
outcome,
|
|
2020
|
+
now: Date.now(),
|
|
2021
|
+
deadline,
|
|
2022
|
+
});
|
|
2023
|
+
if (action === "success") {
|
|
2024
|
+
console.error(` ✓ ${repoName} bootstrap complete`);
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
if (action === "failed") {
|
|
2028
|
+
const tail = projectBootstrapLogTail(cfg, repoName);
|
|
2029
|
+
throw new Error(
|
|
2030
|
+
"scripts/gcp-project-bootstrap.sh failed on the VM. " +
|
|
2031
|
+
`Last lines of ${projectBootstrapLogPath(repoName)}:\n${tail}`,
|
|
2032
|
+
);
|
|
2033
|
+
}
|
|
2034
|
+
if (action === "timeout") {
|
|
2035
|
+
// The install is STILL RUNNING on a healthy VM and we simply stopped
|
|
2036
|
+
// waiting. This message is the signature npm/fleet-add-failure.js keys on
|
|
2037
|
+
// to classify the add as `project-bootstrap-timeout` (retryable) instead of
|
|
2038
|
+
// the non-retryable generic cloud-up-failed that tore down VM-8.
|
|
2039
|
+
throw new Error(
|
|
2040
|
+
`project bootstrap exceeded the local ${Math.round(PROJECT_BOOTSTRAP_TIMEOUT_MS / 60000)}-minute budget ` +
|
|
2041
|
+
`— the VM and its detached bootstrap may be healthy and still running. ` +
|
|
2042
|
+
`Re-run to re-attach (each bootstrap step is skip-on-exists), or inspect ` +
|
|
2043
|
+
`${projectBootstrapLogPath(repoName)} on the VM.`,
|
|
2044
|
+
);
|
|
2045
|
+
}
|
|
2046
|
+
// action === "reattach": the follow session ended but the install is alive
|
|
2047
|
+
// (or the probe could not see it yet). Re-attach to its log.
|
|
2048
|
+
console.error(" ↺ follow session ended but the project bootstrap is still running — re-attaching...");
|
|
1563
2049
|
}
|
|
1564
2050
|
}
|
|
1565
2051
|
|
|
@@ -1761,49 +2247,113 @@ function resolveGhcrPushToken() {
|
|
|
1761
2247
|
return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
|
|
1762
2248
|
}
|
|
1763
2249
|
|
|
2250
|
+
// Registry manifest media types we accept when probing image presence over the
|
|
2251
|
+
// GHCR HTTP API — Docker v2 + manifest lists and their OCI equivalents, so a
|
|
2252
|
+
// multi-arch index answers 200 the same as a single-arch manifest.
|
|
2253
|
+
const MANIFEST_ACCEPT = [
|
|
2254
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
2255
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
2256
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
2257
|
+
"application/vnd.oci.image.index.v1+json",
|
|
2258
|
+
].join(", ");
|
|
2259
|
+
const GHCR_PROBE_TIMEOUT_MS = 15000;
|
|
2260
|
+
|
|
2261
|
+
/** Default HTTPS client for the registry probe: a GET that resolves to
|
|
2262
|
+
* `{ statusCode }` and NEVER rejects — any socket / DNS / timeout error resolves
|
|
2263
|
+
* to `statusCode: 0` (the probe reads that as "undetermined"). Injected as the
|
|
2264
|
+
* `httpImpl` option so probeUsableEditorImage is unit-testable with no network
|
|
2265
|
+
* (and no Docker daemon). `requestImpl` (defaulting to `https.request`) is
|
|
2266
|
+
* injectable so the response / error / timeout mapping is itself unit-testable
|
|
2267
|
+
* without a real socket. */
|
|
2268
|
+
function httpsManifestGet({ hostname, path: reqPath, headers }, requestImpl = https.request) {
|
|
2269
|
+
return new Promise((resolve) => {
|
|
2270
|
+
const req = requestImpl(
|
|
2271
|
+
{ hostname, path: reqPath, method: "GET", headers },
|
|
2272
|
+
(res) => {
|
|
2273
|
+
res.resume(); // drain the body so the socket is freed
|
|
2274
|
+
res.on("end", () => resolve({ statusCode: res.statusCode }));
|
|
2275
|
+
},
|
|
2276
|
+
);
|
|
2277
|
+
req.on("error", () => resolve({ statusCode: 0 }));
|
|
2278
|
+
req.setTimeout(GHCR_PROBE_TIMEOUT_MS, () => req.destroy());
|
|
2279
|
+
req.end();
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
/** Probe ONE fully-qualified image ref (`host/repo:tag`) over the registry HTTP
|
|
2284
|
+
* API — no docker daemon, so a docker-less operator host can still determine
|
|
2285
|
+
* presence. Returns true (200 present), false (404 absent), or null (auth /
|
|
2286
|
+
* network / malformed-ref → can't tell absent from forbidden). */
|
|
2287
|
+
async function probeManifestRef(fullRef, bearer, httpGet) {
|
|
2288
|
+
const colon = fullRef.lastIndexOf(":");
|
|
2289
|
+
if (colon < 0) return null;
|
|
2290
|
+
const ref = fullRef.slice(0, colon); // host/repo
|
|
2291
|
+
const tag = fullRef.slice(colon + 1); // slug-latest
|
|
2292
|
+
const slash = ref.indexOf("/");
|
|
2293
|
+
if (slash < 0) return null;
|
|
2294
|
+
const hostname = ref.slice(0, slash); // ghcr.io
|
|
2295
|
+
const repo = ref.slice(slash + 1); // codeyam-ai/codeyam-editor-cloud-smoke
|
|
2296
|
+
let res;
|
|
2297
|
+
try {
|
|
2298
|
+
res = await httpGet({
|
|
2299
|
+
hostname,
|
|
2300
|
+
path: `/v2/${repo}/manifests/${encodeURIComponent(tag)}`,
|
|
2301
|
+
headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
|
|
2302
|
+
});
|
|
2303
|
+
} catch (_e) {
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
const code = res && res.statusCode;
|
|
2307
|
+
if (code === 200) return true;
|
|
2308
|
+
if (code === 404) return false;
|
|
2309
|
+
return null; // 401 / 403 / 5xx / 0 (network) → undetermined
|
|
2310
|
+
}
|
|
2311
|
+
|
|
1764
2312
|
/** Read-only probe: does a usable prebuilt editor image already exist on the
|
|
1765
2313
|
* registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
|
|
1766
2314
|
* add to decide whether to pace one VM ahead of the rest (see
|
|
1767
|
-
* npm/fleet-prewarm.js::decideBatchPrewarm).
|
|
1768
|
-
*
|
|
2315
|
+
* npm/fleet-prewarm.js::decideBatchPrewarm).
|
|
2316
|
+
*
|
|
2317
|
+
* Queries the registry over its HTTP API (`GET /v2/<repo>/manifests/<tag>`, a
|
|
2318
|
+
* HEAD-equivalent that reads the manifest without pulling the multi-GB image)
|
|
2319
|
+
* instead of `docker login` / `docker manifest inspect`. This is the root fix
|
|
2320
|
+
* for the docker-less GCE operator host: the old docker path errored on the
|
|
2321
|
+
* first `docker login` spawn (the control plane has no docker daemon) and so
|
|
2322
|
+
* returned `null` forever, which decideBatchPrewarm read as "fan out" — the
|
|
2323
|
+
* image was therefore never published and every cold add source-built. Over
|
|
2324
|
+
* HTTP the probe returns a DEFINITIVE verdict with no daemon.
|
|
1769
2325
|
*
|
|
1770
2326
|
* Returns:
|
|
1771
2327
|
* true — at least one of the usable tags exists (a cold add can pull; no pacer)
|
|
1772
2328
|
* 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.
|
|
2329
|
+
* null — undetermined: no read token, or an auth/network error that can't be
|
|
2330
|
+
* distinguished from "absent".
|
|
1777
2331
|
*
|
|
1778
|
-
*
|
|
1779
|
-
*
|
|
1780
|
-
*
|
|
1781
|
-
|
|
2332
|
+
* GHCR's private packages require auth; without a read token we can't tell
|
|
2333
|
+
* absent from forbidden, so → null. The read token is sent as the registry
|
|
2334
|
+
* bearer (GHCR accepts a base64-encoded PAT directly — no separate token-
|
|
2335
|
+
* exchange round-trip). `httpImpl` is injectable for unit tests; a legacy
|
|
2336
|
+
* `spawnImpl` option is still accepted (and ignored) for backward-compat — the
|
|
2337
|
+
* HTTP happy path needs no spawn. */
|
|
2338
|
+
async function probeUsableEditorImage({ branch, registry, token, httpImpl } = {}) {
|
|
1782
2339
|
const fp = require("./fleet-prewarm");
|
|
1783
|
-
const spawn = spawnImpl || spawnSync;
|
|
1784
2340
|
const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
|
|
1785
2341
|
const readToken = token || resolveGhcrToken();
|
|
1786
2342
|
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;
|
|
2343
|
+
const httpGet = httpImpl || httpsManifestGet;
|
|
2344
|
+
// GHCR accepts a base64-encoded PAT as the registry bearer — no token-exchange
|
|
2345
|
+
// round-trip needed.
|
|
2346
|
+
const bearer = Buffer.from(readToken).toString("base64");
|
|
1795
2347
|
const tags = fp.usableImageTags({ registry: reg, branch });
|
|
1796
2348
|
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.
|
|
2349
|
+
for (const fullRef of tags) {
|
|
2350
|
+
const verdict = await probeManifestRef(fullRef, bearer, httpGet);
|
|
2351
|
+
if (verdict === true) return true; // a usable image exists
|
|
2352
|
+
if (verdict === null) allClean = false; // this tag's probe was inconclusive
|
|
2353
|
+
// verdict === false → this tag is absent; keep checking the rest
|
|
2354
|
+
}
|
|
2355
|
+
// Every tag probed cleanly and none existed → definitively absent. If any probe
|
|
2356
|
+
// was inconclusive we can't claim absence → undetermined.
|
|
1807
2357
|
return allClean ? false : null;
|
|
1808
2358
|
}
|
|
1809
2359
|
|
|
@@ -1840,6 +2390,15 @@ function classifyGitRemoteError(stderr) {
|
|
|
1840
2390
|
) {
|
|
1841
2391
|
return "network";
|
|
1842
2392
|
}
|
|
2393
|
+
// A greenfield project (`--new-project` scaffold) has no origin remote and an
|
|
2394
|
+
// unborn `main`, so `git ls-remote origin` fails with "does not appear to be a
|
|
2395
|
+
// git repository". No token would fix this — there is simply nothing to push
|
|
2396
|
+
// yet — so it is neither `credential` nor `network` nor `git-spawn`. Keep the
|
|
2397
|
+
// match narrow: `repository not found` is a genuine access failure against a
|
|
2398
|
+
// real remote and must NOT be swallowed here.
|
|
2399
|
+
if (lower.includes("does not appear to be a git repository")) {
|
|
2400
|
+
return "no-remote";
|
|
2401
|
+
}
|
|
1843
2402
|
return "other";
|
|
1844
2403
|
}
|
|
1845
2404
|
|
|
@@ -1877,6 +2436,14 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1877
2436
|
reason: "git binary failed to launch — not a credential gap, not re-seeding",
|
|
1878
2437
|
};
|
|
1879
2438
|
}
|
|
2439
|
+
if (category === "no-remote") {
|
|
2440
|
+
return {
|
|
2441
|
+
heal: false,
|
|
2442
|
+
category,
|
|
2443
|
+
reason:
|
|
2444
|
+
"no git remote configured — greenfield project, nothing to push yet (not a credential gap)",
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
1880
2447
|
return {
|
|
1881
2448
|
heal: true,
|
|
1882
2449
|
category,
|
|
@@ -1894,6 +2461,16 @@ function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
|
1894
2461
|
};
|
|
1895
2462
|
}
|
|
1896
2463
|
|
|
2464
|
+
/** The single GitHub credential line format shared by every seed path: the
|
|
2465
|
+
* host staging file, the durable on-disk `store` fallback, and the live
|
|
2466
|
+
* cache-daemon approve all expect `https://x-access-token:<token>@github.com`
|
|
2467
|
+
* (git's credential-URL form, matched by both the entrypoint parser and
|
|
2468
|
+
* git-credential-store). Centralized so the `x-access-token` shape has one
|
|
2469
|
+
* source of truth instead of being re-typed at each call site. */
|
|
2470
|
+
function githubCredentialLine(token) {
|
|
2471
|
+
return `https://x-access-token:${token}@github.com`;
|
|
2472
|
+
}
|
|
2473
|
+
|
|
1897
2474
|
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1898
2475
|
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1899
2476
|
* without this, claude / codex / gemini inside the container can clone the
|
|
@@ -1919,21 +2496,28 @@ async function seedGithubCredentials(cfg, opts = {}) {
|
|
|
1919
2496
|
console.error(
|
|
1920
2497
|
`Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
|
|
1921
2498
|
);
|
|
1922
|
-
const credLine =
|
|
2499
|
+
const credLine = githubCredentialLine(token);
|
|
1923
2500
|
// Quote the token through one shell layer (gcloud ssh --command); the
|
|
1924
2501
|
// value is base64'd to keep it out of the shell history.
|
|
1925
2502
|
const b64 = Buffer.from(`${credLine}\n`).toString("base64");
|
|
1926
|
-
// Verify-before-
|
|
1927
|
-
// (`[ -s ]`), and only THEN
|
|
2503
|
+
// Verify-before-write: decode into a chmod-600 temp, assert it is non-empty
|
|
2504
|
+
// (`[ -s ]`), and only THEN copy it over the live staging file. An empty or
|
|
1928
2505
|
// unreadable decode must never clobber an existing good file — the entrypoint
|
|
1929
2506
|
// reads this file on every boot, and a silent empty write is exactly how a
|
|
1930
2507
|
// reprovision lands a VM that "could not read Username for github.com". The
|
|
1931
|
-
// trailing `echo OK` runs only on
|
|
2508
|
+
// trailing `echo OK` runs only on success, so an empty write can't mask
|
|
1932
2509
|
// success with a `&& echo` (the failure shape seen on the Claude creds sinks).
|
|
2510
|
+
//
|
|
2511
|
+
// Use `cat "$t" > "$f"` (in-place truncate), NOT `mv`: the staging file is
|
|
2512
|
+
// bind-mounted read-only into the running container, and `mv` mints a NEW
|
|
2513
|
+
// inode that the live `:ro` mount does not follow — so a `mv`-based re-seed
|
|
2514
|
+
// never reached the running container until a full recreate. Truncating the
|
|
2515
|
+
// existing file in place keeps its inode, so the durable write is visible to
|
|
2516
|
+
// the container immediately. Then remove the temp.
|
|
1933
2517
|
const remoteCmd =
|
|
1934
2518
|
`f=$HOME/.codeyam-cloud-git-credentials && t="$f.tmp.$$" && ` +
|
|
1935
2519
|
`echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
|
|
1936
|
-
`if [ -s "$t" ]; then
|
|
2520
|
+
`if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
|
|
1937
2521
|
`else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
|
|
1938
2522
|
const result = runGcloud(
|
|
1939
2523
|
buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
|
|
@@ -1944,26 +2528,76 @@ async function seedGithubCredentials(cfg, opts = {}) {
|
|
|
1944
2528
|
}
|
|
1945
2529
|
}
|
|
1946
2530
|
|
|
2531
|
+
/** Sentinel the remote read prints when it cannot find an `editor.json` under
|
|
2532
|
+
* any `~/projects/*` directory. Distinguishes "this VM has no scaffolded
|
|
2533
|
+
* project" (a real miss worth warning about) from "the project is scaffolded
|
|
2534
|
+
* but declares no port" (legitimately a `VITE_PORT` default). Without the
|
|
2535
|
+
* distinction both collapse to `{}` and the miss is silent. */
|
|
2536
|
+
const VM_APP_PORT_NO_CONFIG = "<<<NOCONFIG>>>";
|
|
2537
|
+
|
|
2538
|
+
/** Build the remote shell command that locates the project's `.codeyam` dir and
|
|
2539
|
+
* cats its two config files in one round-trip.
|
|
2540
|
+
*
|
|
2541
|
+
* Resolution is glob-based rather than `~/projects/<repoName>` alone: the host
|
|
2542
|
+
* directory is not guaranteed to be named after the repo (a renamed repo, a
|
|
2543
|
+
* manually-cloned dir, a fleet slug), and when the guess missed, the old code
|
|
2544
|
+
* silently returned `VITE_PORT` and forwarded the wrong port. The repo-named
|
|
2545
|
+
* path is still tried FIRST so a multi-project VM resolves deterministically;
|
|
2546
|
+
* the glob is the fallback. When nothing matches, print the sentinel so the
|
|
2547
|
+
* caller can warn instead of guessing. Pure string builder; no I/O. */
|
|
2548
|
+
function buildVmAppPortReadCommand(repoName) {
|
|
2549
|
+
return (
|
|
2550
|
+
`d=""; ` +
|
|
2551
|
+
`for c in ~/projects/${repoName}/.codeyam ~/projects/*/.codeyam; do ` +
|
|
2552
|
+
`if [ -f "$c/editor.json" ]; then d="$c"; break; fi; done; ` +
|
|
2553
|
+
`if [ -z "$d" ]; then echo '${VM_APP_PORT_NO_CONFIG}'; else ` +
|
|
2554
|
+
`cat "$d/editor.json" 2>/dev/null || echo '{}'; ` +
|
|
2555
|
+
`echo '<<<LOCAL>>>'; ` +
|
|
2556
|
+
`cat "$d/editor.local.json" 2>/dev/null || echo '{}'; fi`
|
|
2557
|
+
);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
1947
2560
|
/** Best-effort read of the VM's resolved app dev-server port so cloud:up's
|
|
1948
2561
|
* laptop `-L` forward, readiness probe, and previewOrigin seed all target the
|
|
1949
2562
|
* port the app actually serves on instead of a hardcoded 5173. Cats the VM's
|
|
1950
2563
|
* editor.json and editor.local.json in one SSH round-trip and resolves via
|
|
1951
|
-
* `resolveConfiguredAppPort`.
|
|
1952
|
-
*
|
|
1953
|
-
*
|
|
2564
|
+
* `resolveConfiguredAppPort`.
|
|
2565
|
+
*
|
|
2566
|
+
* Falls back to `VITE_PORT` on any failure — but fails LOUD, not silent. A
|
|
2567
|
+
* wrong port here is not a cosmetic default: it forwards the wrong `-L` port,
|
|
2568
|
+
* and if the preview ends up on a dedicated origin the viewer gets
|
|
2569
|
+
* "localhost refused to connect" with nothing pointing at the cause. So an SSH
|
|
2570
|
+
* failure or a VM with no scaffolded project warns on stderr naming that
|
|
2571
|
+
* symptom. A scaffolded project that simply declares no port is NOT a miss —
|
|
2572
|
+
* `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
|
|
2573
|
+
* on) and stays quiet. */
|
|
1954
2574
|
async function readVmAppPort(cfg) {
|
|
1955
2575
|
const repoName = resolveProject(cfg).name;
|
|
1956
|
-
const base = `~/projects/${repoName}/.codeyam`;
|
|
1957
2576
|
const read = runGcloud(
|
|
1958
2577
|
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 '{}'`,
|
|
2578
|
+
command: buildVmAppPortReadCommand(repoName),
|
|
1963
2579
|
}),
|
|
1964
2580
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1965
2581
|
);
|
|
1966
|
-
if (read.status !== 0 || typeof read.stdout !== "string")
|
|
2582
|
+
if (read.status !== 0 || typeof read.stdout !== "string") {
|
|
2583
|
+
console.error(
|
|
2584
|
+
`[cloud] WARNING: could not read the VM's app port over SSH — ` +
|
|
2585
|
+
`falling back to ${VITE_PORT}. If the app does not serve on ` +
|
|
2586
|
+
`${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
|
|
2587
|
+
`Live Preview will show "localhost refused to connect".`,
|
|
2588
|
+
);
|
|
2589
|
+
return VITE_PORT;
|
|
2590
|
+
}
|
|
2591
|
+
if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
|
|
2592
|
+
console.error(
|
|
2593
|
+
`[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
|
|
2594
|
+
`VM (looked for '${repoName}', then any project dir) — falling back to ` +
|
|
2595
|
+
`${VITE_PORT}. If the project is scaffolded under a different name and ` +
|
|
2596
|
+
`serves on another port, the port-forward targets the wrong port and ` +
|
|
2597
|
+
`the Live Preview will show "localhost refused to connect".`,
|
|
2598
|
+
);
|
|
2599
|
+
return VITE_PORT;
|
|
2600
|
+
}
|
|
1967
2601
|
const parse = (s) => {
|
|
1968
2602
|
try {
|
|
1969
2603
|
const v = JSON.parse(s);
|
|
@@ -2287,6 +2921,66 @@ async function seedAgentApiKey(cfg) {
|
|
|
2287
2921
|
// vm--no-cli-install-before-container-started removed; do not re-add it.
|
|
2288
2922
|
}
|
|
2289
2923
|
|
|
2924
|
+
/** Pure: deep-merge `patch` over the VM's current editor.local.json content and
|
|
2925
|
+
* return the base64 of the pretty-printed result, ready for
|
|
2926
|
+
* buildEditorLocalSeedWriteCommand. `currentRaw` is the raw `cat` output of the
|
|
2927
|
+
* remote file (may be undefined, empty, or corrupt) — anything that doesn't
|
|
2928
|
+
* parse to a plain object is treated as `{}`, because overwriting a corrupt
|
|
2929
|
+
* override file with a known-good one is safer than refusing to seed. Extracted
|
|
2930
|
+
* so seedGreenfieldProjectTitle stays a thin I/O passthrough and the
|
|
2931
|
+
* parse+merge+encode is unit-tested without a live VM. */
|
|
2932
|
+
function mergeEditorLocalPatchB64(currentRaw, patch) {
|
|
2933
|
+
let current = {};
|
|
2934
|
+
try {
|
|
2935
|
+
const parsed = JSON.parse(currentRaw);
|
|
2936
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
2937
|
+
current = parsed;
|
|
2938
|
+
}
|
|
2939
|
+
} catch {
|
|
2940
|
+
// Unparseable / missing remote content → start from {}.
|
|
2941
|
+
}
|
|
2942
|
+
const merged = deepMergeJson(current, patch);
|
|
2943
|
+
return Buffer.from(JSON.stringify(merged, null, 2), "utf8").toString("base64");
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
/** Seed a GREENFIELD project's `projectTitle` into the VM's editor.local.json
|
|
2947
|
+
* BEFORE compose up, so the editor caches the real project name at startup and
|
|
2948
|
+
* the fleet dashboard renders the correct card label with no false
|
|
2949
|
+
* project-dir-drift. Like seedAgentApiKey, this MUST run before
|
|
2950
|
+
* ensureEditorContainerUp — the editor reads its config once at startup.
|
|
2951
|
+
*
|
|
2952
|
+
* No-op for a clone (buildGreenfieldTitlePatch returns null): a repo Add labels
|
|
2953
|
+
* from its origin basename, so there is nothing to seed and no SSH round-trip
|
|
2954
|
+
* is spent. Runs on every cloud:up, so a reconfigure re-seeds the title,
|
|
2955
|
+
* converging a VM provisioned before this fix onto the correct identity without
|
|
2956
|
+
* a destroy. Thin I/O passthrough: the parse+merge+encode lives in the pure
|
|
2957
|
+
* mergeEditorLocalPatchB64 helper. */
|
|
2958
|
+
async function seedGreenfieldProjectTitle(cfg) {
|
|
2959
|
+
const patch = buildGreenfieldTitlePatch(cfg);
|
|
2960
|
+
if (!patch) return; // clone (or no greenfield name) — nothing to seed
|
|
2961
|
+
|
|
2962
|
+
const repoName = resolveProject(cfg).name;
|
|
2963
|
+
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
2964
|
+
console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
|
|
2965
|
+
|
|
2966
|
+
const read = runGcloud(
|
|
2967
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2968
|
+
command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
|
|
2969
|
+
}),
|
|
2970
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2971
|
+
);
|
|
2972
|
+
const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
|
|
2973
|
+
const write = runGcloud(
|
|
2974
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2975
|
+
command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
|
|
2976
|
+
}),
|
|
2977
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
2978
|
+
);
|
|
2979
|
+
if (write.status !== 0) {
|
|
2980
|
+
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2290
2984
|
/** Tri-state probe of the editor container's running state on the VM host.
|
|
2291
2985
|
* One ssh round-trip; returns `"running"`, `"not-running"`, or `"unknown"`.
|
|
2292
2986
|
*
|
|
@@ -2549,6 +3243,42 @@ function streamedScriptMergeBase(editorBranch) {
|
|
|
2549
3243
|
return mbResult.status === 0 ? mbResult.stdout.trim() : null;
|
|
2550
3244
|
}
|
|
2551
3245
|
|
|
3246
|
+
/** Fetch `editorBranch` from origin into a REAL remote-tracking ref
|
|
3247
|
+
* (`refs/remotes/origin/<branch>`) rather than only into `FETCH_HEAD`.
|
|
3248
|
+
*
|
|
3249
|
+
* Operator hosts are commonly shallow, single-branch clones that track only
|
|
3250
|
+
* `+refs/heads/main`, so `origin/<some-feature-branch>` is not a local ref and
|
|
3251
|
+
* every `git show origin/<branch>:<path>` against it fails. Fetching through an
|
|
3252
|
+
* explicit refspec creates the ref, which keeps the drift verdict and the bytes
|
|
3253
|
+
* actually streamed derived from the same object. Falls back to a plain fetch
|
|
3254
|
+
* (FETCH_HEAD only) when the refspec form is rejected — `readRemoteScriptBlob`
|
|
3255
|
+
* can still read through FETCH_HEAD in that case. */
|
|
3256
|
+
function fetchStreamedScriptRef(editorBranch) {
|
|
3257
|
+
const refspec = `${editorBranch}:refs/remotes/origin/${editorBranch}`;
|
|
3258
|
+
const withRef = spawnSync("git", ["fetch", "--force", "origin", refspec], { encoding: "utf8" });
|
|
3259
|
+
if (withRef.status === 0) return { ok: true, stderr: "" };
|
|
3260
|
+
|
|
3261
|
+
const plain = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
|
|
3262
|
+
if (plain.status === 0) return { ok: true, stderr: "" };
|
|
3263
|
+
return { ok: false, stderr: (plain.stderr || withRef.stderr || "").trim() };
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
/** Resolve one revision of a streamed script from the fetched remote, trying the
|
|
3267
|
+
* remote-tracking ref first and `FETCH_HEAD` second. `gitArg` is the git verb
|
|
3268
|
+
* (`rev-parse` for the blob hash, `show` for the bytes) so the drift facts and the
|
|
3269
|
+
* streamed bytes resolve through the SAME ref-preference order — a verdict of
|
|
3270
|
+
* "stream remote" can therefore never disagree with what actually got streamed.
|
|
3271
|
+
* Returns `{ ok: false }` only when the blob is unreadable through both refs. */
|
|
3272
|
+
function resolveRemoteScriptRev(gitArg, relPath, editorBranch) {
|
|
3273
|
+
for (const ref of [`origin/${editorBranch}`, "FETCH_HEAD"]) {
|
|
3274
|
+
const result = spawnSync("git", [gitArg, `${ref}:${relPath}`], { encoding: "utf8" });
|
|
3275
|
+
if (result.status === 0 && typeof result.stdout === "string") {
|
|
3276
|
+
return { ok: true, stdout: result.stdout, ref };
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
return { ok: false, stdout: "", ref: null };
|
|
3280
|
+
}
|
|
3281
|
+
|
|
2552
3282
|
/** Gather the drift facts for one streamed script relative to `origin/<editorBranch>`:
|
|
2553
3283
|
* its local (working-tree) blob hash, the remote-tip blob hash, the merge-base blob
|
|
2554
3284
|
* hash, and whether the working tree is dirty. Any git failure yields a null hash,
|
|
@@ -2560,11 +3290,8 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
|
|
|
2560
3290
|
const localHashResult = spawnSync("git", ["hash-object", relPath], { encoding: "utf8" });
|
|
2561
3291
|
const localHash = localHashResult.status === 0 ? localHashResult.stdout.trim() : null;
|
|
2562
3292
|
|
|
2563
|
-
|
|
2564
|
-
const
|
|
2565
|
-
if (remoteHashResult.status === 0) {
|
|
2566
|
-
remoteHash = remoteHashResult.stdout.trim();
|
|
2567
|
-
}
|
|
3293
|
+
const remoteRev = resolveRemoteScriptRev("rev-parse", relPath, editorBranch);
|
|
3294
|
+
const remoteHash = remoteRev.ok ? remoteRev.stdout.trim() : null;
|
|
2568
3295
|
|
|
2569
3296
|
let baseHash = null;
|
|
2570
3297
|
if (mergeBase) {
|
|
@@ -2587,7 +3314,15 @@ function gatherScriptDriftFacts(relPath, editorBranch, mergeBase) {
|
|
|
2587
3314
|
* un-pulled dashboard can't add VMs" failure. Streams the on-disk copy instead
|
|
2588
3315
|
* when the operator authored local content the remote tip lacks (dirty edit or a
|
|
2589
3316
|
* committed local-ahead change — developer mode) or when the remote is unreachable
|
|
2590
|
-
* (offline fallback
|
|
3317
|
+
* (offline fallback — `remoteHash === null`, the only sanctioned on-disk path).
|
|
3318
|
+
*
|
|
3319
|
+
* A "remote" verdict NEVER falls back to the on-disk copy. On a clean-but-behind
|
|
3320
|
+
* checkout the on-disk bytes are precisely the stale bytes the drift guard exists
|
|
3321
|
+
* to keep off the VM, so substituting them silently defeats the guard: the verdict
|
|
3322
|
+
* said "stream the reviewed remote tip" and the VM would instead receive the old
|
|
3323
|
+
* script, with only a `[Warning]` to show for it. When the remote blob cannot be
|
|
3324
|
+
* read through either ref this throws, aborting the add.
|
|
3325
|
+
* `absScriptPath` is an absolute path under the repo root. */
|
|
2591
3326
|
function readStreamedScript(absScriptPath, cfg = {}) {
|
|
2592
3327
|
const relPath = path.relative(rootDir, absScriptPath) || absScriptPath;
|
|
2593
3328
|
const editorBranch = streamedScriptEditorBranch(cfg);
|
|
@@ -2596,15 +3331,18 @@ function readStreamedScript(absScriptPath, cfg = {}) {
|
|
|
2596
3331
|
|
|
2597
3332
|
const { streamedScriptSource } = require("./script-drift");
|
|
2598
3333
|
if (streamedScriptSource(facts) === "remote") {
|
|
2599
|
-
const remote =
|
|
2600
|
-
if (remote.
|
|
2601
|
-
console.error(` streaming canonical ${relPath} from
|
|
3334
|
+
const remote = resolveRemoteScriptRev("show", relPath, editorBranch);
|
|
3335
|
+
if (remote.ok) {
|
|
3336
|
+
console.error(` streaming canonical ${relPath} from ${remote.ref} (checkout behind remote is fine)`);
|
|
2602
3337
|
return remote.stdout;
|
|
2603
3338
|
}
|
|
2604
|
-
//
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
3339
|
+
// The facts said "the remote tip is the canonical source" but its bytes are
|
|
3340
|
+
// unreadable. Streaming the on-disk copy here would provision unreviewed stale
|
|
3341
|
+
// bytes under the banner of a successful add — fail loud instead.
|
|
3342
|
+
throw new Error(
|
|
3343
|
+
`could not read ${relPath} from origin/${editorBranch} (or FETCH_HEAD); ` +
|
|
3344
|
+
`refusing to stream a possibly-stale on-disk copy. ` +
|
|
3345
|
+
`Fetch the branch first: git fetch origin ${editorBranch}:refs/remotes/origin/${editorBranch}`,
|
|
2608
3346
|
);
|
|
2609
3347
|
}
|
|
2610
3348
|
return fs.readFileSync(absScriptPath, "utf8");
|
|
@@ -2626,10 +3364,11 @@ function checkScriptDrift(cfg) {
|
|
|
2626
3364
|
|
|
2627
3365
|
console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
|
|
2628
3366
|
|
|
2629
|
-
// 1.
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
3367
|
+
// 1. Fetch into refs/remotes/origin/<editorBranch> so the ref resolves even on a
|
|
3368
|
+
// single-branch clone that tracks only +refs/heads/main.
|
|
3369
|
+
const fetchResult = fetchStreamedScriptRef(editorBranch);
|
|
3370
|
+
if (!fetchResult.ok) {
|
|
3371
|
+
console.error(` [Warning] Failed to fetch origin ${editorBranch} (${fetchResult.stderr || "unknown error"}). Skipping drift check.`);
|
|
2633
3372
|
return;
|
|
2634
3373
|
}
|
|
2635
3374
|
|
|
@@ -2742,6 +3481,29 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
|
|
|
2742
3481
|
});
|
|
2743
3482
|
}
|
|
2744
3483
|
|
|
3484
|
+
/** Grant-only pass: (re-)apply one VM's default IAP access grant by spawning
|
|
3485
|
+
* `scripts/gcp/vm-ingress.sh <N> grant-access` — the churn-free action that runs
|
|
3486
|
+
* `apply_default_iap_access` without touching any NEG/BES/host-rule resource.
|
|
3487
|
+
* Reconcile uses this for already-`wired` VMs its re-wire loop skips, so a healed
|
|
3488
|
+
* VM whose backends already exist still (re-)grants the automation SA the IAP
|
|
3489
|
+
* accessor. Returns the spawnSync result so the caller logs granted vs failed;
|
|
3490
|
+
* best-effort + loud, never fatal. */
|
|
3491
|
+
function runVmIngressGrantAccess(cfg, vmNumber, domain, slug) {
|
|
3492
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3493
|
+
const inv = vmIngressGrantAccessInvocation({
|
|
3494
|
+
scriptPath,
|
|
3495
|
+
vmNumber,
|
|
3496
|
+
domain,
|
|
3497
|
+
project: cfg.project,
|
|
3498
|
+
zone: cfg.zone,
|
|
3499
|
+
slug,
|
|
3500
|
+
});
|
|
3501
|
+
return spawnSync(inv.command, inv.args, {
|
|
3502
|
+
stdio: "inherit",
|
|
3503
|
+
env: { ...process.env, ...inv.env },
|
|
3504
|
+
});
|
|
3505
|
+
}
|
|
3506
|
+
|
|
2745
3507
|
/** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
|
|
2746
3508
|
* Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
|
|
2747
3509
|
* (the VM is reachable operator-side over SSH regardless), so on failure it
|
|
@@ -3285,6 +4047,10 @@ async function cmdUp(args, projectDir = rootDir) {
|
|
|
3285
4047
|
}
|
|
3286
4048
|
}
|
|
3287
4049
|
await seedAgentApiKey(cfg);
|
|
4050
|
+
// Greenfield only (no-op for a clone): seed projectTitle so the editor
|
|
4051
|
+
// resolves its identity to the real project name instead of the bare
|
|
4052
|
+
// /workspace mount basename — the source of the false project-dir-drift.
|
|
4053
|
+
await seedGreenfieldProjectTitle(cfg);
|
|
3288
4054
|
await ensureEditorContainerUp(cfg);
|
|
3289
4055
|
// Provision contract: a just-provisioned VM must actually reach GitHub.
|
|
3290
4056
|
// Verify in-container git auth now that the container (and its credential
|
|
@@ -3639,17 +4405,40 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3639
4405
|
zone: cfg.zone,
|
|
3640
4406
|
slug,
|
|
3641
4407
|
});
|
|
4408
|
+
// Bound the per-VM status probe: a hung `bash vm-ingress.sh <N> status`
|
|
4409
|
+
// (its gcloud describes stalling on an IAP/metadata hiccup) must not stall the
|
|
4410
|
+
// whole sweep. On timeout res.stdout is empty, so parseIngressStatusVerdict
|
|
4411
|
+
// returns null → an `unknown` verdict → selectIngressReconcileTargets SKIPS
|
|
4412
|
+
// this VM and the sweep continues to the next one.
|
|
3642
4413
|
const res = spawnSync(inv.command, inv.args, {
|
|
3643
4414
|
env: { ...process.env, ...inv.env },
|
|
3644
4415
|
encoding: "utf8",
|
|
4416
|
+
timeout: GCLOUD_CALL_TIMEOUT_MS,
|
|
3645
4417
|
});
|
|
3646
4418
|
return { vmNumber: n, slug, verdict: parseIngressStatusVerdict(res.stdout || "") };
|
|
3647
4419
|
});
|
|
3648
4420
|
const targets = selectIngressReconcileTargets(statuses);
|
|
3649
4421
|
const targetNums = new Set(targets.map((t) => t.vmNumber));
|
|
4422
|
+
// The complement of the re-wire loop: `wired` VMs whose backends already exist
|
|
4423
|
+
// so the re-wire loop skips them, yet still need the SA-accessor (re-)granted —
|
|
4424
|
+
// `up` is the only path that grants it, and it never runs for a wired VM. This
|
|
4425
|
+
// closes the VM-8 heal-path gap (a wired VM whose backends predate the SA-grant
|
|
4426
|
+
// fix would otherwise stay 403 forever). Disjoint from `targets` by construction.
|
|
4427
|
+
const grantOnlyNums = new Set(
|
|
4428
|
+
selectIngressGrantOnlyTargets(statuses).map((g) => g.vmNumber),
|
|
4429
|
+
);
|
|
3650
4430
|
for (const s of statuses) {
|
|
3651
|
-
if (
|
|
3652
|
-
|
|
4431
|
+
if (targetNums.has(s.vmNumber)) continue;
|
|
4432
|
+
const label = s.slug || `vm-${s.vmNumber}`;
|
|
4433
|
+
if (grantOnlyNums.has(s.vmNumber)) {
|
|
4434
|
+
// Churn-free grant-only pass: (re-)grant the accessor without re-wiring any
|
|
4435
|
+
// NEG/BES/host-rule resource. Idempotent (a no-op when the SA is already
|
|
4436
|
+
// bound) and best-effort-but-loud — a grant hiccup logs, never aborts.
|
|
4437
|
+
const gres = runVmIngressGrantAccess(cfg, s.vmNumber, domain, s.slug);
|
|
4438
|
+
console.error(
|
|
4439
|
+
` VM-${s.vmNumber} ${label}: wired → ok (accessor ${gres.status === 0 ? "granted" : "grant FAILED"})`,
|
|
4440
|
+
);
|
|
4441
|
+
} else {
|
|
3653
4442
|
console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
|
|
3654
4443
|
}
|
|
3655
4444
|
}
|
|
@@ -3679,7 +4468,13 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3679
4468
|
for (const surface of gi.SURFACES) {
|
|
3680
4469
|
const bes = gi.backendServiceName(n, surface);
|
|
3681
4470
|
const inv = backendServiceTimeoutInvocation({ bes, project: cfg.project });
|
|
3682
|
-
|
|
4471
|
+
// Bound the per-BES converge too: a hung `gcloud backend-services update`
|
|
4472
|
+
// returns a non-zero (timed-out) result, so it falls into the skip branch
|
|
4473
|
+
// below and the sweep moves on to the next BES rather than stalling.
|
|
4474
|
+
const res = spawnSync(inv.command, inv.args, {
|
|
4475
|
+
encoding: "utf8",
|
|
4476
|
+
timeout: GCLOUD_CALL_TIMEOUT_MS,
|
|
4477
|
+
});
|
|
3683
4478
|
if (res.status === 0) {
|
|
3684
4479
|
console.error(` ${bes}: timeout → 86400`);
|
|
3685
4480
|
} else {
|
|
@@ -3689,6 +4484,12 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3689
4484
|
}
|
|
3690
4485
|
}
|
|
3691
4486
|
|
|
4487
|
+
// Converge the shared fleet firewall, then surface any app port it still fails to
|
|
4488
|
+
// admit. Both ride this same boot + periodic cadence so the running fleet
|
|
4489
|
+
// self-heals without a hand-run bootstrap.
|
|
4490
|
+
const firewallState = convergeFleetFirewall({ cfg });
|
|
4491
|
+
await reportFirewallPortGaps({ cfg, roster, firewallState });
|
|
4492
|
+
|
|
3692
4493
|
// Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): any
|
|
3693
4494
|
// backend service whose VM number is gone from the roster is a destroyed VM's
|
|
3694
4495
|
// leftover — cleaned here automatically with the verified `down`, so the
|
|
@@ -3697,22 +4498,131 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
|
3697
4498
|
sweepOrphanIngress({ cfg, domain, roster, projectDir });
|
|
3698
4499
|
}
|
|
3699
4500
|
|
|
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
|
-
*
|
|
4501
|
+
/** Auto-clean orphaned LB ingress (vm--auto-clean-orphaned-lb-ingress): LB
|
|
4502
|
+
* resources whose owning VM number is gone from the roster (a destroyed VM) and
|
|
4503
|
+
* has no in-flight add. Enumerates the live backend services AND zonal NEGs,
|
|
4504
|
+
* UNIONs their owning VM numbers (so a partial teardown whose NEG outlived its
|
|
4505
|
+
* backend service — the VM-5/6 case — still surfaces its VM as a candidate),
|
|
4506
|
+
* maps each name back to its VM number, subtracts the roster + in-flight adds +
|
|
4507
|
+
* live instances (the false-orphan guards), and runs the VERIFIED
|
|
4508
|
+
* `vm-ingress.sh <N> down` (asserts neg=no bes=no host=no, with retries) for
|
|
4509
|
+
* each orphan — so an orphan is genuinely cleared or surfaced as a real failure,
|
|
4510
|
+
* never a silent leak and never a manual command. Emits a machine-readable
|
|
4511
|
+
* `CY_ORPHAN_SWEEP <json>` summary line the dashboard parses to reconcile its
|
|
4512
|
+
* orphan-attention banner; the summary carries `stillPresent` (VM numbers that
|
|
4513
|
+
* still own ≥1 LB resource after the sweep) so the dashboard can clear a stale
|
|
4514
|
+
* banner for a VM that is now back in the roster or has no residual resources.
|
|
4515
|
+
* Best-effort per VM: one `down` failing never aborts the rest of the sweep.
|
|
4516
|
+
* Fail-safe: if the live-instance list can't be fetched, teardown is skipped for
|
|
4517
|
+
* this pass (a live-but-unrostered VM must never be torn down) while the banner
|
|
4518
|
+
* reconcile still runs. Returns the `{ cleaned, failed, skipped, stillPresent }`
|
|
4519
|
+
* summary (null when the backend-service enumeration itself failed) — the same
|
|
4520
|
+
* shape it prints — so the flow is directly assertable. `deps` is injectable so
|
|
4521
|
+
* the enumerate → select → down flow is testable without gcloud. */
|
|
4522
|
+
/** Converge the shared fleet firewall rule to all-tcp so the LB reaches whatever
|
|
4523
|
+
* port a project's app actually serves.
|
|
4524
|
+
*
|
|
4525
|
+
* A rule pinned to a hardcoded port list (`tcp:14199,tcp:5173,tcp:3000`) drops
|
|
4526
|
+
* LB→VM traffic for any VM serving outside it — the VM-6-on-8080 Live Preview
|
|
4527
|
+
* outage — while the preview backend service still reads HEALTHY, because its
|
|
4528
|
+
* health check runs on the always-admitted editor port. The failure is therefore
|
|
4529
|
+
* invisible until someone opens the preview. Converging here (not only in the
|
|
4530
|
+
* hand-run google-ingress.sh bootstrap) is what makes a drifted rule self-heal on
|
|
4531
|
+
* the running fleet.
|
|
4532
|
+
*
|
|
4533
|
+
* All-tcp is the right exposure, not a per-stack port list: the rule stays scoped
|
|
4534
|
+
* to Google's LB + health-check source ranges and the fleet instance tag either
|
|
4535
|
+
* way, so it admits any app port (Vite 5173, Next 3000, Astro 4321, Go 8080, …)
|
|
4536
|
+
* without enumeration. EVERY candidate name in FLEET_FIREWALL_RULE_NAMES is
|
|
4537
|
+
* converged: describing only one silently no-ops on a fleet wired under the other.
|
|
4538
|
+
*
|
|
4539
|
+
* Scope-safe by construction — `update --rules=tcp` rewrites only the allowed
|
|
4540
|
+
* protocols/ports, never a rule's source ranges or target tags, so this can never
|
|
4541
|
+
* change who the rule applies to. Best-effort throughout: a describe/update
|
|
4542
|
+
* failure logs and continues, never aborting the rest of the reconcile.
|
|
4543
|
+
*
|
|
4544
|
+
* Returns `[{ ruleName, rules, converged }]` for every rule that exists — `rules`
|
|
4545
|
+
* is the post-converge allowed value — so the flow is assertable without gcloud.
|
|
4546
|
+
* `deps.spawnSync` is injectable for the same reason.
|
|
4547
|
+
* See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
|
|
4548
|
+
function convergeFleetFirewall({ cfg } = {}, deps = {}) {
|
|
4549
|
+
const run = deps.spawnSync || spawnSync;
|
|
4550
|
+
const project = cfg && cfg.project;
|
|
4551
|
+
const state = [];
|
|
4552
|
+
for (const ruleName of FLEET_FIREWALL_RULE_NAMES) {
|
|
4553
|
+
const dinv = fleetFirewallDescribeInvocation({ ruleName, project });
|
|
4554
|
+
const dres = run(dinv.command, dinv.args, { encoding: "utf8" });
|
|
4555
|
+
// A non-zero describe means no rule under this name — expected, not an error:
|
|
4556
|
+
// only one of the candidate names is deployed on any given fleet.
|
|
4557
|
+
if (!dres || dres.status !== 0) continue;
|
|
4558
|
+
const current = ((dres.stdout || "") + "").trim();
|
|
4559
|
+
if (!firewallNeedsConverge(current)) {
|
|
4560
|
+
console.error(` firewall ${ruleName}: already all-tcp → ok`);
|
|
4561
|
+
state.push({ ruleName, rules: "tcp", converged: false });
|
|
4562
|
+
continue;
|
|
4563
|
+
}
|
|
4564
|
+
const cinv = fleetFirewallConvergeInvocation({ ruleName, project });
|
|
4565
|
+
const cres = run(cinv.command, cinv.args, { encoding: "utf8" });
|
|
4566
|
+
if (cres && cres.status === 0) {
|
|
4567
|
+
console.error(` firewall ${ruleName}: rules '${current}' → tcp (all-tcp)`);
|
|
4568
|
+
state.push({ ruleName, rules: "tcp", converged: true });
|
|
4569
|
+
} else {
|
|
4570
|
+
const tail = (
|
|
4571
|
+
(cres && (cres.stderr || (cres.error && cres.error.message))) || ""
|
|
4572
|
+
).toString().trim().split("\n").pop() || "error";
|
|
4573
|
+
console.error(` firewall ${ruleName}: converge skipped (${tail})`);
|
|
4574
|
+
state.push({ ruleName, rules: current, converged: false });
|
|
4575
|
+
}
|
|
4576
|
+
}
|
|
4577
|
+
if (!state.length) {
|
|
4578
|
+
console.error(
|
|
4579
|
+
` firewall: no rule found under ${FLEET_FIREWALL_RULE_NAMES.join(" / ")} —` +
|
|
4580
|
+
" LB→VM traffic may be blocked; run scripts/gcp/google-ingress.sh",
|
|
4581
|
+
);
|
|
4582
|
+
}
|
|
4583
|
+
return state;
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4586
|
+
/** Surface any rostered VM whose app port the fleet firewall does not admit — the
|
|
4587
|
+
* tripwire for a masked "HEALTHY but unreachable" preview.
|
|
4588
|
+
*
|
|
4589
|
+
* The preview backend service health-checks the fixed editor port, so a rule that
|
|
4590
|
+
* admits that port but not the app's real one reads HEALTHY while every real
|
|
4591
|
+
* request is dropped. That masking is what made the VM-6 outage invisible. Emits a
|
|
4592
|
+
* machine-readable `CY_FIREWALL_PORT_GAP <json>` line per gap, mirroring the
|
|
4593
|
+
* `CY_ORPHAN_SWEEP` marker convention the dashboard already parses.
|
|
4594
|
+
*
|
|
4595
|
+
* Only runs when a rule is still port-pinned: a converged all-tcp rule admits every
|
|
4596
|
+
* port, so the healthy path cannot produce a gap and skipping it avoids an SSH
|
|
4597
|
+
* round-trip per rostered VM for a check that cannot fail. In practice this fires
|
|
4598
|
+
* only if the converge itself failed (e.g. a permissions regression).
|
|
4599
|
+
*
|
|
4600
|
+
* Returns the emitted `[{ vmNumber, appPort, rule, allowed }]` gaps — the same
|
|
4601
|
+
* shape it prints — so the marker contract is directly assertable.
|
|
4602
|
+
* `deps.readVmAppPort` is injectable so tests never reach for SSH. */
|
|
4603
|
+
async function reportFirewallPortGaps({ cfg, roster, firewallState } = {}, deps = {}) {
|
|
4604
|
+
const readPort = deps.readVmAppPort || readVmAppPort;
|
|
4605
|
+
const pinned = (firewallState || []).filter((f) => firewallNeedsConverge(f.rules));
|
|
4606
|
+
const gaps = [];
|
|
4607
|
+
if (!pinned.length) return gaps;
|
|
4608
|
+
for (const n of roster || []) {
|
|
4609
|
+
const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
|
|
4610
|
+
const appPort = await readPort(perVmCfg);
|
|
4611
|
+
for (const f of pinned) {
|
|
4612
|
+
if (firewallAdmitsPort(f.rules, appPort)) continue;
|
|
4613
|
+
const gap = { vmNumber: n, appPort, rule: f.ruleName, allowed: f.rules };
|
|
4614
|
+
console.error(` VM-${n}: app port ${appPort} NOT admitted by ${f.ruleName}`);
|
|
4615
|
+
console.log(`CY_FIREWALL_PORT_GAP ${JSON.stringify(gap)}`);
|
|
4616
|
+
gaps.push(gap);
|
|
4617
|
+
}
|
|
4618
|
+
}
|
|
4619
|
+
return gaps;
|
|
4620
|
+
}
|
|
4621
|
+
|
|
3713
4622
|
function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {}, deps = {}) {
|
|
3714
4623
|
const gi = deps.googleIngress || require("./google-ingress.js");
|
|
3715
4624
|
const fcl = deps.fleetCleanup || require("./fleet-cleanup.js");
|
|
4625
|
+
const frecon = deps.fleetReconcile || require("./fleet-reconcile.js");
|
|
3716
4626
|
const run = deps.spawnSync || spawnSync;
|
|
3717
4627
|
|
|
3718
4628
|
const listInv = backendServicesListInvocation({ project: cfg && cfg.project });
|
|
@@ -3724,11 +4634,31 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3724
4634
|
console.error(` orphan sweep skipped — could not list backend services (${tail}).`);
|
|
3725
4635
|
return null;
|
|
3726
4636
|
}
|
|
3727
|
-
const
|
|
4637
|
+
const besVmNumbers = String(listRes.stdout || "")
|
|
3728
4638
|
.split("\n")
|
|
3729
4639
|
.map((line) => gi.vmNumberFromResourceName(line.trim()))
|
|
3730
4640
|
.filter((n) => n != null);
|
|
3731
4641
|
|
|
4642
|
+
// Also enumerate zonal NEGs and union their owners in: a partial teardown can
|
|
4643
|
+
// leave a `vm-<N>-<surface>-neg` behind after its backend service is already
|
|
4644
|
+
// gone, so a backend-service-only candidate set misses that residue. Best-effort
|
|
4645
|
+
// — a NEG-list failure never aborts the backend-service-driven sweep.
|
|
4646
|
+
let negVmNumbers = [];
|
|
4647
|
+
const negInv = negListInvocation({ project: cfg && cfg.project });
|
|
4648
|
+
const negRes = run(negInv.command, negInv.args, { encoding: "utf8" });
|
|
4649
|
+
if (negRes && negRes.status === 0) {
|
|
4650
|
+
negVmNumbers = String(negRes.stdout || "")
|
|
4651
|
+
.split("\n")
|
|
4652
|
+
.map((line) => gi.vmNumberFromResourceName(line.trim()))
|
|
4653
|
+
.filter((n) => n != null);
|
|
4654
|
+
} else {
|
|
4655
|
+
const tail = (
|
|
4656
|
+
(negRes && (negRes.stderr || (negRes.error && negRes.error.message))) || ""
|
|
4657
|
+
).toString().trim().split("\n").pop() || "error";
|
|
4658
|
+
console.error(` orphan sweep: could not list NEGs (${tail}) — sweeping backend services only.`);
|
|
4659
|
+
}
|
|
4660
|
+
const ingressVmNumbers = [...new Set([...besVmNumbers, ...negVmNumbers])].sort((a, b) => a - b);
|
|
4661
|
+
|
|
3732
4662
|
// A VM in the roster, or with an in-flight `add-<n>.json` sidecar (provisioning,
|
|
3733
4663
|
// not yet rostered), is live — never an orphan.
|
|
3734
4664
|
const pendingDir = process.env.PENDING_JOBS_DIR || path.join(projectDir, ".codeyam/logs/pending-jobs");
|
|
@@ -3736,9 +4666,34 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3736
4666
|
.listPendingJobs(pendingDir)
|
|
3737
4667
|
.filter((j) => j.action === "add")
|
|
3738
4668
|
.map((j) => j.n);
|
|
3739
|
-
const orphans = selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds });
|
|
3740
4669
|
|
|
3741
|
-
|
|
4670
|
+
// Defensive live-instance guard: never tear down a VM whose GCP instance is
|
|
4671
|
+
// actually running (a roster drift could leave a live VM unrostered; a teardown
|
|
4672
|
+
// that SUCCEEDED against it would knock it offline). `liveVmNumbers === null`
|
|
4673
|
+
// means the list couldn't be fetched — fail safe by skipping teardown this pass
|
|
4674
|
+
// (below) rather than risk a false teardown. The banner reconcile still runs.
|
|
4675
|
+
let liveVmNumbers = null;
|
|
4676
|
+
const instInv = instancesListInvocation({ project: cfg && cfg.project });
|
|
4677
|
+
const instRes = run(instInv.command, instInv.args, { encoding: "utf8" });
|
|
4678
|
+
if (instRes && instRes.status === 0) {
|
|
4679
|
+
liveVmNumbers = frecon.parseRunningFleetVms(instRes.stdout, fleetInstancePrefix());
|
|
4680
|
+
} else {
|
|
4681
|
+
const tail = (
|
|
4682
|
+
(instRes && (instRes.stderr || (instRes.error && instRes.error.message))) || ""
|
|
4683
|
+
).toString().trim().split("\n").pop() || "error";
|
|
4684
|
+
console.error(` orphan sweep: could not list instances (${tail}) — skipping teardown this pass (banners still reconciled).`);
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
const orphans = liveVmNumbers === null
|
|
4688
|
+
? []
|
|
4689
|
+
: selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers });
|
|
4690
|
+
|
|
4691
|
+
// stillPresent = every VM that owned an LB resource going in, minus the ones we
|
|
4692
|
+
// verifiably clean below. The dashboard clears a banner for any orphan VM not in
|
|
4693
|
+
// this set (or now back in the roster). Computed up front so the no-orphans and
|
|
4694
|
+
// fail-safe-skip paths report it too — that is what unfreezes a VM-3-style banner
|
|
4695
|
+
// (re-rostered, so never a sweep target) with no teardown at all.
|
|
4696
|
+
const summary = { cleaned: [], failed: [], skipped: inFlightAdds.slice(), stillPresent: ingressVmNumbers.slice() };
|
|
3742
4697
|
if (!orphans.length) {
|
|
3743
4698
|
console.error(" orphan sweep: no orphaned LB ingress found.");
|
|
3744
4699
|
console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
|
|
@@ -3764,7 +4719,14 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3764
4719
|
});
|
|
3765
4720
|
const res = run(inv.command, inv.args, { encoding: "utf8", env: { ...process.env, ...inv.env } });
|
|
3766
4721
|
const verdict = parseIngressTeardownVerdict((res && res.stdout) || "");
|
|
3767
|
-
|
|
4722
|
+
// Pass `signal` too: a teardown the caller's group-kill reaped has a NULL
|
|
4723
|
+
// status, which the classifier would otherwise report as `spawn-failed`
|
|
4724
|
+
// rather than the truthful `killed=SIGKILL`.
|
|
4725
|
+
const outcome = classifyOrphanDownResult({
|
|
4726
|
+
verdict,
|
|
4727
|
+
status: res ? res.status : null,
|
|
4728
|
+
signal: res ? res.signal : null,
|
|
4729
|
+
});
|
|
3768
4730
|
if (outcome.cleaned) {
|
|
3769
4731
|
summary.cleaned.push(n);
|
|
3770
4732
|
console.error(` VM-${n}: orphaned ingress → auto-cleaned (verified clean)`);
|
|
@@ -3775,6 +4737,11 @@ function sweepOrphanIngress({ cfg, domain, roster, projectDir = rootDir } = {},
|
|
|
3775
4737
|
console.error(` VM-${n}: orphaned ingress → auto-clean FAILED (${tail.slice(-200)})`);
|
|
3776
4738
|
}
|
|
3777
4739
|
}
|
|
4740
|
+
// A verifiably-cleaned VM no longer owns any LB resource — drop it from
|
|
4741
|
+
// stillPresent so the dashboard clears its banner (failed VMs stay, keeping
|
|
4742
|
+
// their real attention item).
|
|
4743
|
+
const cleanedSet = new Set(summary.cleaned);
|
|
4744
|
+
summary.stillPresent = ingressVmNumbers.filter((n) => !cleanedSet.has(n));
|
|
3778
4745
|
console.log(`CY_ORPHAN_SWEEP ${JSON.stringify(summary)}`);
|
|
3779
4746
|
return summary;
|
|
3780
4747
|
}
|
|
@@ -3828,20 +4795,33 @@ async function probeGitCredentials(cfg) {
|
|
|
3828
4795
|
/** Seed the LIVE in-memory git credential-cache daemon over its socket — the
|
|
3829
4796
|
* same `git credential approve` shape the entrypoint uses
|
|
3830
4797
|
* (scripts/cloud-editor-entrypoint.sh) — so push works immediately with no
|
|
3831
|
-
* container restart.
|
|
3832
|
-
* the
|
|
3833
|
-
*
|
|
4798
|
+
* container restart. ALSO writes the container-local on-disk `store` fallback
|
|
4799
|
+
* file (the fill-only second helper baked into the image) directly, so a
|
|
4800
|
+
* reconcile leaves the VM durable against the cache daemon dying mid-life, not
|
|
4801
|
+
* just healthy for the moment. base64 keeps the token out of the shell history
|
|
4802
|
+
* across the gcloud-ssh → docker layers, and the store write is an in-place
|
|
4803
|
+
* `cat > file` truncate (chmod 600), never touched by git's approve path.
|
|
4804
|
+
* Throws if either write fails. Shared by {@link cmdReconcileCredentials} and
|
|
4805
|
+
* {@link assertGitPushCredentials}. */
|
|
3834
4806
|
async function seedLiveCredentialCache(cfg, token) {
|
|
3835
4807
|
const repoName = resolveProject(cfg).name;
|
|
3836
4808
|
const projectCd = `cd ~/projects/${repoName}`;
|
|
3837
4809
|
const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
|
|
3838
4810
|
const socket = "/run/codeyam/git-credential-cache.socket";
|
|
4811
|
+
const store = "/run/codeyam/git-credentials-store";
|
|
3839
4812
|
const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
|
|
3840
4813
|
const b64 = Buffer.from(approveStdin).toString("base64");
|
|
4814
|
+
// The store-file line is the same one-URL-per-line format as the host staging
|
|
4815
|
+
// file; the image's fill-only `store` helper reads it on a cache miss.
|
|
4816
|
+
const storeB64 = Buffer.from(`${githubCredentialLine(token)}\n`).toString("base64");
|
|
3841
4817
|
const seedCmd =
|
|
3842
|
-
`${projectCd} &&
|
|
4818
|
+
`${projectCd} && ` +
|
|
4819
|
+
`echo ${b64} | base64 -d | ${composeExec} ` +
|
|
3843
4820
|
`git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
|
|
3844
|
-
`credential approve &&
|
|
4821
|
+
`credential approve && ` +
|
|
4822
|
+
`echo ${storeB64} | base64 -d | ${composeExec} ` +
|
|
4823
|
+
`bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
|
|
4824
|
+
`echo APPROVED`;
|
|
3845
4825
|
const seed = runGcloud(
|
|
3846
4826
|
buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
|
|
3847
4827
|
{ stdio: ["ignore", "pipe", "inherit"] },
|
|
@@ -3880,6 +4860,39 @@ async function cmdReconcileCredentials(args, projectDir = rootDir) {
|
|
|
3880
4860
|
);
|
|
3881
4861
|
}
|
|
3882
4862
|
|
|
4863
|
+
/** `cloud:reconcile-admin` — flip admin mode on/off for an already-running VM
|
|
4864
|
+
* WITHOUT a full re-provision. Resolves the desired value from this invocation
|
|
4865
|
+
* (`--admin` / `CODEYAM_ADMIN`, else off), rewrites the rendered compose's
|
|
4866
|
+
* CODEYAM_ADMIN line, and recreates the editor service — matching what a manual
|
|
4867
|
+
* compose edit + `docker compose up -d editor` does today, so the durable admin
|
|
4868
|
+
* setting no longer requires a full re-provision to change. */
|
|
4869
|
+
async function cmdReconcileAdmin(args, projectDir = rootDir) {
|
|
4870
|
+
ensureGcloud();
|
|
4871
|
+
const flags = parseFlags(args);
|
|
4872
|
+
const cfg = loadCloudConfig(projectDir);
|
|
4873
|
+
if (!cfg) {
|
|
4874
|
+
throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
4875
|
+
}
|
|
4876
|
+
const desired =
|
|
4877
|
+
buildProvisionEnv(process.env, flags).CODEYAM_ADMIN === "1" ? "1" : "";
|
|
4878
|
+
const repoName = resolveProject(cfg).name;
|
|
4879
|
+
console.error(
|
|
4880
|
+
`Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
|
|
4881
|
+
);
|
|
4882
|
+
const result = runGcloud(
|
|
4883
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
4884
|
+
command: buildReconcileAdminCommand(repoName, desired),
|
|
4885
|
+
}),
|
|
4886
|
+
{ stdio: "inherit" },
|
|
4887
|
+
);
|
|
4888
|
+
if (result.status !== 0) {
|
|
4889
|
+
throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
|
|
4890
|
+
}
|
|
4891
|
+
console.error(
|
|
4892
|
+
` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
|
|
4893
|
+
);
|
|
4894
|
+
}
|
|
4895
|
+
|
|
3883
4896
|
/** Provision-time guarantee that a just-provisioned VM can actually reach
|
|
3884
4897
|
* GitHub. Probes the staging file + in-container `git ls-remote origin` and
|
|
3885
4898
|
* routes the outcome through the SAME {@link decideCredentialHeal} classifier
|
|
@@ -4011,6 +5024,7 @@ const HANDLERS = {
|
|
|
4011
5024
|
"reconcile-ingress": cmdReconcileIngress,
|
|
4012
5025
|
"wire-add-tail": cmdWireAddTail,
|
|
4013
5026
|
"reconcile-credentials": cmdReconcileCredentials,
|
|
5027
|
+
"reconcile-admin": cmdReconcileAdmin,
|
|
4014
5028
|
access: cmdAccess,
|
|
4015
5029
|
};
|
|
4016
5030
|
|
|
@@ -4033,6 +5047,7 @@ async function cloudMain() {
|
|
|
4033
5047
|
|
|
4034
5048
|
module.exports = {
|
|
4035
5049
|
// pure helpers
|
|
5050
|
+
isSpawnTimeout,
|
|
4036
5051
|
agentEnvVarName,
|
|
4037
5052
|
agentNpmPackage,
|
|
4038
5053
|
AGENT_ENV_VARS,
|
|
@@ -4048,6 +5063,7 @@ module.exports = {
|
|
|
4048
5063
|
KNOWN_FLAGS,
|
|
4049
5064
|
FIREWALL_RULE_NAME,
|
|
4050
5065
|
INSTANCE_TAG,
|
|
5066
|
+
DEFAULT_FLEET_MACHINE_TYPE,
|
|
4051
5067
|
EDITOR_PORT,
|
|
4052
5068
|
VITE_PORT,
|
|
4053
5069
|
parseFlags,
|
|
@@ -4066,6 +5082,7 @@ module.exports = {
|
|
|
4066
5082
|
previewSameOriginSafeFromStack,
|
|
4067
5083
|
isRottedManagedPreviewOrigin,
|
|
4068
5084
|
isWrongModePreviewOrigin,
|
|
5085
|
+
isSilentlyBrokenPreview,
|
|
4069
5086
|
resolveEditorConfig,
|
|
4070
5087
|
validateEditorVariant,
|
|
4071
5088
|
assertVariantProvisionable,
|
|
@@ -4074,13 +5091,19 @@ module.exports = {
|
|
|
4074
5091
|
resolveGhcrToken,
|
|
4075
5092
|
resolveGhcrPushToken,
|
|
4076
5093
|
probeUsableEditorImage,
|
|
5094
|
+
httpsManifestGet,
|
|
4077
5095
|
resolveProject,
|
|
4078
5096
|
clientProjectBootstrapArgs,
|
|
4079
5097
|
cloudConfigPath,
|
|
4080
5098
|
loadCloudConfig,
|
|
4081
5099
|
saveCloudConfig,
|
|
4082
5100
|
loadOrInitCloudConfig,
|
|
5101
|
+
buildProvisionEnv,
|
|
5102
|
+
buildReconcileAdminCommand,
|
|
5103
|
+
cmdReconcileAdmin,
|
|
4083
5104
|
buildEditorLocalEnvPatch,
|
|
5105
|
+
buildGreenfieldTitlePatch,
|
|
5106
|
+
mergeEditorLocalPatchB64,
|
|
4084
5107
|
buildEditorLocalSeedWriteCommand,
|
|
4085
5108
|
seedPreviewOrigin,
|
|
4086
5109
|
healPreviewOriginAtReady,
|
|
@@ -4094,6 +5117,22 @@ module.exports = {
|
|
|
4094
5117
|
decideBootstrapOutcome,
|
|
4095
5118
|
projectBootstrapDoneMarker,
|
|
4096
5119
|
projectBootstrapDoneProbeCommand,
|
|
5120
|
+
projectBootstrapScriptPath,
|
|
5121
|
+
projectBootstrapRunnerPath,
|
|
5122
|
+
projectBootstrapLogPath,
|
|
5123
|
+
projectBootstrapPidPath,
|
|
5124
|
+
projectBootstrapStatusPath,
|
|
5125
|
+
projectBootstrapRunnerScript,
|
|
5126
|
+
projectBootstrapLaunchCommand,
|
|
5127
|
+
projectBootstrapFollowCommand,
|
|
5128
|
+
projectBootstrapStatusCommand,
|
|
5129
|
+
parseProjectBootstrapStatus,
|
|
5130
|
+
decideProjectBootstrapOutcome,
|
|
5131
|
+
decideProjectBootstrapFollowAction,
|
|
5132
|
+
projectBootstrapEnv,
|
|
5133
|
+
PROJECT_BOOTSTRAP_LAUNCHED_MARKER,
|
|
5134
|
+
PROJECT_BOOTSTRAP_FOLLOW_SECS,
|
|
5135
|
+
ensureProjectBootstrapped,
|
|
4097
5136
|
SSH_KEEPALIVE_FLAGS,
|
|
4098
5137
|
resolveSetupForwardEnv,
|
|
4099
5138
|
FORWARD_ENV_KEYS_ENV,
|
|
@@ -4108,6 +5147,8 @@ module.exports = {
|
|
|
4108
5147
|
checkScriptDrift,
|
|
4109
5148
|
streamedScriptEditorBranch,
|
|
4110
5149
|
streamedScriptMergeBase,
|
|
5150
|
+
fetchStreamedScriptRef,
|
|
5151
|
+
resolveRemoteScriptRev,
|
|
4111
5152
|
gatherScriptDriftFacts,
|
|
4112
5153
|
readStreamedScript,
|
|
4113
5154
|
STREAMED_SCRIPTS,
|
|
@@ -4134,6 +5175,8 @@ module.exports = {
|
|
|
4134
5175
|
cmdIngressUp,
|
|
4135
5176
|
cmdReconcileIngress,
|
|
4136
5177
|
sweepOrphanIngress,
|
|
5178
|
+
convergeFleetFirewall,
|
|
5179
|
+
reportFirewallPortGaps,
|
|
4137
5180
|
cmdReconcileCredentials,
|
|
4138
5181
|
cmdAccess,
|
|
4139
5182
|
cloudMain,
|
|
@@ -4141,12 +5184,14 @@ module.exports = {
|
|
|
4141
5184
|
seedLiveCredentialCache,
|
|
4142
5185
|
assertGitPushCredentials,
|
|
4143
5186
|
seedGithubCredentials,
|
|
5187
|
+
githubCredentialLine,
|
|
4144
5188
|
resolveGithubToken,
|
|
4145
5189
|
resolveGithubTokenWithSource,
|
|
4146
5190
|
MissingGitPushCredentialsError,
|
|
4147
5191
|
classifyGitRemoteError,
|
|
4148
5192
|
decideCredentialHeal,
|
|
4149
5193
|
readVmAppPort,
|
|
5194
|
+
buildVmAppPortReadCommand,
|
|
4150
5195
|
resolveLaptopGitIdentity,
|
|
4151
5196
|
};
|
|
4152
5197
|
|