@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5
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-branch-switch.js +1 -1
- package/npm/cloud.js +1693 -96
- package/npm/container.js +14 -0
- package/npm/disk-pressure.js +167 -0
- package/npm/editor-dev.js +107 -15
- package/npm/editor.js +97 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +130 -0
- package/npm/fleet-cleanup.js +145 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +46 -2
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +231 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +100 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +310 -4
- package/npm/scenario-check.js +684 -33
- package/npm/scenario-handlers.js +94 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +453 -43
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
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 os = require("os");
|
|
14
15
|
const path = require("path");
|
|
15
16
|
const readline = require("readline");
|
|
16
17
|
const {
|
|
@@ -33,12 +34,58 @@ const { FORWARD_ENV_KEYS_ENV } = require("./fleet-env");
|
|
|
33
34
|
// stable `https://vm-<N>.preview.<domain>` previewOrigin from its number. When
|
|
34
35
|
// no domain is configured these stay dormant and the legacy quick-tunnel seed
|
|
35
36
|
// path runs unchanged.
|
|
36
|
-
const { resolveFleetDomain, vmUrls } = require("./vm-urls");
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// it
|
|
40
|
-
//
|
|
41
|
-
|
|
37
|
+
const { resolveFleetDomain, vmUrls, normalizeVmSlug } = require("./vm-urls");
|
|
38
|
+
// Per-VM custom-slug sidecar (`.codeyam/logs/fleet-vm-slugs.json`). A slug is an
|
|
39
|
+
// optional human-readable override for a VM's leftmost hostname label; Add
|
|
40
|
+
// persists it (`setSlug`), Add/Destroy resolve it (`slugFor`) to route the same
|
|
41
|
+
// `<slug>.<surface>.<domain>` host, and Destroy (`cmdDestroy`) clears it
|
|
42
|
+
// unconditionally (`removeSlug`, decoupled from LB ingress teardown). Absent
|
|
43
|
+
// ⇒ the numeric `vm-<N>` scheme is unchanged.
|
|
44
|
+
const {
|
|
45
|
+
slugFor,
|
|
46
|
+
vmForSlug,
|
|
47
|
+
setSlug,
|
|
48
|
+
removeSlug,
|
|
49
|
+
} = require("./fleet-vm-slugs");
|
|
50
|
+
// Per-VM IAP access whitelist sidecar (`.codeyam/logs/fleet-vm-access.json`).
|
|
51
|
+
// Resource-level grants so a named collaborator can reach ONE VM's surfaces:
|
|
52
|
+
// Add *reconciles* the whitelist to exactly the requested list (`accessFor` +
|
|
53
|
+
// `setAccess`), revoking a reused number's stale grants for a clean slate; the
|
|
54
|
+
// edit CLI mutates the delta (`addAccess`/`removeAccess`); and Destroy revokes +
|
|
55
|
+
// clears (`accessFor` + `clearAccess`). Independent of the owner's project-wide grant.
|
|
56
|
+
const {
|
|
57
|
+
normalizeAccessList,
|
|
58
|
+
normalizePrincipal,
|
|
59
|
+
accessFor,
|
|
60
|
+
setAccess,
|
|
61
|
+
addAccess,
|
|
62
|
+
removeAccess,
|
|
63
|
+
clearAccess,
|
|
64
|
+
} = require("./fleet-vm-access");
|
|
65
|
+
// LB-ingress shell-invocation builders (pure; unit-tested in ingress-invoke.test.js).
|
|
66
|
+
// `resolveIngressDomain` reads the fleet domain from cloud.json (`fleetDomain`)
|
|
67
|
+
// with a FLEET_DOMAIN env fallback — null ⇒ legacy quick-tunnel mode, where the
|
|
68
|
+
// Add/Destroy path is unchanged. The *Invocation builders shape the exact
|
|
69
|
+
// `{ command, args, env }` cloud.js spawns for scripts/gcp/{google,vm}-ingress.sh.
|
|
70
|
+
const {
|
|
71
|
+
resolveIngressDomain,
|
|
72
|
+
resolveDnsZone,
|
|
73
|
+
regionFromZone,
|
|
74
|
+
vmIngressInvocation,
|
|
75
|
+
vmIngressStatusInvocation,
|
|
76
|
+
parseIngressStatusVerdict,
|
|
77
|
+
selectIngressReconcileTargets,
|
|
78
|
+
detachedAddTailInvocation,
|
|
79
|
+
ingressInflightMarkerPath,
|
|
80
|
+
iapAccessInvocation,
|
|
81
|
+
sharedIngressInvocation,
|
|
82
|
+
} = require("./ingress-invoke");
|
|
83
|
+
// Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
|
|
84
|
+
// provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
|
|
85
|
+
// the watchdog see it within one poll cycle — without a manual fleet-vms.json
|
|
86
|
+
// edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
|
|
87
|
+
// fleet for `reconcile-ingress`.
|
|
88
|
+
const { addToRoster, readRoster } = require("./fleet-roster");
|
|
42
89
|
|
|
43
90
|
// =============================================================================
|
|
44
91
|
// Pure helpers — every export here is unit-tested in cloud.test.js without
|
|
@@ -51,6 +98,12 @@ const AGENT_ENV_VARS = {
|
|
|
51
98
|
gemini: "GEMINI_API_KEY",
|
|
52
99
|
};
|
|
53
100
|
|
|
101
|
+
const AGENT_NPM_PACKAGES = {
|
|
102
|
+
claude: "@anthropic-ai/claude-code",
|
|
103
|
+
codex: "@openai/codex",
|
|
104
|
+
gemini: "@google/gemini-cli",
|
|
105
|
+
};
|
|
106
|
+
|
|
54
107
|
const KNOWN_FLAGS = [
|
|
55
108
|
"repo", "branch", "instance-name", "project", "zone", "agent",
|
|
56
109
|
// Editor-source selection, decoupled from the client project (repo/branch):
|
|
@@ -67,11 +120,39 @@ const KNOWN_FLAGS = [
|
|
|
67
120
|
// runs setup by default when the project ships one; pass this for projects
|
|
68
121
|
// where a cloud setup is genuinely wrong (the script's --skip-setup hatch).
|
|
69
122
|
"skip-setup",
|
|
123
|
+
// Fleet LB-ingress domain (e.g. fleet.codeyam.com). When set + persisted in
|
|
124
|
+
// cloud.json, Add wires the VM onto the shared Google HTTPS LB and Destroy
|
|
125
|
+
// unwires it; unset = legacy quick-tunnel mode (Add/Destroy unchanged). The
|
|
126
|
+
// Cloud DNS managed-zone name defaults to fleet-zone; override with --dns-zone.
|
|
127
|
+
"fleet-domain",
|
|
128
|
+
"dns-zone",
|
|
129
|
+
// Optional human-readable hostname slug for THIS VM (single VM only). When
|
|
130
|
+
// set on a numbered fleet VM with a fleet domain, its public URLs become
|
|
131
|
+
// `<slug>.editor.<domain>` / `<slug>.preview.<domain>` instead of the
|
|
132
|
+
// number-derived `vm-<N>...`. Persisted in the slug sidecar (NOT cloud.json,
|
|
133
|
+
// like --no-tunnel), so a later bare `cloud:up` doesn't re-apply a stale slug.
|
|
134
|
+
"vm-slug",
|
|
135
|
+
// Per-VM IAP access whitelist: comma-separated principals (user:/group:/domain:,
|
|
136
|
+
// a bare email defaults to user:) granted resource-level access to THIS VM's
|
|
137
|
+
// surfaces on Add. Persisted in the access sidecar (NOT cloud.json, like
|
|
138
|
+
// --vm-slug), so a later bare `cloud:up` doesn't re-apply a stale list. Edited
|
|
139
|
+
// after the fact with `cloud:access`.
|
|
140
|
+
"vm-access",
|
|
141
|
+
// `cloud:access` edit-an-existing-VM flags: add/remove one principal, or list
|
|
142
|
+
// the current whitelist. Single-VM, resolved from --instance-name.
|
|
143
|
+
"add",
|
|
144
|
+
"remove",
|
|
145
|
+
"list",
|
|
146
|
+
// Escape hatch for an intentionally read-only VM: suppress the fail-loud /
|
|
147
|
+
// degraded-badge when no GitHub push token resolves at provision time. The
|
|
148
|
+
// default is loud — a tokenless VM cannot sync plan claims and must never
|
|
149
|
+
// report healthy silently. Use only when push genuinely isn't wanted.
|
|
150
|
+
"allow-no-git-credentials",
|
|
70
151
|
];
|
|
71
152
|
|
|
72
153
|
// Flags that take no value (presence = true). parseFlags must not demand a
|
|
73
154
|
// value for these or `--no-tunnel` would error "requires a value".
|
|
74
|
-
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup"];
|
|
155
|
+
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
|
|
75
156
|
|
|
76
157
|
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
77
158
|
const INSTANCE_TAG = "codeyam-editor";
|
|
@@ -129,6 +210,17 @@ function agentEnvVarName(agent) {
|
|
|
129
210
|
return AGENT_ENV_VARS[agent];
|
|
130
211
|
}
|
|
131
212
|
|
|
213
|
+
/** Map an agent name to its global npm package. Throws on unknown agents so
|
|
214
|
+
* adding an agent is a one-entry change in `AGENT_NPM_PACKAGES`. */
|
|
215
|
+
function agentNpmPackage(agent) {
|
|
216
|
+
if (!Object.prototype.hasOwnProperty.call(AGENT_NPM_PACKAGES, agent)) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_NPM_PACKAGES).join(", ")}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return AGENT_NPM_PACKAGES[agent];
|
|
222
|
+
}
|
|
223
|
+
|
|
132
224
|
/** Parse `--flag value` argv into an object. Unknown flags or missing values
|
|
133
225
|
* throw with a usage hint — silently dropping them would let typos like
|
|
134
226
|
* `--branchh main` succeed with no branch flag at all. */
|
|
@@ -303,6 +395,7 @@ function resolveSeedPreviewOrigin(
|
|
|
303
395
|
appPort = VITE_PORT,
|
|
304
396
|
env = process.env,
|
|
305
397
|
sameOriginSafe = true,
|
|
398
|
+
slug = null,
|
|
306
399
|
) {
|
|
307
400
|
// Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
|
|
308
401
|
// same-origin under the reserved relative path, in every fleet mode. The
|
|
@@ -331,15 +424,27 @@ function resolveSeedPreviewOrigin(
|
|
|
331
424
|
// client project (vm == null) keeps the localhost seed even with a domain set.
|
|
332
425
|
if (domain && vm != null) {
|
|
333
426
|
return {
|
|
334
|
-
|
|
427
|
+
// A custom slug overrides the leftmost `vm-<N>` label so the iframe origin
|
|
428
|
+
// matches the slug host the LB routes; absent, this is the numeric host.
|
|
429
|
+
previewOrigin: vmUrls(vm, domain, slug).preview,
|
|
430
|
+
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
// Quick-tunnel / domain-free mode: support both 'local' (default) and 'tunnel' modes.
|
|
434
|
+
// In 'local' mode, we use http://localhost:<15172 + VM_NUMBER> directly.
|
|
435
|
+
// In 'tunnel' mode, we prefer the operator's published quick-tunnel URL.
|
|
436
|
+
const mode = env.PREVIEW_ORIGIN_MODE || "local";
|
|
437
|
+
if (mode === "local" && vm != null) {
|
|
438
|
+
// http://localhost inside an https:// editor page is allowed.
|
|
439
|
+
// Browsers treat http://localhost as a potentially-trustworthy origin,
|
|
440
|
+
// so the mixed-content blocker does not apply.
|
|
441
|
+
const previewPort = 15172 + vm;
|
|
442
|
+
return {
|
|
443
|
+
previewOrigin: `http://localhost:${previewPort}`,
|
|
335
444
|
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
336
445
|
};
|
|
337
446
|
}
|
|
338
|
-
// Quick-tunnel
|
|
339
|
-
// `<N>-preview` quick-tunnel URL (the public origin a remote browser can
|
|
340
|
-
// actually load), else the SSH-tunnel bootstrap localhost origin
|
|
341
|
-
// (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
|
|
342
|
-
// the same-origin subpath — that is exactly what recurses for this stack.
|
|
447
|
+
// Quick-tunnel 'tunnel' mode fallback / non-fleet project fallback:
|
|
343
448
|
const previewKey = vm != null ? `${vm}-preview` : null;
|
|
344
449
|
const tunnelUrl =
|
|
345
450
|
previewKey &&
|
|
@@ -394,6 +499,46 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
|
|
|
394
499
|
);
|
|
395
500
|
}
|
|
396
501
|
|
|
502
|
+
// A loopback / localhost previewOrigin: `http://127.0.0.1:<port>`,
|
|
503
|
+
// `http://localhost:<port>`, `http://[::1]:<port>`, with or without a port,
|
|
504
|
+
// path, or query. These are reachable only from the editor host itself (or a
|
|
505
|
+
// laptop `-L` forward), never from a remote browser — exactly the dead-end the
|
|
506
|
+
// dedicated_preview_origin fallback lands on when the seed was wrong-moded.
|
|
507
|
+
const LOOPBACK_ORIGIN_RE =
|
|
508
|
+
/^https?:\/\/(?:127\.0\.0\.1|localhost|\[::1\])(?:[:/?#]|$)/i;
|
|
509
|
+
|
|
510
|
+
/** Decide whether a VM's current `editor.local.json` carries a WRONG-MODE
|
|
511
|
+
* previewOrigin for a stack that is NOT same-origin-safe (Next.js etc.) and so
|
|
512
|
+
* must be self-healed to a dedicated origin. This is the inverse of
|
|
513
|
+
* `isRottedManagedPreviewOrigin`: it fires when the stack.json race froze the
|
|
514
|
+
* same-origin-safe default (or a loopback bootstrap) onto a client-routed
|
|
515
|
+
* stack — the VM7 failure where the iframe shows `127.0.0.1 refused to
|
|
516
|
+
* connect`. True iff BOTH hold:
|
|
517
|
+
* - the stack is NOT same-origin-safe (a same-origin-safe stack legitimately
|
|
518
|
+
* keeps the `/__codeyam_preview` subpath — never heal it; that is
|
|
519
|
+
* `isRottedManagedPreviewOrigin`'s job in the other direction); and
|
|
520
|
+
* - the `previewOrigin` is the same-origin subpath sentinel, any other
|
|
521
|
+
* relative path, or a loopback/localhost origin — none of which a remote
|
|
522
|
+
* browser can load for a dedicated-origin stack. A real dedicated origin
|
|
523
|
+
* (an absolute non-loopback URL — quick-tunnel or named-tunnel host) is
|
|
524
|
+
* left alone, so re-running the heal once a good origin is in place is a
|
|
525
|
+
* no-op.
|
|
526
|
+
* Symmetric with `isRottedManagedPreviewOrigin` and tested as a pair. Pure —
|
|
527
|
+
* `localJson` is a parsed editor.local.json object. */
|
|
528
|
+
function isWrongModePreviewOrigin(localJson, sameOriginSafe) {
|
|
529
|
+
if (sameOriginSafe) return false;
|
|
530
|
+
if (!localJson || typeof localJson !== "object") return false;
|
|
531
|
+
const origin = localJson.previewOrigin;
|
|
532
|
+
if (typeof origin !== "string" || origin.length === 0) return false;
|
|
533
|
+
// The reserved same-origin subpath, or any relative path (no scheme): these
|
|
534
|
+
// recurse into the editor SPA for a client-routed stack.
|
|
535
|
+
if (origin === SAME_ORIGIN_PREVIEW_PATH) return true;
|
|
536
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(origin)) return true;
|
|
537
|
+
// An absolute loopback/localhost origin: reachable only from the host, dead
|
|
538
|
+
// for a remote browser.
|
|
539
|
+
return LOOPBACK_ORIGIN_RE.test(origin);
|
|
540
|
+
}
|
|
541
|
+
|
|
397
542
|
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
398
543
|
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
399
544
|
function readCloudflaredUrls() {
|
|
@@ -474,9 +619,25 @@ function editorImageBootstrapArgs(cfg) {
|
|
|
474
619
|
* so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
|
|
475
620
|
* — can't override the new intent. Without this, launching `--repo X` after a
|
|
476
621
|
* greenfield run silently provisions the greenfield project (resolveProject
|
|
477
|
-
* prioritizes newProject). Mutates and returns cfg.
|
|
478
|
-
|
|
479
|
-
|
|
622
|
+
* prioritizes newProject). Mutates and returns cfg.
|
|
623
|
+
*
|
|
624
|
+
* `prev` is the pre-merge persisted config (default `{}`). When `--repo`
|
|
625
|
+
* SWITCHES to a different repo and no `--branch` was passed this run, the
|
|
626
|
+
* inherited client branch is dropped: a persisted branch belongs to the OLD
|
|
627
|
+
* repo and may not exist on the new one. (The harvardintech VM was torn down
|
|
628
|
+
* because a `--repo harvardintech` re-up reused `editor-improvements-48` — a
|
|
629
|
+
* branch that lives only on the editor repo — and `git clone --branch` failed.)
|
|
630
|
+
* An explicit `--branch` this run always wins (the drop is gated on
|
|
631
|
+
* `!flags.branch`), and a same-repo re-up keeps its persisted branch — that is
|
|
632
|
+
* the legitimate "set the branch once, reuse it" workflow. `sameRepo` is used
|
|
633
|
+
* for the comparison so `.git`/case differences don't trigger a spurious drop. */
|
|
634
|
+
function reconcileProjectExclusivity(cfg, flags = {}, prev = {}) {
|
|
635
|
+
if (flags.repo) {
|
|
636
|
+
delete cfg.newProject;
|
|
637
|
+
if (!flags.branch && prev.repo && !sameRepo(prev.repo, flags.repo)) {
|
|
638
|
+
delete cfg.branch;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
480
641
|
if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
|
|
481
642
|
return cfg;
|
|
482
643
|
}
|
|
@@ -529,25 +690,50 @@ function loadCloudConfig(projectDir) {
|
|
|
529
690
|
try {
|
|
530
691
|
return JSON.parse(raw);
|
|
531
692
|
} catch (e) {
|
|
532
|
-
|
|
693
|
+
try {
|
|
694
|
+
raw = fs.readFileSync(p, "utf8");
|
|
695
|
+
return JSON.parse(raw);
|
|
696
|
+
} catch (e2) {
|
|
697
|
+
throw new Error(`Failed to parse ${p}: ${e2.message}`);
|
|
698
|
+
}
|
|
533
699
|
}
|
|
534
700
|
}
|
|
535
701
|
|
|
536
702
|
function saveCloudConfig(projectDir, cfg) {
|
|
537
703
|
const p = cloudConfigPath(projectDir);
|
|
538
|
-
|
|
539
|
-
fs.
|
|
704
|
+
const dir = path.dirname(p);
|
|
705
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
706
|
+
const tmpPath = path.join(dir, `cloud.json.${process.pid}.tmp`);
|
|
707
|
+
try {
|
|
708
|
+
fs.writeFileSync(tmpPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
709
|
+
fs.renameSync(tmpPath, p);
|
|
710
|
+
} catch (e) {
|
|
711
|
+
fs.rmSync(tmpPath, { force: true });
|
|
712
|
+
throw e;
|
|
713
|
+
}
|
|
540
714
|
}
|
|
541
715
|
|
|
542
716
|
/** Build the JSON patch to merge into the VM's `editor.local.json` so the
|
|
543
|
-
* configured agent
|
|
544
|
-
*
|
|
545
|
-
*
|
|
717
|
+
* configured agent becomes the editor's build-agent provider AND (when the
|
|
718
|
+
* key is available) picks up the user's local API key on first launch. The
|
|
719
|
+
* top-level `provider` field (lowercase — matching `BuildAgentProvider`'s
|
|
720
|
+
* serde) makes the VM's editor default to the chosen agent instead of Claude;
|
|
721
|
+
* `editor.local.json` is deep-merged over `editor.json` on the VM, so this
|
|
722
|
+
* overrides the packaged default.
|
|
723
|
+
*
|
|
724
|
+
* The patch ALWAYS carries `provider` — the agent is a launch *choice* that
|
|
725
|
+
* must persist on the VM regardless of whether the operator happens to have
|
|
726
|
+
* the agent's API key exported locally (Gemini, for instance, is normally
|
|
727
|
+
* authenticated interactively on the VM). `env.<KEY>` is the separate,
|
|
728
|
+
* opportunistic seed: it's included only when the matching key is set in
|
|
729
|
+
* `localEnv`. The function never returns `null`. Writing `provider: "claude"`
|
|
730
|
+
* when claude is the default deep-merges to a no-op, so the provider can be
|
|
731
|
+
* written unconditionally without special-casing the default. */
|
|
546
732
|
function buildEditorLocalEnvPatch(localEnv, agent) {
|
|
547
733
|
const varName = agentEnvVarName(agent);
|
|
548
734
|
const value = localEnv[varName];
|
|
549
|
-
if (!value) return
|
|
550
|
-
return { env: { [varName]: value } };
|
|
735
|
+
if (!value) return { provider: agent };
|
|
736
|
+
return { provider: agent, env: { [varName]: value } };
|
|
551
737
|
}
|
|
552
738
|
|
|
553
739
|
/** Parse `docker compose ps --format json` (compose v2 — one JSON object per
|
|
@@ -700,6 +886,72 @@ function runGcloud(args, opts = {}) {
|
|
|
700
886
|
});
|
|
701
887
|
}
|
|
702
888
|
|
|
889
|
+
// Build a bounded, de-noised failure detail from a runGcloud (spawnSync)
|
|
890
|
+
// result: prefer stderr, fall back to stdout, and surface a spawn-level error
|
|
891
|
+
// (e.g. ENOENT when gcloud is not on PATH) when there is no captured output.
|
|
892
|
+
// Trims the known gcloud noise lines — the same `/WARNING|NumPy|google.com/iap/`
|
|
893
|
+
// filter the dashboard's tailLog applies — and keeps the last ~15 lines so the
|
|
894
|
+
// actionable message is not buried under auth/IAP warnings. Pure: result in,
|
|
895
|
+
// string out, so it is unit-testable without spawning gcloud.
|
|
896
|
+
function formatGcloudFailure(result) {
|
|
897
|
+
const denoise = (text) =>
|
|
898
|
+
String(text || "")
|
|
899
|
+
.split("\n")
|
|
900
|
+
.map((l) => l.replace(/\r$/, ""))
|
|
901
|
+
.filter((l) => l.trim() !== "")
|
|
902
|
+
.filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
|
|
903
|
+
.slice(-15)
|
|
904
|
+
.join("\n");
|
|
905
|
+
|
|
906
|
+
const stderr = denoise(result && result.stderr);
|
|
907
|
+
if (stderr) return stderr;
|
|
908
|
+
const stdout = denoise(result && result.stdout);
|
|
909
|
+
if (stdout) return stdout;
|
|
910
|
+
if (result && result.error && result.error.message) {
|
|
911
|
+
return result.error.message;
|
|
912
|
+
}
|
|
913
|
+
return "(no output captured from gcloud)";
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
|
|
917
|
+
// for an ssh failure) and a signature match against known transient strings.
|
|
918
|
+
function isTransientSshFailure(result) {
|
|
919
|
+
if (!result || result.status !== 255) {
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
const output = String(result.stderr || "") + String(result.stdout || "");
|
|
923
|
+
const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
|
|
924
|
+
return transientRegex.test(output);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// A retry wrapper for gcloud ssh-script calls that handles transient connection drops
|
|
928
|
+
// with exponential backoff, mimicking the IAP ready backoff.
|
|
929
|
+
async function runGcloudSshWithRetry(verb, cfg, extras, { stdio, retries = 5, label, sleepFn } = {}) {
|
|
930
|
+
let lastResult;
|
|
931
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
932
|
+
const result = runGcloud(buildGcloudArgs(verb, cfg, extras), { stdio });
|
|
933
|
+
if (result.status === 0) {
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
lastResult = result;
|
|
937
|
+
if (isTransientSshFailure(result)) {
|
|
938
|
+
if (attempt < retries) {
|
|
939
|
+
const delay = Math.min(2000 * Math.pow(1.5, attempt - 1), 8000);
|
|
940
|
+
console.error(` ↺ transient SSH failure (attempt ${attempt}/${retries}) [${label || verb}] ... retrying in ${delay / 1000}s`);
|
|
941
|
+
if (sleepFn) {
|
|
942
|
+
await sleepFn(delay);
|
|
943
|
+
} else {
|
|
944
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
945
|
+
}
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
// For non-transient failure, return immediately, or if we exhausted our attempts.
|
|
950
|
+
return result;
|
|
951
|
+
}
|
|
952
|
+
return lastResult;
|
|
953
|
+
}
|
|
954
|
+
|
|
703
955
|
function ensureGcloud() {
|
|
704
956
|
const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
|
|
705
957
|
if (result.error || result.status !== 0) {
|
|
@@ -740,9 +992,21 @@ function loadOrInitCloudConfig(projectDir, flags) {
|
|
|
740
992
|
const existing = loadCloudConfig(projectDir);
|
|
741
993
|
// noTunnel is a per-invocation behavior, never persisted — else a later manual
|
|
742
994
|
// `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
|
|
743
|
-
|
|
995
|
+
// vmSlug is likewise a per-Add input that lives in the slug sidecar keyed by VM
|
|
996
|
+
// number, never in this project config — else a later bare `cloud:up` would
|
|
997
|
+
// re-stamp a stale slug onto the VM. vmAccess is the same shape: a per-Add input
|
|
998
|
+
// that lives in the access sidecar, never in cloud.json.
|
|
999
|
+
const persist = (c) => { const { noTunnel, vmSlug, vmAccess, ...rest } = c; return rest; };
|
|
744
1000
|
if (existing) {
|
|
745
|
-
const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags);
|
|
1001
|
+
const merged = reconcileProjectExclusivity({ ...existing, ...flags }, flags, existing);
|
|
1002
|
+
const { isPlaceholderProject } = require("./launch-config");
|
|
1003
|
+
if (isPlaceholderProject(merged.project)) {
|
|
1004
|
+
const configPath = cloudConfigPath(projectDir);
|
|
1005
|
+
throw new Error(
|
|
1006
|
+
`Failed to load cloud config: project '${merged.project}' in ${configPath} is a test-fixture placeholder. ` +
|
|
1007
|
+
`Please configure a real GCP project or set the GCP_PROJECT environment variable.`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
746
1010
|
const toSave = persist(merged);
|
|
747
1011
|
if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
|
|
748
1012
|
saveCloudConfig(projectDir, toSave);
|
|
@@ -752,6 +1016,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
|
|
|
752
1016
|
|
|
753
1017
|
const cfg = reconcileProjectExclusivity({ ...flags }, flags);
|
|
754
1018
|
cfg.project = cfg.project || gcloudGetValue("config-get-project");
|
|
1019
|
+
|
|
1020
|
+
const { isPlaceholderProject } = require("./launch-config");
|
|
1021
|
+
if (isPlaceholderProject(cfg.project)) {
|
|
1022
|
+
throw new Error(
|
|
1023
|
+
`Failed to load cloud config: resolved project '${cfg.project}' is a test-fixture placeholder. ` +
|
|
1024
|
+
`Please configure a real GCP project or set the GCP_PROJECT environment variable.`
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
755
1028
|
cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
|
|
756
1029
|
cfg.agent = cfg.agent || "claude";
|
|
757
1030
|
agentEnvVarName(cfg.agent);
|
|
@@ -799,9 +1072,11 @@ async function ensureFirewallRule(cfg) {
|
|
|
799
1072
|
async function ensureInstance(cfg) {
|
|
800
1073
|
console.error(`Checking instance ${cfg.instanceName}...`);
|
|
801
1074
|
const initialState = fetchInstanceState(cfg);
|
|
1075
|
+
const createdThisRun = initialState === null;
|
|
1076
|
+
|
|
802
1077
|
if (initialState === "RUNNING") {
|
|
803
1078
|
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
804
|
-
return;
|
|
1079
|
+
return { createdThisRun };
|
|
805
1080
|
}
|
|
806
1081
|
if (initialState === "TERMINATED") {
|
|
807
1082
|
console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
|
|
@@ -823,7 +1098,7 @@ async function ensureInstance(cfg) {
|
|
|
823
1098
|
if (fetchInstanceState(cfg) === "RUNNING") {
|
|
824
1099
|
console.error(` ✓ ${cfg.instanceName} is RUNNING`);
|
|
825
1100
|
await waitForIapReady(cfg);
|
|
826
|
-
return;
|
|
1101
|
+
return { createdThisRun };
|
|
827
1102
|
}
|
|
828
1103
|
await new Promise((r) => setTimeout(r, 2000));
|
|
829
1104
|
}
|
|
@@ -1079,7 +1354,7 @@ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
|
|
|
1079
1354
|
return `echo '${b64}' | base64 -d | ${envPrefix}bash -s -- ${argsString}`.trim();
|
|
1080
1355
|
}
|
|
1081
1356
|
|
|
1082
|
-
async function ensureBootstrapped(cfg) {
|
|
1357
|
+
async function ensureBootstrapped(cfg, { sleepFn } = {}) {
|
|
1083
1358
|
console.error("Checking VM bootstrap state...");
|
|
1084
1359
|
const probe = runGcloud(
|
|
1085
1360
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
@@ -1140,12 +1415,17 @@ async function ensureBootstrapped(cfg) {
|
|
|
1140
1415
|
// fresh detached build that will survive an SSH disconnect.
|
|
1141
1416
|
console.error(" launching detached bootstrap on the VM (survives SSH disconnect; ~1-14 min)...");
|
|
1142
1417
|
const launch = bootstrapLaunchCommand(scriptBody, bootstrapArgs, bootstrapEnv);
|
|
1143
|
-
const launched =
|
|
1144
|
-
|
|
1145
|
-
|
|
1418
|
+
const launched = await runGcloudSshWithRetry(
|
|
1419
|
+
"ssh-script",
|
|
1420
|
+
cfg,
|
|
1421
|
+
{ command: launch },
|
|
1422
|
+
{ stdio: ["ignore", "pipe", "pipe"], label: "bootstrap launch", sleepFn }
|
|
1146
1423
|
);
|
|
1147
1424
|
if (launched.status !== 0) {
|
|
1148
|
-
|
|
1425
|
+
const detail = formatGcloudFailure(launched);
|
|
1426
|
+
throw new Error(
|
|
1427
|
+
`failed to launch the detached bootstrap on the VM (gcloud ssh exit ${launched.status}).\n${detail}`,
|
|
1428
|
+
);
|
|
1149
1429
|
}
|
|
1150
1430
|
}
|
|
1151
1431
|
|
|
@@ -1250,10 +1530,19 @@ async function ensureProjectBootstrapped(cfg) {
|
|
|
1250
1530
|
// Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
|
|
1251
1531
|
// standalone `cloud:up` path, so that flow is unchanged.
|
|
1252
1532
|
const forwardEnv = resolveSetupForwardEnv();
|
|
1533
|
+
// Forward the fleet domain so the bootstrap's compose render publishes the
|
|
1534
|
+
// editor/app ports on 0.0.0.0 (the LB NEG reaches them on the VM's internal
|
|
1535
|
+
// IP) instead of loopback-only. Only when a fleet domain is configured AND
|
|
1536
|
+
// this is a numbered fleet VM — a non-fleet client project keeps the
|
|
1537
|
+
// loopback-only publish (legacy parity). Absent ⇒ key omitted entirely.
|
|
1538
|
+
const ingressEnv = {};
|
|
1539
|
+
const ingressPlan = ingressWiringPlan(cfg);
|
|
1540
|
+
if (ingressPlan.wire) ingressEnv.FLEET_DOMAIN = ingressPlan.domain;
|
|
1253
1541
|
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
1254
1542
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
1255
1543
|
CY_GIT_USER_NAME: laptopGit.name,
|
|
1256
1544
|
CY_GIT_USER_EMAIL: laptopGit.email,
|
|
1545
|
+
...ingressEnv,
|
|
1257
1546
|
...forwardEnv,
|
|
1258
1547
|
});
|
|
1259
1548
|
const result = runGcloud(
|
|
@@ -1280,6 +1569,7 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
1280
1569
|
);
|
|
1281
1570
|
if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
|
|
1282
1571
|
console.error(" ✓ editor container is already running");
|
|
1572
|
+
await ensureAgentCliInstalled(cfg);
|
|
1283
1573
|
return;
|
|
1284
1574
|
}
|
|
1285
1575
|
console.error(" starting editor container (docker compose up -d)...");
|
|
@@ -1292,6 +1582,34 @@ async function ensureEditorContainerUp(cfg) {
|
|
|
1292
1582
|
if (up.status !== 0) {
|
|
1293
1583
|
throw new Error("docker compose up -d failed on the VM");
|
|
1294
1584
|
}
|
|
1585
|
+
await ensureAgentCliInstalled(cfg);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// Hard gate: provisioning success REQUIRES a running editor container. Reuses
|
|
1589
|
+
// the exact compose-ps probe ensureEditorContainerUp uses, but as a TERMINAL
|
|
1590
|
+
// re-check rather than a precondition — `docker compose up -d` can exit 0 over
|
|
1591
|
+
// a container that then crash-loops or exits, and an upstream phase that never
|
|
1592
|
+
// ran leaves no /workspace at all. Either way `cmdUp` would otherwise report
|
|
1593
|
+
// success. This throws (non-zero exit) when the editor service isn't
|
|
1594
|
+
// `running`, converting a silent partial provision into a loud, actionable
|
|
1595
|
+
// failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
|
|
1596
|
+
// on. The `|| true` keeps the probe's exit status 0 so the real signal is the
|
|
1597
|
+
// parsed compose state, not the SSH/cd exit code.
|
|
1598
|
+
async function assertEditorContainerRunning(cfg) {
|
|
1599
|
+
const repoName = resolveProject(cfg).name;
|
|
1600
|
+
const composeCmd = `docker compose -f docker-compose.cloud.yml`;
|
|
1601
|
+
const probe = runGcloud(
|
|
1602
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
1603
|
+
command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
|
|
1604
|
+
}),
|
|
1605
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
1606
|
+
);
|
|
1607
|
+
if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
|
|
1608
|
+
throw new Error(
|
|
1609
|
+
`provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
|
|
1610
|
+
`(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1295
1613
|
}
|
|
1296
1614
|
|
|
1297
1615
|
/** Read the laptop's git author identity (`user.name`, `user.email`) so
|
|
@@ -1320,13 +1638,52 @@ function resolveLaptopGitIdentity(spawnImpl = spawnSync) {
|
|
|
1320
1638
|
* intentionally not done: the cost of one `gh` subprocess per cloud:up is
|
|
1321
1639
|
* trivial and lets a user `gh auth login` between operations. */
|
|
1322
1640
|
function resolveGithubToken() {
|
|
1323
|
-
|
|
1324
|
-
|
|
1641
|
+
return resolveGithubTokenWithSource().token;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/** Like {@link resolveGithubToken} but also reports WHICH channel resolved the
|
|
1645
|
+
* token (`GITHUB_TOKEN` env, `GH_TOKEN` env, or `gh auth token`). The source is
|
|
1646
|
+
* logged on a successful seed so a misconfigured add-environment — e.g. a
|
|
1647
|
+
* launchd dashboard agent whose plist lacks the token env and whose `gh` can't
|
|
1648
|
+
* reach the operator's keychain — is diagnosable from the add log instead of
|
|
1649
|
+
* surfacing days later as a fleet-wide plan-claim-sync outage. `source` is
|
|
1650
|
+
* `null` when no token resolves. */
|
|
1651
|
+
function resolveGithubTokenWithSource() {
|
|
1652
|
+
if (process.env.GITHUB_TOKEN) {
|
|
1653
|
+
return { token: process.env.GITHUB_TOKEN, source: "GITHUB_TOKEN" };
|
|
1654
|
+
}
|
|
1655
|
+
if (process.env.GH_TOKEN) {
|
|
1656
|
+
return { token: process.env.GH_TOKEN, source: "GH_TOKEN" };
|
|
1657
|
+
}
|
|
1325
1658
|
const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
1326
1659
|
if (ghOut.status === 0) {
|
|
1327
|
-
|
|
1660
|
+
const token = String(ghOut.stdout || "").trim();
|
|
1661
|
+
if (token) return { token, source: "gh auth token" };
|
|
1662
|
+
}
|
|
1663
|
+
return { token: "", source: null };
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/** Thrown by {@link seedGithubCredentials} when no GitHub token resolves and the
|
|
1667
|
+
* caller has NOT opted into a credential-less VM. The root harm this guards is
|
|
1668
|
+
* a VM that comes up "healthy" yet cannot `git push` its plan-claim refs to
|
|
1669
|
+
* origin, silently stranding fleet-wide claim sync (observed 2026-06-29 across
|
|
1670
|
+
* all 9 VMs). `degradedReason` is the machine-readable `CY_DEGRADED` body the
|
|
1671
|
+
* add path emits so the dashboard card lands amber with a recovery command
|
|
1672
|
+
* instead of a falsely-green healthy card. */
|
|
1673
|
+
class MissingGitPushCredentialsError extends Error {
|
|
1674
|
+
constructor() {
|
|
1675
|
+
super(
|
|
1676
|
+
"No GITHUB_TOKEN / GH_TOKEN set and `gh auth token` is unavailable —\n" +
|
|
1677
|
+
"the cloud VM cannot git pull/push, so plan-claim sync will not reach origin.\n" +
|
|
1678
|
+
"Export GITHUB_TOKEN or run `gh auth login` locally, then `cloud:up` again,\n" +
|
|
1679
|
+
"or re-seed a running VM with `npm run cloud:reconcile-credentials`.\n" +
|
|
1680
|
+
"Pass --allow-no-git-credentials to provision an intentionally read-only VM.",
|
|
1681
|
+
);
|
|
1682
|
+
this.name = "MissingGitPushCredentialsError";
|
|
1683
|
+
this.degradedReason =
|
|
1684
|
+
"no-git-credentials: no GitHub push token resolved — plan-claim sync will not " +
|
|
1685
|
+
"reach origin. Recover: npm run cloud:reconcile-credentials";
|
|
1328
1686
|
}
|
|
1329
|
-
return "";
|
|
1330
1687
|
}
|
|
1331
1688
|
|
|
1332
1689
|
/** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
|
|
@@ -1360,6 +1717,139 @@ function resolveGhcrPushToken() {
|
|
|
1360
1717
|
return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
|
|
1361
1718
|
}
|
|
1362
1719
|
|
|
1720
|
+
/** Read-only probe: does a usable prebuilt editor image already exist on the
|
|
1721
|
+
* registry for `branch`? Used by the fleet dashboard BEFORE a concurrent batch
|
|
1722
|
+
* add to decide whether to pace one VM ahead of the rest (see
|
|
1723
|
+
* npm/fleet-prewarm.js::decideBatchPrewarm). A `docker manifest inspect` is a
|
|
1724
|
+
* HEAD-equivalent — it reads the manifest without pulling the (multi-GB) image.
|
|
1725
|
+
*
|
|
1726
|
+
* Returns:
|
|
1727
|
+
* true — at least one of the usable tags exists (a cold add can pull; no pacer)
|
|
1728
|
+
* false — every tag was checked and none exists (known-absent; pacing pays)
|
|
1729
|
+
* null — undetermined: no read token, docker missing, or a probe error that
|
|
1730
|
+
* can't be distinguished from "absent". The caller treats null as
|
|
1731
|
+
* "fan out" (today's behavior, never worse) rather than risk a
|
|
1732
|
+
* 10-min serialization that might buy nothing.
|
|
1733
|
+
*
|
|
1734
|
+
* The private packages require auth, so we `docker login` with the read token
|
|
1735
|
+
* first; without a token we can't tell absent from forbidden, so → null.
|
|
1736
|
+
* `spawnImpl` is injectable for unit tests. */
|
|
1737
|
+
function probeUsableEditorImage({ branch, registry, token, spawnImpl } = {}) {
|
|
1738
|
+
const fp = require("./fleet-prewarm");
|
|
1739
|
+
const spawn = spawnImpl || spawnSync;
|
|
1740
|
+
const reg = registry || process.env.CODEYAM_REGISTRY || "ghcr.io/codeyam-ai";
|
|
1741
|
+
const readToken = token || resolveGhcrToken();
|
|
1742
|
+
if (!readToken) return null; // can't auth → can't distinguish absent from forbidden
|
|
1743
|
+
const host = String(reg).split("/")[0];
|
|
1744
|
+
const login = spawn(
|
|
1745
|
+
"docker",
|
|
1746
|
+
["login", host, "-u", process.env.CODEYAM_GHCR_USER || "codeyam", "--password-stdin"],
|
|
1747
|
+
{ input: readToken, encoding: "utf8" },
|
|
1748
|
+
);
|
|
1749
|
+
// spawn error (docker missing) or a non-zero login → undetermined.
|
|
1750
|
+
if (!login || login.error || login.status !== 0) return null;
|
|
1751
|
+
const tags = fp.usableImageTags({ registry: reg, branch });
|
|
1752
|
+
let allClean = true;
|
|
1753
|
+
for (const tag of tags) {
|
|
1754
|
+
const res = spawn("docker", ["manifest", "inspect", tag], { encoding: "utf8" });
|
|
1755
|
+
if (!res || res.error) {
|
|
1756
|
+
allClean = false; // probe itself failed to run — can't claim "absent"
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
if (res.status === 0) return true; // a usable image exists
|
|
1760
|
+
}
|
|
1761
|
+
// Every tag inspected cleanly and none existed → definitively absent. If any
|
|
1762
|
+
// probe failed to run, we can't be sure → undetermined.
|
|
1763
|
+
return allClean ? false : null;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/** JS mirror of crates/control-api/src/plan_claims_sync.rs::classify_sync_error.
|
|
1767
|
+
* Categorizes a `git ls-remote` / push stderr into the SAME buckets the Rust
|
|
1768
|
+
* sync task uses (`credential` / `git-spawn` / `network` / `other`) so the
|
|
1769
|
+
* credential self-heal only re-seeds on a real credential gap, never a
|
|
1770
|
+
* transient network blip. Deliberate duplication: cloud.js (the fleet tool)
|
|
1771
|
+
* cannot call into the Rust crate, and the category strings are a stable
|
|
1772
|
+
* contract — keep the two lists in sync if either changes. */
|
|
1773
|
+
function classifyGitRemoteError(stderr) {
|
|
1774
|
+
const lower = String(stderr || "").toLowerCase();
|
|
1775
|
+
if (
|
|
1776
|
+
[
|
|
1777
|
+
"could not read username",
|
|
1778
|
+
"terminal prompts disabled",
|
|
1779
|
+
"authentication failed",
|
|
1780
|
+
"device not configured",
|
|
1781
|
+
].some((s) => lower.includes(s))
|
|
1782
|
+
) {
|
|
1783
|
+
return "credential";
|
|
1784
|
+
}
|
|
1785
|
+
if (lower.includes("no such file") && lower.includes("git")) {
|
|
1786
|
+
return "git-spawn";
|
|
1787
|
+
}
|
|
1788
|
+
if (
|
|
1789
|
+
[
|
|
1790
|
+
"could not resolve host",
|
|
1791
|
+
"connection refused",
|
|
1792
|
+
"connection timed out",
|
|
1793
|
+
"ssl certificate problem",
|
|
1794
|
+
"tls",
|
|
1795
|
+
].some((s) => lower.includes(s))
|
|
1796
|
+
) {
|
|
1797
|
+
return "network";
|
|
1798
|
+
}
|
|
1799
|
+
return "other";
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/** Pure decision for the credential self-heal. Given a probe of a running VM —
|
|
1803
|
+
* whether the host staging file (`~/.codeyam-cloud-git-credentials`) is empty,
|
|
1804
|
+
* and the outcome of an in-container `git ls-remote origin` — decide whether to
|
|
1805
|
+
* re-seed credentials in place.
|
|
1806
|
+
*
|
|
1807
|
+
* Re-seed iff the host staging file is empty (so a restart wouldn't recover) OR
|
|
1808
|
+
* `ls-remote` failed with the `credential` signature. A `network` failure must
|
|
1809
|
+
* NOT trigger a re-write (a transient blip is not a credential gap), and a
|
|
1810
|
+
* healthy VM (ls-remote ok AND staging seeded) is a no-op. Returns
|
|
1811
|
+
* `{ heal, category, reason }`. */
|
|
1812
|
+
function decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr }) {
|
|
1813
|
+
if (lsRemoteOk && !stagingEmpty) {
|
|
1814
|
+
return {
|
|
1815
|
+
heal: false,
|
|
1816
|
+
category: "ok",
|
|
1817
|
+
reason: "git ls-remote origin succeeds and the staging file is seeded",
|
|
1818
|
+
};
|
|
1819
|
+
}
|
|
1820
|
+
if (!lsRemoteOk) {
|
|
1821
|
+
const category = classifyGitRemoteError(lsRemoteStderr);
|
|
1822
|
+
if (category === "network") {
|
|
1823
|
+
return {
|
|
1824
|
+
heal: false,
|
|
1825
|
+
category,
|
|
1826
|
+
reason: "ls-remote failed with a network error — not re-seeding (transient)",
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
if (category === "git-spawn") {
|
|
1830
|
+
return {
|
|
1831
|
+
heal: false,
|
|
1832
|
+
category,
|
|
1833
|
+
reason: "git binary failed to launch — not a credential gap, not re-seeding",
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
return {
|
|
1837
|
+
heal: true,
|
|
1838
|
+
category,
|
|
1839
|
+
reason: stagingEmpty
|
|
1840
|
+
? "staging file empty and ls-remote failing — re-seeding"
|
|
1841
|
+
: "ls-remote failed with a credential error — re-seeding",
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
// ls-remote ok but the durable host file is empty (live cache seeded
|
|
1845
|
+
// out-of-band): re-seed so the next container restart survives.
|
|
1846
|
+
return {
|
|
1847
|
+
heal: true,
|
|
1848
|
+
category: "credential",
|
|
1849
|
+
reason: "staging file empty (re-seed for restart durability)",
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1363
1853
|
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1364
1854
|
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1365
1855
|
* without this, claude / codex / gemini inside the container can clone the
|
|
@@ -1367,17 +1857,24 @@ function resolveGhcrPushToken() {
|
|
|
1367
1857
|
* inlined URL) but cannot `git pull` / `git push` afterward. The file is
|
|
1368
1858
|
* chmod 600 and never committed; if the local environment has no token,
|
|
1369
1859
|
* the placeholder file from gcp-project-bootstrap.sh stays in place. */
|
|
1370
|
-
async function seedGithubCredentials(cfg) {
|
|
1371
|
-
const token =
|
|
1860
|
+
async function seedGithubCredentials(cfg, opts = {}) {
|
|
1861
|
+
const { token, source } = resolveGithubTokenWithSource();
|
|
1372
1862
|
if (!token) {
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1863
|
+
if (opts.allowNoCredentials) {
|
|
1864
|
+
// Explicit opt-out: an intentionally read-only VM. Warn loudly (so it is
|
|
1865
|
+
// never invisible) but do not throw — the caller asked for this.
|
|
1866
|
+
console.error(
|
|
1867
|
+
"\n--allow-no-git-credentials: no GitHub token resolved; provisioning a\n" +
|
|
1868
|
+
"read-only VM. It cannot git push, so plan-claim sync will not reach origin.\n",
|
|
1869
|
+
);
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
// Fail loud: a VM that can't push plan-claim refs is degraded by definition.
|
|
1873
|
+
throw new MissingGitPushCredentialsError();
|
|
1379
1874
|
}
|
|
1380
|
-
console.error(
|
|
1875
|
+
console.error(
|
|
1876
|
+
`Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
|
|
1877
|
+
);
|
|
1381
1878
|
const credLine = `https://x-access-token:${token}@github.com`;
|
|
1382
1879
|
// Quote the token through one shell layer (gcloud ssh --command); the
|
|
1383
1880
|
// value is base64'd to keep it out of the shell history.
|
|
@@ -1511,36 +2008,63 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1511
2008
|
sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
|
|
1512
2009
|
}
|
|
1513
2010
|
|
|
2011
|
+
// Resolve this VM's custom slug (if any) so a dedicated-origin seed uses the
|
|
2012
|
+
// slug host. The slug rides on cfg at Add time (the --vm-slug flag, validated
|
|
2013
|
+
// early); for any other entry point it's resolved from the sidecar by number.
|
|
2014
|
+
const seedVm = resolveFleetVmNumber(cfg.instanceName);
|
|
2015
|
+
const seedSlug =
|
|
2016
|
+
cfg.vmSlug && String(cfg.vmSlug).trim() !== ""
|
|
2017
|
+
? cfg.vmSlug
|
|
2018
|
+
: seedVm != null
|
|
2019
|
+
? slugFor(seedVm)
|
|
2020
|
+
: null;
|
|
1514
2021
|
const seed = resolveSeedPreviewOrigin(
|
|
1515
2022
|
cfg.instanceName,
|
|
1516
2023
|
readCloudflaredUrls(),
|
|
1517
2024
|
appPort,
|
|
1518
2025
|
process.env,
|
|
1519
2026
|
sameOriginSafe,
|
|
2027
|
+
seedSlug,
|
|
1520
2028
|
);
|
|
1521
2029
|
|
|
1522
2030
|
// Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
|
|
1523
|
-
// is normally a user pin or a good prior seed and is left untouched.
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
2031
|
+
// is normally a user pin or a good prior seed and is left untouched. There are
|
|
2032
|
+
// TWO symmetric heal exceptions, both of which the frozen skip-guard would
|
|
2033
|
+
// otherwise pin forever:
|
|
2034
|
+
// 1. a ROTTED managed cloudflared quick-tunnel URL on a same-origin-safe VM
|
|
2035
|
+
// (the VM-1/2/6 leftover from before the improve38 same-origin
|
|
2036
|
+
// migration): that frozen URL points at a dead rotated tunnel (NXDOMAIN /
|
|
2037
|
+
// blank preview), so we heal it back to the same-origin path; and
|
|
2038
|
+
// 2. a WRONG-MODE previewOrigin on a NOT-same-origin-safe VM (the VM7 race:
|
|
2039
|
+
// the first seed ran before onboarding wrote stack.json, defaulted to
|
|
2040
|
+
// `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
|
|
2041
|
+
// remote browser then loads as `127.0.0.1 refused to connect`). Once
|
|
2042
|
+
// stack.json exists, heal it forward to the dedicated origin.
|
|
2043
|
+
// Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
|
|
2044
|
+
// re-runs this seed over cloud:up, either heal reaches every touch-point the
|
|
2045
|
+
// moment stack.json is present — closing the race without re-ordering
|
|
2046
|
+
// provisioning, and without a separate per-VM hand-fix.
|
|
1531
2047
|
const originAlreadySet =
|
|
1532
2048
|
typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
|
|
1533
2049
|
const healing =
|
|
1534
|
-
originAlreadySet &&
|
|
2050
|
+
originAlreadySet &&
|
|
2051
|
+
(isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
|
|
2052
|
+
isWrongModePreviewOrigin(current, sameOriginSafe));
|
|
1535
2053
|
if (originAlreadySet && !healing) {
|
|
1536
2054
|
return;
|
|
1537
2055
|
}
|
|
1538
2056
|
|
|
1539
|
-
// On heal,
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
// the
|
|
1543
|
-
|
|
2057
|
+
// On heal, reconcile the `_managedPreviewOrigin` marker to the healed value's
|
|
2058
|
+
// own shape. The rotted→same-origin heal (seed carries NO marker) DROPS any
|
|
2059
|
+
// stale marker (deepMergeJson deletes a key whose overlay value is null) — the
|
|
2060
|
+
// constant same-origin path has nothing for the monitor to manage, exactly
|
|
2061
|
+
// matching the manual `config-override --unset _managedPreviewOrigin` fix. The
|
|
2062
|
+
// wrong-mode→dedicated heal (seed carries the marker) KEEPS it — that origin
|
|
2063
|
+
// IS managed (the monitor's gated rotation push owns it), so the marker must
|
|
2064
|
+
// survive so the next respawn recognises the value as ours.
|
|
2065
|
+
const overlay = healing
|
|
2066
|
+
? { ...seed, _managedPreviewOrigin: seed._managedPreviewOrigin ?? null }
|
|
2067
|
+
: seed;
|
|
1544
2068
|
console.error(
|
|
1545
2069
|
healing
|
|
1546
2070
|
? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
|
|
@@ -1560,20 +2084,114 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1560
2084
|
}
|
|
1561
2085
|
}
|
|
1562
2086
|
|
|
2087
|
+
/** READY-probe auto-heal: after the editor container is verifiably Up, ask the
|
|
2088
|
+
* running editor's own `/api/config` for its stack verdict and effective
|
|
2089
|
+
* preview origin, and heal a wrong-moded seed in place — never failing the add.
|
|
2090
|
+
*
|
|
2091
|
+
* The race this closes (VM7, 2026-06-10): the pre-container `seedPreviewOrigin`
|
|
2092
|
+
* at line ~1906 runs BEFORE onboarding writes stack.json, so it defaults to
|
|
2093
|
+
* `sameOriginSafe: true` and seeds the `/__codeyam_preview` subpath. For a
|
|
2094
|
+
* client-routed stack (Next.js) the editor then reports `previewSameOriginSafe:
|
|
2095
|
+
* false` but, with only the subpath in editor.local.json, falls its
|
|
2096
|
+
* `dedicated_preview_origin` back to `http://127.0.0.1:<appPort>` — which a
|
|
2097
|
+
* remote browser renders as "refused to connect". If stack.json appeared since
|
|
2098
|
+
* the first seed (the editor reads it live from disk), we re-run the seed — now
|
|
2099
|
+
* the wrong-mode heal fires (`isWrongModePreviewOrigin`) — and POST
|
|
2100
|
+
* `/api/editor-reload-config` so the running server serves the healed origin
|
|
2101
|
+
* with no restart.
|
|
2102
|
+
*
|
|
2103
|
+
* Best-effort and non-fatal by contract: a probe failure, a still-absent
|
|
2104
|
+
* stack.json (verdict stays `true`), or a heal that can only reach a loopback
|
|
2105
|
+
* bootstrap origin (the operator's `<N>-preview` tunnel not published yet) all
|
|
2106
|
+
* leave the add green. The cloudflared-monitor's periodic tick (section 2) is
|
|
2107
|
+
* the durable owner that heals post-add flips — onboarding finishing minutes
|
|
2108
|
+
* after READY. */
|
|
2109
|
+
async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
|
|
2110
|
+
const probe = runGcloud(
|
|
2111
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2112
|
+
command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
|
|
2113
|
+
}),
|
|
2114
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2115
|
+
);
|
|
2116
|
+
if (probe.status !== 0 || typeof probe.stdout !== "string") {
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
let config;
|
|
2120
|
+
try {
|
|
2121
|
+
config = JSON.parse(probe.stdout);
|
|
2122
|
+
} catch {
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
if (!config || typeof config !== "object") return;
|
|
2126
|
+
|
|
2127
|
+
// The editor's own verdict (section 3's `/api/config` field) plus the
|
|
2128
|
+
// wrong-mode predicate (a subpath / relative / loopback origin on a
|
|
2129
|
+
// not-same-origin-safe stack) — the exact dead-end the dedicated-origin
|
|
2130
|
+
// fallback lands on. Same-origin-safe stacks (verdict true) never enter here.
|
|
2131
|
+
if (config.previewSameOriginSafe !== false) return;
|
|
2132
|
+
if (!isWrongModePreviewOrigin(config, false)) return;
|
|
2133
|
+
|
|
2134
|
+
console.error(
|
|
2135
|
+
`READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
|
|
2136
|
+
`but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
|
|
2137
|
+
);
|
|
2138
|
+
// Re-run the seed: stack.json is now readable, so the wrong-mode heal writes
|
|
2139
|
+
// the dedicated origin into editor.local.json (a published `<N>-preview`
|
|
2140
|
+
// tunnel URL, else the localhost bootstrap for the window before the monitor's
|
|
2141
|
+
// first tick).
|
|
2142
|
+
await seedPreviewOrigin(cfg, appPort);
|
|
2143
|
+
// Tell the running server to re-read editor.local.json — no restart (the
|
|
2144
|
+
// footgun that killed VM7's live Claude session).
|
|
2145
|
+
const reload = runGcloud(
|
|
2146
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2147
|
+
command: `curl -fsS --max-time 5 -X POST http://localhost:${EDITOR_PORT}/api/editor-reload-config 2>/dev/null || true`,
|
|
2148
|
+
}),
|
|
2149
|
+
{ stdio: ["ignore", "inherit", "inherit"] },
|
|
2150
|
+
);
|
|
2151
|
+
if (reload.status !== 0) {
|
|
2152
|
+
console.error(
|
|
2153
|
+
` Warning: heal wrote editor.local.json but /api/editor-reload-config did ` +
|
|
2154
|
+
`not return cleanly — the next monitor tick or cloud:up touch-point will reconcile.`,
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
console.error(
|
|
2158
|
+
` Live Preview origin healed. If the operator <N>-preview tunnel is not ` +
|
|
2159
|
+
`published yet, the cloudflared-monitor's periodic tick provides the ongoing heal.`,
|
|
2160
|
+
);
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
/** Write the chosen agent provider and local API keys into editor.local.json on
|
|
2164
|
+
* the VM BEFORE compose up so the file exists when the bind-mount happens —
|
|
2165
|
+
* the provider seed MUST run before ensureEditorContainerUp, because the editor
|
|
2166
|
+
* caches config.provider at startup and reports it as currentProvider; a post-start
|
|
2167
|
+
* seed leaves the running editor (and therefore the dashboard badge) on the default
|
|
2168
|
+
* "claude" until a config reload. */
|
|
1563
2169
|
async function seedAgentApiKey(cfg) {
|
|
2170
|
+
// The provider seed is unconditional — `buildEditorLocalEnvPatch` always
|
|
2171
|
+
// returns a patch carrying `provider`, so the chosen agent always lands on
|
|
2172
|
+
// the VM. The API-key seed is opportunistic: when the matching key isn't set
|
|
2173
|
+
// locally the patch carries `provider` only, and we still print the
|
|
2174
|
+
// interactive-auth fallback line so the operator knows to authenticate the
|
|
2175
|
+
// agent on the VM later (Gemini's normal path).
|
|
1564
2176
|
const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
|
|
1565
|
-
|
|
2177
|
+
const hasKey = Boolean(patch.env);
|
|
2178
|
+
if (!hasKey) {
|
|
1566
2179
|
console.error(
|
|
1567
|
-
`\nNo ${agentEnvVarName(cfg.agent)} set locally —
|
|
1568
|
-
`Authenticate ${cfg.agent}
|
|
1569
|
-
`
|
|
1570
|
-
|
|
2180
|
+
`\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
|
|
2181
|
+
`Authenticate ${cfg.agent} on the VM by either:\n` +
|
|
2182
|
+
` • exporting ${agentEnvVarName(cfg.agent)} locally and re-running cloud:up, or\n` +
|
|
2183
|
+
` • pasting the key into the Build tab's auth gate in the editor.\n` +
|
|
2184
|
+
`Either path now flows the key through to the agent process on the VM.\n` +
|
|
2185
|
+
`Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
|
|
2186
|
+
`supported headlessly on the VM.\n`,
|
|
1571
2187
|
);
|
|
1572
|
-
return;
|
|
1573
2188
|
}
|
|
1574
2189
|
const repoName = resolveProject(cfg).name;
|
|
1575
2190
|
const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
|
|
1576
|
-
|
|
2191
|
+
const seedLabel = hasKey
|
|
2192
|
+
? `Seeding ${agentEnvVarName(cfg.agent)} into ${remotePath}...`
|
|
2193
|
+
: `Seeding provider '${cfg.agent}' into ${remotePath}...`;
|
|
2194
|
+
console.error(seedLabel);
|
|
1577
2195
|
|
|
1578
2196
|
const read = runGcloud(
|
|
1579
2197
|
buildGcloudArgs("ssh-probe", cfg, {
|
|
@@ -1606,6 +2224,71 @@ async function seedAgentApiKey(cfg) {
|
|
|
1606
2224
|
if (write.status !== 0) {
|
|
1607
2225
|
throw new Error(`Failed to write ${remotePath} on VM`);
|
|
1608
2226
|
}
|
|
2227
|
+
|
|
2228
|
+
await ensureAgentCliInstalled(cfg);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/** Install the selected agent's CLI inside the editor container on the live VM */
|
|
2232
|
+
async function ensureAgentCliInstalled(cfg) {
|
|
2233
|
+
const agent = cfg.agent;
|
|
2234
|
+
const pkg = agentNpmPackage(agent);
|
|
2235
|
+
const container = process.env.CONTAINER || "codeyam-editor-editor-1";
|
|
2236
|
+
|
|
2237
|
+
// First, check if the container is running on the VM host.
|
|
2238
|
+
// If the container is not running, we do nothing and return.
|
|
2239
|
+
const checkProbe = runGcloud(
|
|
2240
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2241
|
+
command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Name}}'`,
|
|
2242
|
+
}),
|
|
2243
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
2244
|
+
);
|
|
2245
|
+
|
|
2246
|
+
const isRunning = checkProbe.status === 0 && checkProbe.stdout.trim() !== "";
|
|
2247
|
+
if (!isRunning) {
|
|
2248
|
+
console.error(` Container ${container} is not running. Skipping build-agent CLI install for now.`);
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
|
|
2253
|
+
|
|
2254
|
+
const containerScript = [
|
|
2255
|
+
"#!/usr/bin/env bash",
|
|
2256
|
+
"set -euo pipefail",
|
|
2257
|
+
`BINARY="${agent}"`,
|
|
2258
|
+
`PACKAGE="${pkg}"`,
|
|
2259
|
+
`AGENT="${agent}"`,
|
|
2260
|
+
"",
|
|
2261
|
+
"if command -v \"$BINARY\" >/dev/null 2>&1; then",
|
|
2262
|
+
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
2263
|
+
" exit 0",
|
|
2264
|
+
"fi",
|
|
2265
|
+
"",
|
|
2266
|
+
"echo \"Installing $PACKAGE globally in container...\"",
|
|
2267
|
+
"if [ \"$AGENT\" = \"claude\" ]; then",
|
|
2268
|
+
" rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
|
|
2269
|
+
" npm install -g \"$PACKAGE\"",
|
|
2270
|
+
" RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
|
|
2271
|
+
" if [ -n \"$RESOLVED\" ]; then",
|
|
2272
|
+
" ln -sf \"$RESOLVED\" /usr/local/bin/claude",
|
|
2273
|
+
" fi",
|
|
2274
|
+
"else",
|
|
2275
|
+
" npm install -g \"$PACKAGE\"",
|
|
2276
|
+
"fi",
|
|
2277
|
+
"echo \" ✓ Successfully installed $BINARY\"",
|
|
2278
|
+
].join("\n");
|
|
2279
|
+
|
|
2280
|
+
const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
|
|
2281
|
+
|
|
2282
|
+
const installCmd = runGcloud(
|
|
2283
|
+
buildGcloudArgs("ssh-probe", cfg, {
|
|
2284
|
+
command: hostCommand,
|
|
2285
|
+
}),
|
|
2286
|
+
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
2287
|
+
);
|
|
2288
|
+
|
|
2289
|
+
if (installCmd.status !== 0) {
|
|
2290
|
+
throw new Error(`Failed to install build-agent CLI in container for agent: ${agent}`);
|
|
2291
|
+
}
|
|
1609
2292
|
}
|
|
1610
2293
|
|
|
1611
2294
|
async function openTunnel(cfg, appPort = VITE_PORT) {
|
|
@@ -1649,38 +2332,644 @@ function promptForInstanceName(expected) {
|
|
|
1649
2332
|
});
|
|
1650
2333
|
}
|
|
1651
2334
|
|
|
2335
|
+
const STREAMED_SCRIPTS = ["scripts/gcp-bootstrap.sh", "scripts/gcp-project-bootstrap.sh"];
|
|
2336
|
+
|
|
2337
|
+
/** Check if the streamed bootstrap scripts have drifted from the remote repository
|
|
2338
|
+
* and abort provisioning if they are out of date to prevent silent container/VM failures. */
|
|
2339
|
+
function checkScriptDrift(cfg) {
|
|
2340
|
+
if (process.env.CODEYAM_SKIP_SCRIPT_DRIFT_CHECK === "1") {
|
|
2341
|
+
console.error(" [Warning] CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1 active. Skipping streamed-script drift preflight check.");
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
const { editorBranch = "main" } = resolveEditorConfig(cfg);
|
|
2346
|
+
|
|
2347
|
+
console.error(`Checking streamed-script drift against origin/${editorBranch}...`);
|
|
2348
|
+
|
|
2349
|
+
// 1. git fetch origin <editorBranch>
|
|
2350
|
+
const fetchResult = spawnSync("git", ["fetch", "origin", editorBranch], { encoding: "utf8" });
|
|
2351
|
+
if (fetchResult.status !== 0) {
|
|
2352
|
+
console.error(` [Warning] Failed to fetch origin ${editorBranch} (${(fetchResult.stderr || "").trim() || "unknown error"}). Skipping drift check.`);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// 2. Find the merge base between HEAD and origin/<editorBranch>
|
|
2357
|
+
const mbResult = spawnSync("git", ["merge-base", "HEAD", `origin/${editorBranch}`], { encoding: "utf8" });
|
|
2358
|
+
let mergeBase = null;
|
|
2359
|
+
if (mbResult.status === 0) {
|
|
2360
|
+
mergeBase = mbResult.stdout.trim();
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
const filesData = [];
|
|
2364
|
+
|
|
2365
|
+
for (const scriptPath of STREAMED_SCRIPTS) {
|
|
2366
|
+
// a. localHash
|
|
2367
|
+
const localHashResult = spawnSync("git", ["hash-object", scriptPath], { encoding: "utf8" });
|
|
2368
|
+
if (localHashResult.status !== 0) {
|
|
2369
|
+
console.error(` [Warning] Failed to compute local hash for ${scriptPath}. Skipping drift check.`);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
const localHash = localHashResult.stdout.trim();
|
|
2373
|
+
|
|
2374
|
+
// b. remoteHash (from origin/<editorBranch>)
|
|
2375
|
+
let remoteHash = null;
|
|
2376
|
+
const remoteHashResult = spawnSync("git", ["rev-parse", `origin/${editorBranch}:${scriptPath}`], { encoding: "utf8" });
|
|
2377
|
+
if (remoteHashResult.status === 0) {
|
|
2378
|
+
remoteHash = remoteHashResult.stdout.trim();
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// c. baseHash (from mergeBase)
|
|
2382
|
+
let baseHash = null;
|
|
2383
|
+
if (mergeBase) {
|
|
2384
|
+
const baseHashResult = spawnSync("git", ["rev-parse", `${mergeBase}:${scriptPath}`], { encoding: "utf8" });
|
|
2385
|
+
if (baseHashResult.status === 0) {
|
|
2386
|
+
baseHash = baseHashResult.stdout.trim();
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// d. dirty status
|
|
2391
|
+
const statusResult = spawnSync("git", ["status", "--porcelain", scriptPath], { encoding: "utf8" });
|
|
2392
|
+
const dirty = statusResult.status === 0 && statusResult.stdout.trim() !== "";
|
|
2393
|
+
|
|
2394
|
+
filesData.push({
|
|
2395
|
+
path: scriptPath,
|
|
2396
|
+
localHash,
|
|
2397
|
+
remoteHash,
|
|
2398
|
+
baseHash,
|
|
2399
|
+
dirty
|
|
2400
|
+
});
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
const { classifyScriptDrift } = require("./script-drift");
|
|
2404
|
+
const verdictResult = classifyScriptDrift({ files: filesData });
|
|
2405
|
+
|
|
2406
|
+
if (verdictResult.verdict === "stale" || verdictResult.verdict === "diverged") {
|
|
2407
|
+
console.error("");
|
|
2408
|
+
console.error("================================================================================");
|
|
2409
|
+
console.error(`✗ ERROR: Streamed scripts have drifted from origin/${editorBranch}!`);
|
|
2410
|
+
console.error(` Verdict: ${verdictResult.verdict.toUpperCase()}`);
|
|
2411
|
+
console.error(" The following files are out of date on your machine:");
|
|
2412
|
+
for (const f of verdictResult.staleFiles) {
|
|
2413
|
+
console.error(` - ${f}`);
|
|
2414
|
+
}
|
|
2415
|
+
console.error("");
|
|
2416
|
+
console.error(" To proceed safely, please update your local checkout first:");
|
|
2417
|
+
console.error(` git pull --no-rebase origin ${editorBranch}`);
|
|
2418
|
+
console.error("");
|
|
2419
|
+
console.error(" If you must bypass this check (emergency provisioning), set:");
|
|
2420
|
+
console.error(" CODEYAM_SKIP_SCRIPT_DRIFT_CHECK=1");
|
|
2421
|
+
console.error("================================================================================");
|
|
2422
|
+
console.error("");
|
|
2423
|
+
process.exit(1);
|
|
2424
|
+
} else if (verdictResult.verdict === "local-ahead") {
|
|
2425
|
+
console.error(" [Warning] Streaming locally-modified scripts to VM (developer mode).");
|
|
2426
|
+
} else {
|
|
2427
|
+
console.error(" ✓ Streamed scripts are in sync with remote.");
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
1652
2431
|
// =============================================================================
|
|
1653
2432
|
// Subcommand handlers
|
|
1654
2433
|
// =============================================================================
|
|
1655
2434
|
|
|
1656
|
-
|
|
2435
|
+
/** Decide whether a VM's Add/Destroy should wire LB ingress, and with what
|
|
2436
|
+
* coordinates. Wiring fires iff a fleet domain is configured (cloud.json
|
|
2437
|
+
* `fleetDomain` or FLEET_DOMAIN env) AND the instance is a numbered fleet VM
|
|
2438
|
+
* (`resolveFleetVmNumber` → non-null). A non-fleet client project, or no domain,
|
|
2439
|
+
* returns `{ wire: false }` so the legacy quick-tunnel path is untouched. A
|
|
2440
|
+
* malformed domain throws (via resolveIngressDomain) — a typo must fail loud.
|
|
2441
|
+
* `env` is injectable for tests. Pure decision; the spawn lives in runVmIngress. */
|
|
2442
|
+
function ingressWiringPlan(cfg = {}, env = process.env) {
|
|
2443
|
+
const domain = resolveIngressDomain(cfg, env);
|
|
2444
|
+
if (!domain) return { wire: false, reason: "no-domain" };
|
|
2445
|
+
const vmNumber = resolveFleetVmNumber(cfg.instanceName);
|
|
2446
|
+
if (vmNumber == null) return { wire: false, reason: "not-fleet-vm", domain };
|
|
2447
|
+
// Resolve the custom hostname slug, if any: the Add-time --vm-slug flag on cfg
|
|
2448
|
+
// wins (validated/normalized in cmdUp), else the persisted sidecar entry (the
|
|
2449
|
+
// Destroy path, where cfg carries no slug). null ⇒ the numeric vm-<N> host.
|
|
2450
|
+
const slug =
|
|
2451
|
+
cfg.vmSlug && String(cfg.vmSlug).trim() !== ""
|
|
2452
|
+
? normalizeVmSlug(cfg.vmSlug)
|
|
2453
|
+
: slugFor(vmNumber);
|
|
2454
|
+
return { wire: true, domain, vmNumber, slug };
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/** The fleet instance-name prefix vm-ingress.sh expects (`<base>-`, e.g.
|
|
2458
|
+
* `codeyam-editor-codeyam-editor-`). Derived from the canonical editor repo so
|
|
2459
|
+
* it stays in lockstep with `resolveFleetVmNumber`'s base, rather than relying
|
|
2460
|
+
* on the script's hard-coded default. */
|
|
2461
|
+
function fleetInstancePrefix() {
|
|
2462
|
+
return `${deriveInstanceName(CANONICAL_EDITOR_REPO)}-`;
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
/** Wire (`up`) or unwire (`down`) one VM's LB backends by spawning
|
|
2466
|
+
* scripts/gcp/vm-ingress.sh with the resolved coordinates. Returns the spawnSync
|
|
2467
|
+
* result so callers decide fatality — Add treats a non-zero as a loud warning
|
|
2468
|
+
* (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
|
|
2469
|
+
* teardown still removes the instance. */
|
|
2470
|
+
function runVmIngress(cfg, action, vmNumber, domain, appPort, slug) {
|
|
2471
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
2472
|
+
const inv = vmIngressInvocation({
|
|
2473
|
+
scriptPath,
|
|
2474
|
+
vmNumber,
|
|
2475
|
+
action,
|
|
2476
|
+
domain,
|
|
2477
|
+
project: cfg.project,
|
|
2478
|
+
zone: cfg.zone,
|
|
2479
|
+
appPort,
|
|
2480
|
+
instancePrefix: fleetInstancePrefix(),
|
|
2481
|
+
slug,
|
|
2482
|
+
});
|
|
2483
|
+
return spawnSync(inv.command, inv.args, {
|
|
2484
|
+
stdio: "inherit",
|
|
2485
|
+
env: { ...process.env, ...inv.env },
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/** Add-time hook: wire VM-N onto the shared LB when a fleet domain is set.
|
|
2490
|
+
* Best-effort + loud — a wiring hiccup must never abort an otherwise-green Add
|
|
2491
|
+
* (the VM is reachable operator-side over SSH regardless), so on failure it
|
|
2492
|
+
* prints the exact re-run command rather than throwing. No-op (and silent) in
|
|
2493
|
+
* legacy quick-tunnel mode or for a non-fleet client project. */
|
|
2494
|
+
function wireVmIngressOnAdd(cfg, appPort, env = process.env) {
|
|
2495
|
+
const plan = ingressWiringPlan(cfg, env);
|
|
2496
|
+
if (!plan.wire) return;
|
|
2497
|
+
const label = plan.slug || `vm-${plan.vmNumber}`;
|
|
2498
|
+
console.error(` wiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
|
|
2499
|
+
const res = runVmIngress(cfg, "up", plan.vmNumber, plan.domain, appPort, plan.slug);
|
|
2500
|
+
if (res.status === 0) {
|
|
2501
|
+
console.error(` ✓ LB ingress wired: https://${label}.editor.${plan.domain}`);
|
|
2502
|
+
} else {
|
|
2503
|
+
const rewire =
|
|
2504
|
+
`FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} ` +
|
|
2505
|
+
`bash scripts/gcp/vm-ingress.sh ${plan.vmNumber} up`;
|
|
2506
|
+
console.error(
|
|
2507
|
+
` ⚠ LB ingress wiring for VM-${plan.vmNumber} did not complete cleanly. ` +
|
|
2508
|
+
`The VM is provisioned; re-wire with:\n ${rewire}`,
|
|
2509
|
+
);
|
|
2510
|
+
// Machine-readable degraded marker so the unwired VM lands an amber card with
|
|
2511
|
+
// its remediation instead of a falsely-green "+ wired" (the VM7-10 bug). The
|
|
2512
|
+
// dashboard's add-job scanner (server.js) keys off `CY_DEGRADED <reason>`,
|
|
2513
|
+
// exactly as it does for a failed creds copy / `npm run setup`. Best-effort:
|
|
2514
|
+
// the Add stays green (the VM is reachable operator-side) — the marker makes
|
|
2515
|
+
// the missing public ingress VISIBLE, it doesn't abort provisioning.
|
|
2516
|
+
console.log(
|
|
2517
|
+
`CY_DEGRADED ingress-not-wired: VM-${plan.vmNumber} has no public LB ingress ` +
|
|
2518
|
+
`(vm-ingress.sh up exited ${res.status == null ? "abnormally" : res.status}). Re-wire: ${rewire}`,
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
/** Destroy-time hook: unwire VM-N's LB backends before the instance is deleted,
|
|
2524
|
+
* mirroring the destroy-time quick-tunnel cleanup so no orphaned NEG / backend
|
|
2525
|
+
* service / host rule is left behind. Best-effort + loud, and wrapped so a
|
|
2526
|
+
* malformed-domain throw can't block the instance deletion the operator asked
|
|
2527
|
+
* for. No-op in legacy mode / for a non-fleet project. Purely LB ingress
|
|
2528
|
+
* teardown — slug cleanup is NOT done here (it was, but that coupled slug
|
|
2529
|
+
* release to `plan.wire` and stranded non-wired VMs' slugs); `cmdDestroy` now
|
|
2530
|
+
* owns the unconditional `removeSlug`. */
|
|
2531
|
+
function unwireVmIngressOnDestroy(cfg, env = process.env) {
|
|
2532
|
+
let plan;
|
|
2533
|
+
try {
|
|
2534
|
+
plan = ingressWiringPlan(cfg, env);
|
|
2535
|
+
} catch (e) {
|
|
2536
|
+
console.error(` Warning: skipped LB ingress teardown (${e.message}).`);
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
if (!plan.wire) return;
|
|
2540
|
+
console.error(` unwiring LB ingress for VM-${plan.vmNumber} on ${plan.domain}...`);
|
|
2541
|
+
// Pass the SAME slug the host rule was wired with so the slug host (not a stale
|
|
2542
|
+
// vm-<N> one) is removed; resolved from the sidecar by ingressWiringPlan.
|
|
2543
|
+
const res = runVmIngress(cfg, "down", plan.vmNumber, plan.domain, undefined, plan.slug);
|
|
2544
|
+
if (res.status !== 0) {
|
|
2545
|
+
console.error(
|
|
2546
|
+
` Warning: LB ingress teardown for VM-${plan.vmNumber} did not complete; ` +
|
|
2547
|
+
`orphaned LB resources may remain. Re-run: ` +
|
|
2548
|
+
`FLEET_DOMAIN=${plan.domain}${plan.slug ? ` VM_SLUG=${plan.slug}` : ""} bash scripts/gcp/vm-ingress.sh ${plan.vmNumber} down`,
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
/** Grant or revoke per-VM IAP access by spawning scripts/gcp/iap-access.sh with
|
|
2554
|
+
* the resolved coordinates. Returns the spawnSync result so callers decide
|
|
2555
|
+
* fatality — Add treats a non-zero as a loud warning (the VM is provisioned;
|
|
2556
|
+
* access can be re-applied), Destroy likewise warns so a teardown still removes
|
|
2557
|
+
* the instance. `principals` is the validated list to grant/revoke. */
|
|
2558
|
+
function runIapAccess(cfg, vmNumber, action, principals) {
|
|
2559
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp", "iap-access.sh");
|
|
2560
|
+
const inv = iapAccessInvocation({
|
|
2561
|
+
scriptPath,
|
|
2562
|
+
vmNumber,
|
|
2563
|
+
action,
|
|
2564
|
+
principals,
|
|
2565
|
+
project: cfg.project,
|
|
2566
|
+
zone: cfg.zone,
|
|
2567
|
+
});
|
|
2568
|
+
return spawnSync(inv.command, inv.args, {
|
|
2569
|
+
stdio: "inherit",
|
|
2570
|
+
env: { ...process.env, ...inv.env },
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/** Add-time hook: reconcile VM-N's access whitelist to *exactly* the requested
|
|
2575
|
+
* list, making provision the authoritative clean-slate point so a freed-and-
|
|
2576
|
+
* reused VM number never inherits the previous tenant's grants. Computes the
|
|
2577
|
+
* principals the prior tenant had that this Add does not want (`staleOnly`) and
|
|
2578
|
+
* revokes them from the per-VM backend services, grants the requested list, then
|
|
2579
|
+
* rewrites the sidecar to exactly the desired list (clearing the entry when the
|
|
2580
|
+
* list is empty). The gcloud revoke/grant only fire when the VM is wired onto the
|
|
2581
|
+
* LB (a fleet domain is set) — the backend services the bindings target exist
|
|
2582
|
+
* only then — but the sidecar is reconciled regardless, so a domain-less project
|
|
2583
|
+
* still starts clean and a later domain'd re-add inherits nothing stale. Add no
|
|
2584
|
+
* longer depends on Destroy having cleaned up (a dashboard destroy bypasses the
|
|
2585
|
+
* CLI revoke path). Best-effort + loud, the same posture as wireVmIngressOnAdd:
|
|
2586
|
+
* a revoke/grant hiccup (e.g. the SA missing IAP-admin) prints the exact re-run
|
|
2587
|
+
* command rather than aborting an otherwise-green Add. `cfg.vmAccess` is the
|
|
2588
|
+
* array (or comma-separated string) cmdUp validated; absent ⇒ an empty desired
|
|
2589
|
+
* list (the clean-slate case). */
|
|
2590
|
+
function applyVmAccessOnAdd(cfg, env = process.env) {
|
|
2591
|
+
const desired = normalizeAccessList(cfg.vmAccess || []);
|
|
2592
|
+
let plan;
|
|
2593
|
+
try {
|
|
2594
|
+
plan = ingressWiringPlan(cfg, env);
|
|
2595
|
+
} catch {
|
|
2596
|
+
// A malformed domain must not block an otherwise-green Add; fall back to the
|
|
2597
|
+
// name-derived number so the sidecar still reconciles to a clean slate.
|
|
2598
|
+
plan = { wire: false };
|
|
2599
|
+
}
|
|
2600
|
+
const vmNumber = plan.wire ? plan.vmNumber : resolveFleetVmNumber(cfg.instanceName);
|
|
2601
|
+
if (vmNumber == null) return; // not a fleet VM — nothing to reconcile
|
|
2602
|
+
const stale = accessFor(vmNumber);
|
|
2603
|
+
const staleOnly = stale.filter((p) => !desired.includes(p));
|
|
2604
|
+
|
|
2605
|
+
if (plan.wire) {
|
|
2606
|
+
if (staleOnly.length > 0) {
|
|
2607
|
+
console.error(` revoking ${staleOnly.length} stale IAP principal(s) from VM-${vmNumber} (prior tenant)...`);
|
|
2608
|
+
const res = runIapAccess(cfg, vmNumber, "revoke", staleOnly);
|
|
2609
|
+
if (res.status !== 0) {
|
|
2610
|
+
console.error(
|
|
2611
|
+
` ⚠ IAP access revoke for VM-${vmNumber} did not complete cleanly. ` +
|
|
2612
|
+
`The sidecar is reconciled; re-revoke with:\n` +
|
|
2613
|
+
` VM_ACCESS=${staleOnly.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} revoke`,
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
if (desired.length > 0) {
|
|
2618
|
+
console.error(` granting IAP access to VM-${vmNumber} for ${desired.length} principal(s)...`);
|
|
2619
|
+
const res = runIapAccess(cfg, vmNumber, "grant", desired);
|
|
2620
|
+
if (res.status === 0) {
|
|
2621
|
+
console.error(` ✓ IAP access granted: ${desired.join(", ")}`);
|
|
2622
|
+
} else {
|
|
2623
|
+
console.error(
|
|
2624
|
+
` ⚠ IAP access grant for VM-${vmNumber} did not complete cleanly. ` +
|
|
2625
|
+
`The VM is provisioned; re-grant with:\n` +
|
|
2626
|
+
` VM_ACCESS=${desired.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} grant`,
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
} else if (desired.length > 0) {
|
|
2631
|
+
console.error(
|
|
2632
|
+
` (no fleet domain / not a fleet VM — IAP access for ${desired.length} principal(s) not applied; ` +
|
|
2633
|
+
`set a fleet domain and re-run, or use cloud:access)`,
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
// Sidecar reconcile (always, for a fleet VM): record exactly what was granted —
|
|
2638
|
+
// the desired list when wired, or empty when not (nothing was applied without
|
|
2639
|
+
// backend services). Either way setAccess deletes the entry on empty, so a
|
|
2640
|
+
// reused number never carries a prior tenant's stale entry forward into the
|
|
2641
|
+
// dashboard display or a later domain'd re-add.
|
|
2642
|
+
const granted = plan.wire ? desired : [];
|
|
2643
|
+
try {
|
|
2644
|
+
setAccess(vmNumber, granted);
|
|
2645
|
+
} catch (e) {
|
|
2646
|
+
console.error(` ⚠ could not persist VM-${vmNumber} access list (${e.message}).`);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// The directory the add-tail in-flight marker lives in — `os.tmpdir()` (not a
|
|
2651
|
+
// hard-coded `/tmp`) so the windows-portability static check stays happy and the
|
|
2652
|
+
// dashboard reads the same path via the shared `ingressInflightMarkerPath` builder.
|
|
2653
|
+
const INGRESS_INFLIGHT_DIR = os.tmpdir();
|
|
2654
|
+
|
|
2655
|
+
/** Start the slow LB-ingress wiring (+ IAP access grant) for a just-added VM as a
|
|
2656
|
+
* DETACHED background child and return immediately, so the Add completes at
|
|
2657
|
+
* container-healthy instead of blocking ~10 min on the wiring tail. The detached
|
|
2658
|
+
* child re-execs THIS cloud.js as `wire-add-tail` in its OWN session
|
|
2659
|
+
* (`detached: true` + `unref`), so when the dashboard reaps `cloud:up` the wiring
|
|
2660
|
+
* — and any in-flight `backend-services create` — is never SIGTERMed (the add-time
|
|
2661
|
+
* 503 strand). cmdUp writes the in-flight marker BEFORE the spawn so it exists the
|
|
2662
|
+
* instant this returns (the dashboard auto-rewire gather then skips this VM until
|
|
2663
|
+
* its first wiring finishes); the child unlinks it on completion. A no-op when the
|
|
2664
|
+
* add wouldn't wire anyway (legacy quick-tunnel / non-fleet) — there the cheap
|
|
2665
|
+
* sidecar reconcile runs inline. Best-effort + loud: a spawn failure falls back to
|
|
2666
|
+
* running the tail inline rather than dropping the wiring on the floor. */
|
|
2667
|
+
function startDetachedAddWiring(cfg, appPort, env = process.env) {
|
|
2668
|
+
let plan;
|
|
2669
|
+
try {
|
|
2670
|
+
plan = ingressWiringPlan(cfg, env);
|
|
2671
|
+
} catch {
|
|
2672
|
+
plan = { wire: false };
|
|
2673
|
+
}
|
|
2674
|
+
if (!plan.wire) {
|
|
2675
|
+
// Nothing slow to detach: wiring is a no-op and the access reconcile is a cheap
|
|
2676
|
+
// sidecar write. Run them inline so the clean-slate invariant still holds.
|
|
2677
|
+
wireVmIngressOnAdd(cfg, appPort, env);
|
|
2678
|
+
applyVmAccessOnAdd(cfg, env);
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
const marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, plan.vmNumber);
|
|
2682
|
+
const logPath = path.join(INGRESS_INFLIGHT_DIR, `cy-wire-add-vm-${plan.vmNumber}.log`);
|
|
2683
|
+
try {
|
|
2684
|
+
fs.writeFileSync(marker, String(Date.now()));
|
|
2685
|
+
const out = fs.openSync(logPath, "a");
|
|
2686
|
+
const inv = detachedAddTailInvocation({
|
|
2687
|
+
nodePath: process.execPath,
|
|
2688
|
+
scriptPath: __filename,
|
|
2689
|
+
cfg,
|
|
2690
|
+
appPort,
|
|
2691
|
+
});
|
|
2692
|
+
const child = spawn(inv.command, inv.args, {
|
|
2693
|
+
detached: true,
|
|
2694
|
+
stdio: ["ignore", out, out],
|
|
2695
|
+
env: { ...process.env, ...inv.env },
|
|
2696
|
+
});
|
|
2697
|
+
child.unref();
|
|
2698
|
+
fs.closeSync(out);
|
|
2699
|
+
console.error(
|
|
2700
|
+
` LB ingress wiring for VM-${plan.vmNumber} on ${plan.domain} continues in the ` +
|
|
2701
|
+
`background (pid ${child.pid}; log ${logPath}). The Add is complete at ` +
|
|
2702
|
+
`container-healthy; the dashboard reconcile loop converges the public URL.`,
|
|
2703
|
+
);
|
|
2704
|
+
} catch (e) {
|
|
2705
|
+
console.error(
|
|
2706
|
+
` ⚠ could not detach LB ingress wiring for VM-${plan.vmNumber} (${e && e.message}); ` +
|
|
2707
|
+
`running it inline instead.`,
|
|
2708
|
+
);
|
|
2709
|
+
try { fs.unlinkSync(marker); } catch { /* marker may not exist */ }
|
|
2710
|
+
wireVmIngressOnAdd(cfg, appPort, env);
|
|
2711
|
+
applyVmAccessOnAdd(cfg, env);
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
/** `wire-add-tail` (internal): the DETACHED background continuation
|
|
2716
|
+
* `startDetachedAddWiring` spawns. Reads the Add's `cfg`/`appPort` back out of the
|
|
2717
|
+
* env (so it wires EXACTLY what the foreground Add resolved), runs the wiring then
|
|
2718
|
+
* the IAP access grant in the SAME order cmdUp ran them inline, and ALWAYS unlinks
|
|
2719
|
+
* the in-flight marker on the way out — even on a wiring failure — so the
|
|
2720
|
+
* dashboard's auto-rewire backstop takes over instead of being pinned off. Not a
|
|
2721
|
+
* user-facing subcommand; invoked only via re-exec. */
|
|
2722
|
+
function cmdWireAddTail() {
|
|
2723
|
+
let cfg = {};
|
|
2724
|
+
try {
|
|
2725
|
+
cfg = JSON.parse(process.env.CY_WIRE_ADD_CFG || "{}");
|
|
2726
|
+
} catch (e) {
|
|
2727
|
+
console.error(`wire-add-tail: could not parse CY_WIRE_ADD_CFG (${e && e.message}).`);
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
const appPort = process.env.CY_WIRE_ADD_PORT || undefined;
|
|
2731
|
+
let marker = null;
|
|
2732
|
+
try {
|
|
2733
|
+
const vmNumber = resolveFleetVmNumber(cfg.instanceName);
|
|
2734
|
+
if (vmNumber != null) {
|
|
2735
|
+
marker = ingressInflightMarkerPath(INGRESS_INFLIGHT_DIR, vmNumber);
|
|
2736
|
+
}
|
|
2737
|
+
} catch { /* fall through — marker cleanup is best-effort */ }
|
|
2738
|
+
try {
|
|
2739
|
+
wireVmIngressOnAdd(cfg, appPort);
|
|
2740
|
+
applyVmAccessOnAdd(cfg);
|
|
2741
|
+
} finally {
|
|
2742
|
+
if (marker) {
|
|
2743
|
+
try { fs.unlinkSync(marker); } catch { /* already gone — fine */ }
|
|
2744
|
+
}
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/** Destroy-time hook: revoke VM-N's per-backend IAP bindings and clear its access
|
|
2749
|
+
* sidecar entry so a freed-and-reused VM number doesn't inherit a stale
|
|
2750
|
+
* whitelist. Runs BEFORE unwireVmIngressOnDestroy (which deletes the backend
|
|
2751
|
+
* services the bindings live on) so the revoke targets resources that still
|
|
2752
|
+
* exist. Best-effort + loud; the sidecar is always cleared even if the gcloud
|
|
2753
|
+
* revoke warns. No-op for a non-fleet instance or an empty whitelist. */
|
|
2754
|
+
function revokeVmAccessOnDestroy(cfg, env = process.env) {
|
|
2755
|
+
let vmNumber;
|
|
2756
|
+
try {
|
|
2757
|
+
const plan = ingressWiringPlan(cfg, env);
|
|
2758
|
+
vmNumber = plan.wire ? plan.vmNumber : resolveFleetVmNumber(cfg.instanceName);
|
|
2759
|
+
} catch {
|
|
2760
|
+
// A malformed domain must not block teardown — fall back to the name-derived
|
|
2761
|
+
// number so the sidecar still gets cleared.
|
|
2762
|
+
vmNumber = resolveFleetVmNumber(cfg.instanceName);
|
|
2763
|
+
}
|
|
2764
|
+
if (vmNumber == null) return;
|
|
2765
|
+
const list = accessFor(vmNumber);
|
|
2766
|
+
if (list.length > 0) {
|
|
2767
|
+
console.error(` revoking IAP access for VM-${vmNumber} (${list.length} principal(s))...`);
|
|
2768
|
+
const res = runIapAccess(cfg, vmNumber, "revoke", list);
|
|
2769
|
+
if (res.status !== 0) {
|
|
2770
|
+
console.error(
|
|
2771
|
+
` Warning: IAP access revoke for VM-${vmNumber} did not complete; ` +
|
|
2772
|
+
`bindings are removed when the backend service is deleted, but verify with: ` +
|
|
2773
|
+
`VM_ACCESS=${list.join(",")} bash scripts/gcp/iap-access.sh ${vmNumber} revoke`,
|
|
2774
|
+
);
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
try {
|
|
2778
|
+
clearAccess(vmNumber);
|
|
2779
|
+
} catch (e) {
|
|
2780
|
+
console.error(` Warning: could not clear access list for VM-${vmNumber} (${e.message}).`);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/** Destroy-time hook: free the VM's custom slug so a freed-and-reused VM number
|
|
2785
|
+
* doesn't inherit a stale slug — and, critically, so the name is re-Addable.
|
|
2786
|
+
* Resolved purely from the instance name (NOT the ingress plan) so a non-wired /
|
|
2787
|
+
* legacy / quick-tunnel VM still releases its slug; coupling this to
|
|
2788
|
+
* `plan.wire` was the bug that stranded VM-7's `harvardintech`. Best-effort +
|
|
2789
|
+
* loud so a sidecar write hiccup can't block the teardown. No-op for a
|
|
2790
|
+
* non-fleet instance. Returns the released VM number, or null when none. */
|
|
2791
|
+
function releaseVmSlugOnDestroy(cfg) {
|
|
2792
|
+
const vmNumber = resolveFleetVmNumber(cfg.instanceName);
|
|
2793
|
+
if (vmNumber == null) return null;
|
|
2794
|
+
try {
|
|
2795
|
+
removeSlug(vmNumber);
|
|
2796
|
+
} catch (e) {
|
|
2797
|
+
console.error(` Warning: could not clear slug for VM-${vmNumber} (${e.message}).`);
|
|
2798
|
+
}
|
|
2799
|
+
return vmNumber;
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
async function cmdUp(args, projectDir = rootDir) {
|
|
1657
2803
|
ensureGcloud();
|
|
1658
2804
|
const flags = parseFlags(args);
|
|
1659
|
-
const cfg = loadOrInitCloudConfig(
|
|
2805
|
+
const cfg = loadOrInitCloudConfig(projectDir, flags);
|
|
1660
2806
|
// Fail loud BEFORE creating a GCE instance if an unprovisionable editor
|
|
1661
2807
|
// variant was requested — otherwise we'd spin up a VM, build a dev image,
|
|
1662
2808
|
// and silently ignore the operator's staging/production intent.
|
|
1663
2809
|
assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
|
|
1664
2810
|
|
|
2811
|
+
// Validate a custom --vm-slug BEFORE creating any GCE instance so a bad or
|
|
2812
|
+
// duplicate name fails loud at the boundary, never after a VM is provisioned.
|
|
2813
|
+
// A slug is only meaningful for a numbered fleet VM; on a non-fleet client
|
|
2814
|
+
// project it's dropped (with a note) so the legacy path is untouched.
|
|
2815
|
+
if (cfg.vmSlug != null && String(cfg.vmSlug).trim() !== "") {
|
|
2816
|
+
const slug = normalizeVmSlug(cfg.vmSlug); // throws on bad format/reserved/vm-N
|
|
2817
|
+
const vmNumber = resolveFleetVmNumber(cfg.instanceName);
|
|
2818
|
+
if (vmNumber == null) {
|
|
2819
|
+
console.error(
|
|
2820
|
+
` (instance '${cfg.instanceName}' is not a fleet VM — ignoring --vm-slug ${slug})`,
|
|
2821
|
+
);
|
|
2822
|
+
delete cfg.vmSlug;
|
|
2823
|
+
} else {
|
|
2824
|
+
const owner = vmForSlug(slug);
|
|
2825
|
+
if (owner != null && owner !== vmNumber) {
|
|
2826
|
+
throw new Error(
|
|
2827
|
+
`VM slug "${slug}" is already assigned to VM-${owner} — pick another name`,
|
|
2828
|
+
);
|
|
2829
|
+
}
|
|
2830
|
+
cfg.vmSlug = slug; // carry the normalized form for seeding + wiring
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// Validate a custom --vm-access BEFORE creating any GCE instance so a malformed
|
|
2835
|
+
// principal fails loud at the boundary, never after a VM is provisioned (and
|
|
2836
|
+
// never reaches gcloud). Like --vm-slug it's only meaningful for a numbered
|
|
2837
|
+
// fleet VM; on a non-fleet client project it's dropped with a note. The
|
|
2838
|
+
// normalized array is carried on cfg for the post-provision grant.
|
|
2839
|
+
if (cfg.vmAccess != null && String(cfg.vmAccess).trim() !== "") {
|
|
2840
|
+
const principals = normalizeAccessList(cfg.vmAccess); // throws on malformed
|
|
2841
|
+
const vmNumber = resolveFleetVmNumber(cfg.instanceName);
|
|
2842
|
+
if (vmNumber == null) {
|
|
2843
|
+
console.error(
|
|
2844
|
+
` (instance '${cfg.instanceName}' is not a fleet VM — ignoring --vm-access)`,
|
|
2845
|
+
);
|
|
2846
|
+
delete cfg.vmAccess;
|
|
2847
|
+
} else {
|
|
2848
|
+
cfg.vmAccess = principals; // carry the normalized list for the grant hook
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
checkScriptDrift(cfg);
|
|
2853
|
+
|
|
1665
2854
|
await ensureFirewallRule(cfg);
|
|
1666
|
-
await ensureInstance(cfg);
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
2855
|
+
const { createdThisRun } = await ensureInstance(cfg);
|
|
2856
|
+
|
|
2857
|
+
try {
|
|
2858
|
+
await ensureBootstrapped(cfg);
|
|
2859
|
+
await ensureProjectBootstrapped(cfg);
|
|
2860
|
+
// Resolve the app's dev-server port from the VM's now-bootstrapped config so
|
|
2861
|
+
// the previewOrigin seed and the laptop tunnel both target it (not 5173).
|
|
2862
|
+
const appPort = await readVmAppPort(cfg);
|
|
2863
|
+
await seedPreviewOrigin(cfg, appPort);
|
|
2864
|
+
// Write the GitHub token BEFORE compose up so the credentials file exists
|
|
2865
|
+
// when the bind-mount happens — bind-mounting a missing file would silently
|
|
2866
|
+
// create a directory instead and the agent's first `git pull` would fail.
|
|
2867
|
+
try {
|
|
2868
|
+
await seedGithubCredentials(cfg, { allowNoCredentials: cfg.allowNoGitCredentials });
|
|
2869
|
+
} catch (credErr) {
|
|
2870
|
+
if (credErr instanceof MissingGitPushCredentialsError) {
|
|
2871
|
+
// Never silently healthy: land an amber `CY_DEGRADED` card with the
|
|
2872
|
+
// recovery command (same machine-readable channel the dashboard scanner
|
|
2873
|
+
// reads for ingress-not-wired / failed setup) instead of aborting the
|
|
2874
|
+
// whole add. The VM is otherwise reachable + the Claude session works;
|
|
2875
|
+
// `cloud:reconcile-credentials` heals the push gap in place.
|
|
2876
|
+
console.log(`CY_DEGRADED ${credErr.degradedReason}`);
|
|
2877
|
+
console.error(credErr.message);
|
|
2878
|
+
} else {
|
|
2879
|
+
throw credErr;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
await seedAgentApiKey(cfg);
|
|
2883
|
+
await ensureEditorContainerUp(cfg);
|
|
2884
|
+
// Roster the VM into the fleet dashboard NOW that it's provisioned and the
|
|
2885
|
+
// editor container is up. Runs BEFORE the --no-tunnel early-return so the
|
|
2886
|
+
// dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
|
|
2887
|
+
// with the dashboard's onSuccess roster — a double-add is a harmless no-op).
|
|
2888
|
+
rosterProvisionedVm(cfg.instanceName);
|
|
2889
|
+
// Persist this VM's custom slug into the slug sidecar so the dashboard, the
|
|
2890
|
+
// ingress wiring below, and a later Destroy all resolve the same hostname.
|
|
2891
|
+
// Best-effort + loud (matching the roster-write posture): a sidecar-write
|
|
2892
|
+
// failure warns but never aborts an otherwise-green Add — the URLs simply
|
|
2893
|
+
// fall back to the numeric vm-<N> scheme. Only fires for a slug supplied on
|
|
2894
|
+
// a numbered fleet VM (the early validation already dropped a non-fleet one).
|
|
2895
|
+
if (cfg.vmSlug) {
|
|
2896
|
+
const slugVm = resolveFleetVmNumber(cfg.instanceName);
|
|
2897
|
+
try {
|
|
2898
|
+
setSlug(slugVm, cfg.vmSlug);
|
|
2899
|
+
console.error(` ✓ assigned slug "${cfg.vmSlug}" to VM-${slugVm}`);
|
|
2900
|
+
} catch (e) {
|
|
2901
|
+
console.error(
|
|
2902
|
+
` ⚠ failed to persist slug "${cfg.vmSlug}" for VM-${slugVm} (${e && e.message}); ` +
|
|
2903
|
+
`URLs fall back to vm-${slugVm}.`,
|
|
2904
|
+
);
|
|
2905
|
+
delete cfg.vmSlug; // keep wiring + display consistent with what was persisted
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
// Terminal gate: provisioning is NOT "done" until the editor container is
|
|
2909
|
+
// verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
|
|
2910
|
+
// own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
|
|
2911
|
+
// us — a run reported success with no container). A failure here is a loud
|
|
2912
|
+
// non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
|
|
2913
|
+
await assertEditorContainerRunning(cfg);
|
|
2914
|
+
// Wire this VM onto the shared Google HTTPS LB when a fleet domain is
|
|
2915
|
+
// configured — the stable-URL replacement for the quick-tunnel mint. Runs
|
|
2916
|
+
// AFTER the terminal container gate so the editor/preview ports the NEG
|
|
2917
|
+
// targets are live, and is best-effort (the VM is reachable over SSH
|
|
2918
|
+
// regardless). The wiring (+ the IAP access grant that depends on it) is kicked
|
|
2919
|
+
// off DETACHED so the Add returns at container-healthy instead of blocking
|
|
2920
|
+
// ~10 min on the EXTERNAL_MANAGED backend-services create + propagation tail;
|
|
2921
|
+
// the detached child outlives this process so the dashboard's reap of cloud:up
|
|
2922
|
+
// can't SIGTERM an in-flight `backend-services create` (the add-time 503
|
|
2923
|
+
// strand), and the dashboard's auto-rewire gather converges the public URL in
|
|
2924
|
+
// the background. A no-op in legacy quick-tunnel mode / for a non-fleet project
|
|
2925
|
+
// (there the cheap sidecar reconcile runs inline). See
|
|
2926
|
+
// startDetachedAddWiring → wire-add-tail.
|
|
2927
|
+
startDetachedAddWiring(cfg, appPort);
|
|
2928
|
+
// Now that the editor answers on its own surface, ask it whether the
|
|
2929
|
+
// pre-container seed wrong-moded the previewOrigin (the stack.json race) and
|
|
2930
|
+
// heal it in place. Best-effort, never fatal — wrapped so a probe hiccup
|
|
2931
|
+
// can't abort an otherwise-green add.
|
|
2932
|
+
try {
|
|
2933
|
+
await healPreviewOriginAtReady(cfg, appPort);
|
|
2934
|
+
} catch (healErr) {
|
|
2935
|
+
console.error(
|
|
2936
|
+
` Warning: READY preview-origin auto-heal probe failed (${healErr.message}); ` +
|
|
2937
|
+
`the cloudflared-monitor tick will reconcile.`,
|
|
2938
|
+
);
|
|
2939
|
+
}
|
|
2940
|
+
} catch (err) {
|
|
2941
|
+
if (createdThisRun) {
|
|
2942
|
+
console.error(
|
|
2943
|
+
`✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
|
|
2944
|
+
);
|
|
2945
|
+
// Drop the slug + access sidecar entries for this brand-new VM so a
|
|
2946
|
+
// torn-down number doesn't leave a dangling slug or whitelist behind
|
|
2947
|
+
// (best-effort — never block teardown).
|
|
2948
|
+
const torndownVm = resolveFleetVmNumber(cfg.instanceName);
|
|
2949
|
+
if (cfg.vmSlug && torndownVm != null) {
|
|
2950
|
+
try { removeSlug(torndownVm); } catch { /* best-effort cleanup */ }
|
|
2951
|
+
}
|
|
2952
|
+
if (cfg.vmAccess && torndownVm != null) {
|
|
2953
|
+
try { clearAccess(torndownVm); } catch { /* best-effort cleanup */ }
|
|
2954
|
+
}
|
|
2955
|
+
try {
|
|
2956
|
+
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
2957
|
+
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
2958
|
+
if (del.status !== 0) {
|
|
2959
|
+
console.error(
|
|
2960
|
+
` Warning: failed to delete instance ${cfg.instanceName} during abort teardown. ` +
|
|
2961
|
+
`Please delete it manually with 'npm run cloud:destroy'.`,
|
|
2962
|
+
);
|
|
2963
|
+
}
|
|
2964
|
+
} catch (delErr) {
|
|
2965
|
+
console.error(
|
|
2966
|
+
` Warning: error deleting instance ${cfg.instanceName} during abort teardown: ${delErr.message}. ` +
|
|
2967
|
+
`Please delete it manually with 'npm run cloud:destroy'.`,
|
|
2968
|
+
);
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
throw err;
|
|
2972
|
+
}
|
|
1684
2973
|
// --no-tunnel: provisioning-only mode (the fleet dashboard opens its own
|
|
1685
2974
|
// per-VM tunnel). Skipping openTunnel lets cloud:up EXIT cleanly instead of
|
|
1686
2975
|
// hanging on a tunnel, and — critically for parallel multi-add — avoids N
|
|
@@ -1694,10 +2983,10 @@ async function cmdUp(args) {
|
|
|
1694
2983
|
attachKeepAliveAndTeardown(tunnelChild);
|
|
1695
2984
|
}
|
|
1696
2985
|
|
|
1697
|
-
async function cmdTunnel(args) {
|
|
2986
|
+
async function cmdTunnel(args, projectDir = rootDir) {
|
|
1698
2987
|
ensureGcloud();
|
|
1699
2988
|
const flags = parseFlags(args);
|
|
1700
|
-
const existing = loadCloudConfig(
|
|
2989
|
+
const existing = loadCloudConfig(projectDir);
|
|
1701
2990
|
if (!existing) {
|
|
1702
2991
|
throw new Error(
|
|
1703
2992
|
"No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
|
|
@@ -1725,10 +3014,10 @@ async function cmdTunnel(args) {
|
|
|
1725
3014
|
attachKeepAliveAndTeardown(tunnelChild);
|
|
1726
3015
|
}
|
|
1727
3016
|
|
|
1728
|
-
async function cmdStop(args) {
|
|
3017
|
+
async function cmdStop(args, projectDir = rootDir) {
|
|
1729
3018
|
ensureGcloud();
|
|
1730
3019
|
parseFlags(args);
|
|
1731
|
-
const cfg = loadCloudConfig(
|
|
3020
|
+
const cfg = loadCloudConfig(projectDir);
|
|
1732
3021
|
if (!cfg) {
|
|
1733
3022
|
throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
|
|
1734
3023
|
}
|
|
@@ -1744,10 +3033,10 @@ async function cmdStop(args) {
|
|
|
1744
3033
|
);
|
|
1745
3034
|
}
|
|
1746
3035
|
|
|
1747
|
-
async function cmdDestroy(args) {
|
|
3036
|
+
async function cmdDestroy(args, projectDir = rootDir) {
|
|
1748
3037
|
ensureGcloud();
|
|
1749
3038
|
parseFlags(args);
|
|
1750
|
-
const cfg = loadCloudConfig(
|
|
3039
|
+
const cfg = loadCloudConfig(projectDir);
|
|
1751
3040
|
if (!cfg) {
|
|
1752
3041
|
throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
|
|
1753
3042
|
}
|
|
@@ -1758,12 +3047,28 @@ async function cmdDestroy(args) {
|
|
|
1758
3047
|
return;
|
|
1759
3048
|
}
|
|
1760
3049
|
|
|
3050
|
+
// Revoke the VM's per-backend IAP access + clear its whitelist sidecar BEFORE
|
|
3051
|
+
// unwiring, so the revoke targets backend services that still exist (unwire
|
|
3052
|
+
// deletes them). No-op for a non-fleet instance / empty whitelist.
|
|
3053
|
+
revokeVmAccessOnDestroy(cfg);
|
|
3054
|
+
|
|
3055
|
+
// Unwire the VM's LB backends BEFORE deleting the instance (when a fleet
|
|
3056
|
+
// domain is set) so no orphaned NEG / backend service / host rule survives —
|
|
3057
|
+
// mirrors the destroy-time quick-tunnel cleanup. No-op in legacy mode.
|
|
3058
|
+
unwireVmIngressOnDestroy(cfg);
|
|
3059
|
+
|
|
1761
3060
|
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
1762
3061
|
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
1763
3062
|
if (del.status !== 0) {
|
|
1764
3063
|
throw new Error(`Failed to delete instance ${cfg.instanceName}`);
|
|
1765
3064
|
}
|
|
1766
3065
|
|
|
3066
|
+
// Free the VM's custom slug now that the instance is gone, so the name is
|
|
3067
|
+
// re-Addable. Decoupled from unwireVmIngressOnDestroy (whose `plan.wire` gate
|
|
3068
|
+
// used to strand non-wired VMs' slugs); a sibling destroy-time hook like the
|
|
3069
|
+
// access/ingress teardown above.
|
|
3070
|
+
releaseVmSlugOnDestroy(cfg);
|
|
3071
|
+
|
|
1767
3072
|
const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
|
|
1768
3073
|
stdio: ["ignore", "pipe", "ignore"],
|
|
1769
3074
|
});
|
|
@@ -1781,17 +3086,17 @@ async function cmdDestroy(args) {
|
|
|
1781
3086
|
}
|
|
1782
3087
|
|
|
1783
3088
|
try {
|
|
1784
|
-
fs.unlinkSync(cloudConfigPath(
|
|
3089
|
+
fs.unlinkSync(cloudConfigPath(projectDir));
|
|
1785
3090
|
console.error("Deleted .codeyam/cloud.json");
|
|
1786
3091
|
} catch (e) {
|
|
1787
3092
|
if (e.code !== "ENOENT") throw e;
|
|
1788
3093
|
}
|
|
1789
3094
|
}
|
|
1790
3095
|
|
|
1791
|
-
async function cmdStatus(args) {
|
|
3096
|
+
async function cmdStatus(args, projectDir = rootDir) {
|
|
1792
3097
|
ensureGcloud();
|
|
1793
3098
|
parseFlags(args);
|
|
1794
|
-
const cfg = loadCloudConfig(
|
|
3099
|
+
const cfg = loadCloudConfig(projectDir);
|
|
1795
3100
|
if (!cfg) {
|
|
1796
3101
|
console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
1797
3102
|
return;
|
|
@@ -1833,12 +3138,269 @@ async function cmdStatus(args) {
|
|
|
1833
3138
|
);
|
|
1834
3139
|
}
|
|
1835
3140
|
|
|
3141
|
+
/** `cloud:ingress-up` — provision the SHARED, VM-independent LB infra (static
|
|
3142
|
+
* IP, wildcard managed cert, Cloud DNS records, URL map, IAP brand) once for the
|
|
3143
|
+
* fleet by spawning scripts/gcp/google-ingress.sh. Idempotent: re-running against
|
|
3144
|
+
* already-provisioned infra is a safe no-op. Requires a fleet domain (cloud.json
|
|
3145
|
+
* `fleetDomain` or FLEET_DOMAIN env); the per-VM backends are wired by cloud:up's
|
|
3146
|
+
* Add path. Run this before the first fleetDomain Add (or after flipping it on). */
|
|
3147
|
+
async function cmdIngressUp(args, projectDir = rootDir) {
|
|
3148
|
+
ensureGcloud();
|
|
3149
|
+
parseFlags(args);
|
|
3150
|
+
const cfg = loadCloudConfig(projectDir);
|
|
3151
|
+
if (!cfg) {
|
|
3152
|
+
throw new Error(
|
|
3153
|
+
"No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
|
|
3154
|
+
);
|
|
3155
|
+
}
|
|
3156
|
+
const domain = resolveIngressDomain(cfg, process.env);
|
|
3157
|
+
if (!domain) {
|
|
3158
|
+
throw new Error(
|
|
3159
|
+
"No fleet domain configured. Set `fleetDomain` in .codeyam/cloud.json " +
|
|
3160
|
+
"(e.g. via `cloud:up -- --fleet-domain fleet.example.com`) or export FLEET_DOMAIN.",
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
const dnsZone = resolveDnsZone(cfg, process.env);
|
|
3164
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp", "google-ingress.sh");
|
|
3165
|
+
const inv = sharedIngressInvocation({
|
|
3166
|
+
scriptPath,
|
|
3167
|
+
domain,
|
|
3168
|
+
project: cfg.project,
|
|
3169
|
+
zone: cfg.zone,
|
|
3170
|
+
dnsZone,
|
|
3171
|
+
region: regionFromZone(cfg.zone),
|
|
3172
|
+
});
|
|
3173
|
+
console.error(
|
|
3174
|
+
`Provisioning shared LB ingress for ${domain} (project ${cfg.project}, DNS zone ${dnsZone})...`,
|
|
3175
|
+
);
|
|
3176
|
+
const res = spawnSync(inv.command, inv.args, {
|
|
3177
|
+
stdio: "inherit",
|
|
3178
|
+
env: { ...process.env, ...inv.env },
|
|
3179
|
+
});
|
|
3180
|
+
if (res.status !== 0) {
|
|
3181
|
+
throw new Error("scripts/gcp/google-ingress.sh failed; see output above.");
|
|
3182
|
+
}
|
|
3183
|
+
console.error(
|
|
3184
|
+
"✓ shared LB ingress provisioned. Per-VM backends are wired automatically by " +
|
|
3185
|
+
"`cloud:up` (Add) once `fleetDomain` is set in cloud.json.",
|
|
3186
|
+
);
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
/** Detect unwired / half-wired rostered VMs and re-wire them idempotently.
|
|
3190
|
+
* Enumerates the fleet roster (the same source the dashboard reads), runs the
|
|
3191
|
+
* read-only `vm-ingress.sh <N> status` probe per VM, and for any reporting
|
|
3192
|
+
* `unwired` or `partial` re-runs `vm-ingress.sh <N> up` — which skips the
|
|
3193
|
+
* resources already present and creates only the missing backend / host rule.
|
|
3194
|
+
* A no-op in legacy quick-tunnel mode (no fleet domain). Invoked on demand and
|
|
3195
|
+
* automatically at dashboard boot so a fleet that lost ingress self-heals. */
|
|
3196
|
+
async function cmdReconcileIngress(args, projectDir = rootDir) {
|
|
3197
|
+
ensureGcloud();
|
|
3198
|
+
parseFlags(args);
|
|
3199
|
+
const cfg = loadCloudConfig(projectDir) || {};
|
|
3200
|
+
const domain = resolveIngressDomain(cfg, process.env);
|
|
3201
|
+
if (!domain) {
|
|
3202
|
+
console.error(
|
|
3203
|
+
"No fleet domain configured — ingress reconcile is a no-op (legacy quick-tunnel mode).",
|
|
3204
|
+
);
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
const roster = readRoster() || [];
|
|
3208
|
+
if (!roster.length) {
|
|
3209
|
+
console.error("Fleet roster is empty — nothing to reconcile.");
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
|
|
3213
|
+
console.error(`Reconciling LB ingress for ${roster.length} rostered VM(s) on ${domain}...`);
|
|
3214
|
+
const statuses = roster.map((n) => {
|
|
3215
|
+
const slug = slugFor(n);
|
|
3216
|
+
const inv = vmIngressStatusInvocation({
|
|
3217
|
+
scriptPath,
|
|
3218
|
+
vmNumber: n,
|
|
3219
|
+
domain,
|
|
3220
|
+
project: cfg.project,
|
|
3221
|
+
zone: cfg.zone,
|
|
3222
|
+
slug,
|
|
3223
|
+
});
|
|
3224
|
+
const res = spawnSync(inv.command, inv.args, {
|
|
3225
|
+
env: { ...process.env, ...inv.env },
|
|
3226
|
+
encoding: "utf8",
|
|
3227
|
+
});
|
|
3228
|
+
return { vmNumber: n, slug, verdict: parseIngressStatusVerdict(res.stdout || "") };
|
|
3229
|
+
});
|
|
3230
|
+
const targets = selectIngressReconcileTargets(statuses);
|
|
3231
|
+
const targetNums = new Set(targets.map((t) => t.vmNumber));
|
|
3232
|
+
for (const s of statuses) {
|
|
3233
|
+
if (!targetNums.has(s.vmNumber)) {
|
|
3234
|
+
const label = s.slug || `vm-${s.vmNumber}`;
|
|
3235
|
+
console.error(` VM-${s.vmNumber} ${label}: ${s.verdict || "unknown"} → ok`);
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
for (const t of targets) {
|
|
3239
|
+
const label = t.slug || `vm-${t.vmNumber}`;
|
|
3240
|
+
// Resolve the VM's real dev-server port so a re-created preview NEG targets
|
|
3241
|
+
// the right port (not the 5173 bootstrap fallback). Best-effort — readVmAppPort
|
|
3242
|
+
// itself falls back to VITE_PORT on any SSH/parse failure.
|
|
3243
|
+
const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${t.vmNumber}` };
|
|
3244
|
+
const appPort = await readVmAppPort(perVmCfg);
|
|
3245
|
+
const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug);
|
|
3246
|
+
console.error(
|
|
3247
|
+
` VM-${t.vmNumber} ${label}: ${t.verdict} → ${res.status === 0 ? "re-wired" : "re-wire FAILED"}`,
|
|
3248
|
+
);
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
/** `cloud:reconcile-credentials` — self-heal a running VM that came up (or
|
|
3253
|
+
* drifted) without git push credentials, WITHOUT a container restart so a live
|
|
3254
|
+
* Claude session survives. Probes the host staging file + an in-container
|
|
3255
|
+
* `git ls-remote origin`, classifies any failure via {@link decideCredentialHeal}
|
|
3256
|
+
* (so a transient network blip never triggers a needless re-write), and on a
|
|
3257
|
+
* credential gap re-seeds BOTH the durable host file (survives restart via the
|
|
3258
|
+
* entrypoint) and the live in-memory credential-cache daemon (immediate, no
|
|
3259
|
+
* restart). This codifies the manual recovery run on all 9 fleet VMs on
|
|
3260
|
+
* 2026-06-29. */
|
|
3261
|
+
async function cmdReconcileCredentials(args, projectDir = rootDir) {
|
|
3262
|
+
ensureGcloud();
|
|
3263
|
+
parseFlags(args);
|
|
3264
|
+
const cfg = loadCloudConfig(projectDir);
|
|
3265
|
+
if (!cfg) {
|
|
3266
|
+
throw new Error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
3267
|
+
}
|
|
3268
|
+
const repoName = resolveProject(cfg).name;
|
|
3269
|
+
const projectCd = `cd ~/projects/${repoName}`;
|
|
3270
|
+
const composeExec = `docker compose -f docker-compose.cloud.yml exec -T editor`;
|
|
3271
|
+
|
|
3272
|
+
// One SSH round-trip: report staging-file emptiness and the in-container
|
|
3273
|
+
// ls-remote outcome with stable markers we parse below.
|
|
3274
|
+
console.error(`Probing git push credentials on ${cfg.instanceName}...`);
|
|
3275
|
+
const probeCmd =
|
|
3276
|
+
`f=$HOME/.codeyam-cloud-git-credentials; ` +
|
|
3277
|
+
`if [ -s "$f" ]; then echo STAGING=nonempty; else echo STAGING=empty; fi; ` +
|
|
3278
|
+
`${projectCd} && if ${composeExec} git -C /workspace ls-remote origin -h HEAD ` +
|
|
3279
|
+
`>/dev/null 2>/tmp/cy-lsr.err; then echo LSREMOTE=ok; ` +
|
|
3280
|
+
`else echo LSREMOTE=fail; sed 's/^/LSRERR:/' /tmp/cy-lsr.err; fi`;
|
|
3281
|
+
const probe = runGcloud(
|
|
3282
|
+
buildGcloudArgs("ssh-probe", cfg, { command: probeCmd }),
|
|
3283
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
3284
|
+
);
|
|
3285
|
+
if (probe.status !== 0 || typeof probe.stdout !== "string") {
|
|
3286
|
+
throw new Error(`Failed to probe credentials on ${cfg.instanceName} (SSH error).`);
|
|
3287
|
+
}
|
|
3288
|
+
const out = probe.stdout;
|
|
3289
|
+
const stagingEmpty = /STAGING=empty/.test(out);
|
|
3290
|
+
const lsRemoteOk = /LSREMOTE=ok/.test(out);
|
|
3291
|
+
const lsRemoteStderr = out
|
|
3292
|
+
.split("\n")
|
|
3293
|
+
.filter((l) => l.startsWith("LSRERR:"))
|
|
3294
|
+
.map((l) => l.slice("LSRERR:".length))
|
|
3295
|
+
.join("\n");
|
|
3296
|
+
|
|
3297
|
+
const decision = decideCredentialHeal({ stagingEmpty, lsRemoteOk, lsRemoteStderr });
|
|
3298
|
+
console.error(` category=${decision.category}: ${decision.reason}`);
|
|
3299
|
+
if (!decision.heal) {
|
|
3300
|
+
console.error(" ✓ no re-seed needed.");
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
|
|
3304
|
+
// Re-seed needs a token. Reuse seedGithubCredentials for the durable host-file
|
|
3305
|
+
// write (it throws MissingGitPushCredentialsError when none resolves — the
|
|
3306
|
+
// honest "can't heal without a token" outcome).
|
|
3307
|
+
await seedGithubCredentials(cfg);
|
|
3308
|
+
|
|
3309
|
+
// Seed the LIVE in-memory credential-cache daemon over the socket — the same
|
|
3310
|
+
// `git credential approve` shape the entrypoint uses
|
|
3311
|
+
// (scripts/cloud-editor-entrypoint.sh) — so push works immediately with no
|
|
3312
|
+
// container restart. base64 keeps the token out of the shell history across
|
|
3313
|
+
// the gcloud-ssh → docker layers.
|
|
3314
|
+
const { token } = resolveGithubTokenWithSource();
|
|
3315
|
+
const socket = "/run/codeyam/git-credential-cache.socket";
|
|
3316
|
+
const approveStdin = `protocol=https\nhost=github.com\nusername=x-access-token\npassword=${token}\n\n`;
|
|
3317
|
+
const b64 = Buffer.from(approveStdin).toString("base64");
|
|
3318
|
+
const seedCmd =
|
|
3319
|
+
`${projectCd} && echo ${b64} | base64 -d | ${composeExec} ` +
|
|
3320
|
+
`git -c 'credential.helper=cache --timeout=31536000 --socket=${socket}' ` +
|
|
3321
|
+
`credential approve && echo APPROVED`;
|
|
3322
|
+
const seed = runGcloud(
|
|
3323
|
+
buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
|
|
3324
|
+
{ stdio: ["ignore", "pipe", "inherit"] },
|
|
3325
|
+
);
|
|
3326
|
+
if (seed.status !== 0 || !/APPROVED/.test(String(seed.stdout || ""))) {
|
|
3327
|
+
throw new Error(
|
|
3328
|
+
`Re-seeded the host file but failed to seed the live credential cache on ${cfg.instanceName}.`,
|
|
3329
|
+
);
|
|
3330
|
+
}
|
|
3331
|
+
console.error(
|
|
3332
|
+
` ✓ re-seeded host file + live credential cache on ${cfg.instanceName}; ` +
|
|
3333
|
+
`plan-claim sync will recover on the next tick.`,
|
|
3334
|
+
);
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3337
|
+
/** `cloud:access` — edit an existing VM's IAP access whitelist without
|
|
3338
|
+
* re-provisioning. Resolves the VM number from --instance-name (or cloud.json),
|
|
3339
|
+
* mutates the access sidecar, and applies just the delta against the live
|
|
3340
|
+
* backend services via scripts/gcp/iap-access.sh:
|
|
3341
|
+
* --add <principal> grant one principal (idempotent)
|
|
3342
|
+
* --remove <principal> revoke one principal
|
|
3343
|
+
* --list print the current whitelist (no gcloud call)
|
|
3344
|
+
* Exactly one of the three is required. */
|
|
3345
|
+
async function cmdAccess(args, projectDir = rootDir) {
|
|
3346
|
+
const flags = parseFlags(args);
|
|
3347
|
+
const cfg = { ...(loadCloudConfig(projectDir) || {}), ...flags };
|
|
3348
|
+
const instanceName = cfg.instanceName;
|
|
3349
|
+
if (!instanceName) {
|
|
3350
|
+
throw new Error(
|
|
3351
|
+
"cloud:access requires --instance-name <name> (or a .codeyam/cloud.json with one).",
|
|
3352
|
+
);
|
|
3353
|
+
}
|
|
3354
|
+
const vmNumber = resolveFleetVmNumber(instanceName);
|
|
3355
|
+
if (vmNumber == null) {
|
|
3356
|
+
throw new Error(`'${instanceName}' is not a numbered fleet VM — per-VM access does not apply.`);
|
|
3357
|
+
}
|
|
3358
|
+
|
|
3359
|
+
if (flags.list) {
|
|
3360
|
+
const list = accessFor(vmNumber);
|
|
3361
|
+
console.error(`VM-${vmNumber} IAP access whitelist (${list.length}):`);
|
|
3362
|
+
if (list.length === 0) console.error(" (none — only the owner's project-wide grant applies)");
|
|
3363
|
+
for (const p of list) console.error(` ${p}`);
|
|
3364
|
+
return;
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
const adding = flags.add != null && String(flags.add).trim() !== "";
|
|
3368
|
+
const removing = flags.remove != null && String(flags.remove).trim() !== "";
|
|
3369
|
+
if (adding === removing) {
|
|
3370
|
+
throw new Error("cloud:access requires exactly one of --add <principal> / --remove <principal> / --list.");
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
ensureGcloud();
|
|
3374
|
+
const principal = normalizePrincipal(adding ? flags.add : flags.remove); // throws on malformed
|
|
3375
|
+
const action = adding ? "grant" : "revoke";
|
|
3376
|
+
// Mutate the sidecar first so the source of truth reflects intent even if the
|
|
3377
|
+
// gcloud apply later warns; both calls are idempotent.
|
|
3378
|
+
if (adding) addAccess(vmNumber, principal);
|
|
3379
|
+
else removeAccess(vmNumber, principal);
|
|
3380
|
+
|
|
3381
|
+
console.error(`${action === "grant" ? "Granting" : "Revoking"} IAP access for VM-${vmNumber}: ${principal}`);
|
|
3382
|
+
const res = runIapAccess(cfg, vmNumber, action, [principal]);
|
|
3383
|
+
if (res.status === 0) {
|
|
3384
|
+
console.error(` ✓ ${action}ed. Current whitelist: ${accessFor(vmNumber).join(", ") || "(none)"}`);
|
|
3385
|
+
} else {
|
|
3386
|
+
console.error(
|
|
3387
|
+
` ⚠ gcloud ${action} did not complete cleanly; the sidecar was updated. Re-run:\n` +
|
|
3388
|
+
` VM_ACCESS=${principal} bash scripts/gcp/iap-access.sh ${vmNumber} ${action}`,
|
|
3389
|
+
);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
|
|
1836
3393
|
const HANDLERS = {
|
|
1837
3394
|
up: cmdUp,
|
|
1838
3395
|
tunnel: cmdTunnel,
|
|
1839
3396
|
stop: cmdStop,
|
|
1840
3397
|
destroy: cmdDestroy,
|
|
1841
3398
|
status: cmdStatus,
|
|
3399
|
+
"ingress-up": cmdIngressUp,
|
|
3400
|
+
"reconcile-ingress": cmdReconcileIngress,
|
|
3401
|
+
"wire-add-tail": cmdWireAddTail,
|
|
3402
|
+
"reconcile-credentials": cmdReconcileCredentials,
|
|
3403
|
+
access: cmdAccess,
|
|
1842
3404
|
};
|
|
1843
3405
|
|
|
1844
3406
|
async function cloudMain() {
|
|
@@ -1861,7 +3423,10 @@ async function cloudMain() {
|
|
|
1861
3423
|
module.exports = {
|
|
1862
3424
|
// pure helpers
|
|
1863
3425
|
agentEnvVarName,
|
|
3426
|
+
agentNpmPackage,
|
|
1864
3427
|
AGENT_ENV_VARS,
|
|
3428
|
+
AGENT_NPM_PACKAGES,
|
|
3429
|
+
ensureAgentCliInstalled,
|
|
1865
3430
|
KNOWN_FLAGS,
|
|
1866
3431
|
FIREWALL_RULE_NAME,
|
|
1867
3432
|
INSTANCE_TAG,
|
|
@@ -1882,6 +3447,7 @@ module.exports = {
|
|
|
1882
3447
|
resolveSeedPreviewOrigin,
|
|
1883
3448
|
previewSameOriginSafeFromStack,
|
|
1884
3449
|
isRottedManagedPreviewOrigin,
|
|
3450
|
+
isWrongModePreviewOrigin,
|
|
1885
3451
|
resolveEditorConfig,
|
|
1886
3452
|
validateEditorVariant,
|
|
1887
3453
|
assertVariantProvisionable,
|
|
@@ -1889,14 +3455,17 @@ module.exports = {
|
|
|
1889
3455
|
reconcileProjectExclusivity,
|
|
1890
3456
|
resolveGhcrToken,
|
|
1891
3457
|
resolveGhcrPushToken,
|
|
3458
|
+
probeUsableEditorImage,
|
|
1892
3459
|
resolveProject,
|
|
1893
3460
|
clientProjectBootstrapArgs,
|
|
1894
3461
|
cloudConfigPath,
|
|
1895
3462
|
loadCloudConfig,
|
|
1896
3463
|
saveCloudConfig,
|
|
3464
|
+
loadOrInitCloudConfig,
|
|
1897
3465
|
buildEditorLocalEnvPatch,
|
|
1898
3466
|
buildEditorLocalSeedWriteCommand,
|
|
1899
3467
|
seedPreviewOrigin,
|
|
3468
|
+
healPreviewOriginAtReady,
|
|
1900
3469
|
buildGcloudArgs,
|
|
1901
3470
|
sshScriptCommand,
|
|
1902
3471
|
bootstrapRunnerScript,
|
|
@@ -1912,14 +3481,42 @@ module.exports = {
|
|
|
1912
3481
|
FORWARD_ENV_KEYS_ENV,
|
|
1913
3482
|
parseEditorComposeState,
|
|
1914
3483
|
fetchInstanceState,
|
|
3484
|
+
formatGcloudFailure,
|
|
3485
|
+
isTransientSshFailure,
|
|
3486
|
+
runGcloudSshWithRetry,
|
|
3487
|
+
ensureInstance,
|
|
3488
|
+
ensureBootstrapped,
|
|
3489
|
+
assertEditorContainerRunning,
|
|
3490
|
+
checkScriptDrift,
|
|
3491
|
+
STREAMED_SCRIPTS,
|
|
3492
|
+
// ingress wiring (pure decision + spawn wrappers)
|
|
3493
|
+
ingressWiringPlan,
|
|
3494
|
+
fleetInstancePrefix,
|
|
3495
|
+
runVmIngress,
|
|
3496
|
+
wireVmIngressOnAdd,
|
|
3497
|
+
unwireVmIngressOnDestroy,
|
|
3498
|
+
// per-VM IAP access (spawn wrapper + Add/Destroy hooks)
|
|
3499
|
+
runIapAccess,
|
|
3500
|
+
applyVmAccessOnAdd,
|
|
3501
|
+
revokeVmAccessOnDestroy,
|
|
3502
|
+
releaseVmSlugOnDestroy,
|
|
1915
3503
|
// side-effecting handlers
|
|
1916
3504
|
cmdUp,
|
|
1917
3505
|
cmdTunnel,
|
|
1918
3506
|
cmdStop,
|
|
1919
3507
|
cmdDestroy,
|
|
1920
3508
|
cmdStatus,
|
|
3509
|
+
cmdIngressUp,
|
|
3510
|
+
cmdReconcileIngress,
|
|
3511
|
+
cmdReconcileCredentials,
|
|
3512
|
+
cmdAccess,
|
|
1921
3513
|
cloudMain,
|
|
1922
3514
|
seedGithubCredentials,
|
|
3515
|
+
resolveGithubToken,
|
|
3516
|
+
resolveGithubTokenWithSource,
|
|
3517
|
+
MissingGitPushCredentialsError,
|
|
3518
|
+
classifyGitRemoteError,
|
|
3519
|
+
decideCredentialHeal,
|
|
1923
3520
|
readVmAppPort,
|
|
1924
3521
|
resolveLaptopGitIdentity,
|
|
1925
3522
|
};
|