@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +51 -2
- package/npm/fleet-cleanup.js +118 -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 +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
- package/npm/scenario-check.js +603 -32
- package/npm/scenario-handlers.js +78 -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 +329 -50
- 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-BuK0lizt.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-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.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
|
|
@@ -73,11 +120,39 @@ const KNOWN_FLAGS = [
|
|
|
73
120
|
// runs setup by default when the project ships one; pass this for projects
|
|
74
121
|
// where a cloud setup is genuinely wrong (the script's --skip-setup hatch).
|
|
75
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",
|
|
76
151
|
];
|
|
77
152
|
|
|
78
153
|
// Flags that take no value (presence = true). parseFlags must not demand a
|
|
79
154
|
// value for these or `--no-tunnel` would error "requires a value".
|
|
80
|
-
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup"];
|
|
155
|
+
const BOOLEAN_FLAGS = ["no-tunnel", "skip-setup", "list", "allow-no-git-credentials"];
|
|
81
156
|
|
|
82
157
|
const FIREWALL_RULE_NAME = "codeyam-editor-iap-ssh";
|
|
83
158
|
const INSTANCE_TAG = "codeyam-editor";
|
|
@@ -320,6 +395,7 @@ function resolveSeedPreviewOrigin(
|
|
|
320
395
|
appPort = VITE_PORT,
|
|
321
396
|
env = process.env,
|
|
322
397
|
sameOriginSafe = true,
|
|
398
|
+
slug = null,
|
|
323
399
|
) {
|
|
324
400
|
// Same-origin-safe stack (the default — Vite et al.): ALWAYS serve the app
|
|
325
401
|
// same-origin under the reserved relative path, in every fleet mode. The
|
|
@@ -348,15 +424,27 @@ function resolveSeedPreviewOrigin(
|
|
|
348
424
|
// client project (vm == null) keeps the localhost seed even with a domain set.
|
|
349
425
|
if (domain && vm != null) {
|
|
350
426
|
return {
|
|
351
|
-
|
|
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}`,
|
|
352
444
|
_managedPreviewOrigin: MANAGED_PREVIEW_ORIGIN,
|
|
353
445
|
};
|
|
354
446
|
}
|
|
355
|
-
// Quick-tunnel
|
|
356
|
-
// `<N>-preview` quick-tunnel URL (the public origin a remote browser can
|
|
357
|
-
// actually load), else the SSH-tunnel bootstrap localhost origin
|
|
358
|
-
// (`http://localhost:<appPort>`, reached via the laptop `-L` forward). Never
|
|
359
|
-
// the same-origin subpath — that is exactly what recurses for this stack.
|
|
447
|
+
// Quick-tunnel 'tunnel' mode fallback / non-fleet project fallback:
|
|
360
448
|
const previewKey = vm != null ? `${vm}-preview` : null;
|
|
361
449
|
const tunnelUrl =
|
|
362
450
|
previewKey &&
|
|
@@ -411,6 +499,46 @@ function isRottedManagedPreviewOrigin(localJson, sameOriginSafe) {
|
|
|
411
499
|
);
|
|
412
500
|
}
|
|
413
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
|
+
|
|
414
542
|
/** Read the operator-laptop cloudflared-urls.json sidecar. Missing/unparseable
|
|
415
543
|
* → {} (the monitor may not have run yet; the seed falls back to localhost). */
|
|
416
544
|
function readCloudflaredUrls() {
|
|
@@ -491,9 +619,25 @@ function editorImageBootstrapArgs(cfg) {
|
|
|
491
619
|
* so a stale value — e.g. a prior greenfield's `newProject` left in cloud.json
|
|
492
620
|
* — can't override the new intent. Without this, launching `--repo X` after a
|
|
493
621
|
* greenfield run silently provisions the greenfield project (resolveProject
|
|
494
|
-
* prioritizes newProject). Mutates and returns cfg.
|
|
495
|
-
|
|
496
|
-
|
|
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
|
+
}
|
|
497
641
|
if (flags.newProject) { delete cfg.repo; delete cfg.branch; }
|
|
498
642
|
return cfg;
|
|
499
643
|
}
|
|
@@ -546,14 +690,27 @@ function loadCloudConfig(projectDir) {
|
|
|
546
690
|
try {
|
|
547
691
|
return JSON.parse(raw);
|
|
548
692
|
} catch (e) {
|
|
549
|
-
|
|
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
|
+
}
|
|
550
699
|
}
|
|
551
700
|
}
|
|
552
701
|
|
|
553
702
|
function saveCloudConfig(projectDir, cfg) {
|
|
554
703
|
const p = cloudConfigPath(projectDir);
|
|
555
|
-
|
|
556
|
-
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
|
+
}
|
|
557
714
|
}
|
|
558
715
|
|
|
559
716
|
/** Build the JSON patch to merge into the VM's `editor.local.json` so the
|
|
@@ -835,9 +992,21 @@ function loadOrInitCloudConfig(projectDir, flags) {
|
|
|
835
992
|
const existing = loadCloudConfig(projectDir);
|
|
836
993
|
// noTunnel is a per-invocation behavior, never persisted — else a later manual
|
|
837
994
|
// `cloud:up` (zero-arg, reading cloud.json) would silently skip its tunnel.
|
|
838
|
-
|
|
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; };
|
|
839
1000
|
if (existing) {
|
|
840
|
-
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
|
+
}
|
|
841
1010
|
const toSave = persist(merged);
|
|
842
1011
|
if (JSON.stringify(toSave) !== JSON.stringify(existing)) {
|
|
843
1012
|
saveCloudConfig(projectDir, toSave);
|
|
@@ -847,6 +1016,15 @@ function loadOrInitCloudConfig(projectDir, flags) {
|
|
|
847
1016
|
|
|
848
1017
|
const cfg = reconcileProjectExclusivity({ ...flags }, flags);
|
|
849
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
|
+
|
|
850
1028
|
cfg.zone = cfg.zone || gcloudGetValue("config-get-zone");
|
|
851
1029
|
cfg.agent = cfg.agent || "claude";
|
|
852
1030
|
agentEnvVarName(cfg.agent);
|
|
@@ -1352,10 +1530,19 @@ async function ensureProjectBootstrapped(cfg) {
|
|
|
1352
1530
|
// Whitelisted by the dashboard via CY_FORWARD_ENV_KEYS — empty on the
|
|
1353
1531
|
// standalone `cloud:up` path, so that flow is unchanged.
|
|
1354
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;
|
|
1355
1541
|
const command = sshScriptCommand(scriptBody, scriptArgs, {
|
|
1356
1542
|
GITHUB_TOKEN: resolveGithubToken(),
|
|
1357
1543
|
CY_GIT_USER_NAME: laptopGit.name,
|
|
1358
1544
|
CY_GIT_USER_EMAIL: laptopGit.email,
|
|
1545
|
+
...ingressEnv,
|
|
1359
1546
|
...forwardEnv,
|
|
1360
1547
|
});
|
|
1361
1548
|
const result = runGcloud(
|
|
@@ -1451,13 +1638,52 @@ function resolveLaptopGitIdentity(spawnImpl = spawnSync) {
|
|
|
1451
1638
|
* intentionally not done: the cost of one `gh` subprocess per cloud:up is
|
|
1452
1639
|
* trivial and lets a user `gh auth login` between operations. */
|
|
1453
1640
|
function resolveGithubToken() {
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
+
}
|
|
1456
1658
|
const ghOut = spawnSync("gh", ["auth", "token"], { encoding: "utf8" });
|
|
1457
1659
|
if (ghOut.status === 0) {
|
|
1458
|
-
|
|
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";
|
|
1459
1686
|
}
|
|
1460
|
-
return "";
|
|
1461
1687
|
}
|
|
1462
1688
|
|
|
1463
1689
|
/** A PULL-ONLY (`read:packages`) token the VM bootstrap uses to `docker login
|
|
@@ -1491,6 +1717,139 @@ function resolveGhcrPushToken() {
|
|
|
1491
1717
|
return process.env.CODEYAM_GHCR_PUSH_TOKEN || "";
|
|
1492
1718
|
}
|
|
1493
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
|
+
|
|
1494
1853
|
/** Write the laptop's GitHub token into ~/.codeyam-cloud-git-credentials on
|
|
1495
1854
|
* the VM so the cloud compose can mount it into /root/.git-credentials —
|
|
1496
1855
|
* without this, claude / codex / gemini inside the container can clone the
|
|
@@ -1498,17 +1857,24 @@ function resolveGhcrPushToken() {
|
|
|
1498
1857
|
* inlined URL) but cannot `git pull` / `git push` afterward. The file is
|
|
1499
1858
|
* chmod 600 and never committed; if the local environment has no token,
|
|
1500
1859
|
* the placeholder file from gcp-project-bootstrap.sh stays in place. */
|
|
1501
|
-
async function seedGithubCredentials(cfg) {
|
|
1502
|
-
const token =
|
|
1860
|
+
async function seedGithubCredentials(cfg, opts = {}) {
|
|
1861
|
+
const { token, source } = resolveGithubTokenWithSource();
|
|
1503
1862
|
if (!token) {
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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();
|
|
1510
1874
|
}
|
|
1511
|
-
console.error(
|
|
1875
|
+
console.error(
|
|
1876
|
+
`Writing GitHub token (from ${source}) into ~/.codeyam-cloud-git-credentials on the VM...`,
|
|
1877
|
+
);
|
|
1512
1878
|
const credLine = `https://x-access-token:${token}@github.com`;
|
|
1513
1879
|
// Quote the token through one shell layer (gcloud ssh --command); the
|
|
1514
1880
|
// value is base64'd to keep it out of the shell history.
|
|
@@ -1642,36 +2008,63 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1642
2008
|
sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
|
|
1643
2009
|
}
|
|
1644
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;
|
|
1645
2021
|
const seed = resolveSeedPreviewOrigin(
|
|
1646
2022
|
cfg.instanceName,
|
|
1647
2023
|
readCloudflaredUrls(),
|
|
1648
2024
|
appPort,
|
|
1649
2025
|
process.env,
|
|
1650
2026
|
sameOriginSafe,
|
|
2027
|
+
seedSlug,
|
|
1651
2028
|
);
|
|
1652
2029
|
|
|
1653
2030
|
// Skip-on-already-set guard, EXCEPT for self-heal. A non-empty previewOrigin
|
|
1654
|
-
// is normally a user pin or a good prior seed and is left untouched.
|
|
1655
|
-
//
|
|
1656
|
-
//
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
//
|
|
1660
|
-
//
|
|
1661
|
-
//
|
|
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.
|
|
1662
2047
|
const originAlreadySet =
|
|
1663
2048
|
typeof current.previewOrigin === "string" && current.previewOrigin.length > 0;
|
|
1664
2049
|
const healing =
|
|
1665
|
-
originAlreadySet &&
|
|
2050
|
+
originAlreadySet &&
|
|
2051
|
+
(isRottedManagedPreviewOrigin(current, sameOriginSafe) ||
|
|
2052
|
+
isWrongModePreviewOrigin(current, sameOriginSafe));
|
|
1666
2053
|
if (originAlreadySet && !healing) {
|
|
1667
2054
|
return;
|
|
1668
2055
|
}
|
|
1669
2056
|
|
|
1670
|
-
// On heal,
|
|
1671
|
-
//
|
|
1672
|
-
//
|
|
1673
|
-
// the
|
|
1674
|
-
|
|
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;
|
|
1675
2068
|
console.error(
|
|
1676
2069
|
healing
|
|
1677
2070
|
? `Healing rotted managed previewOrigin (${current.previewOrigin}) → ${seed.previewOrigin} in ${remotePath}...`
|
|
@@ -1691,6 +2084,82 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
|
|
|
1691
2084
|
}
|
|
1692
2085
|
}
|
|
1693
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
|
+
|
|
1694
2163
|
/** Write the chosen agent provider and local API keys into editor.local.json on
|
|
1695
2164
|
* the VM BEFORE compose up so the file exists when the bind-mount happens —
|
|
1696
2165
|
* the provider seed MUST run before ensureEditorContainerUp, because the editor
|
|
@@ -1709,9 +2178,12 @@ async function seedAgentApiKey(cfg) {
|
|
|
1709
2178
|
if (!hasKey) {
|
|
1710
2179
|
console.error(
|
|
1711
2180
|
`\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
|
|
1712
|
-
`Authenticate ${cfg.agent}
|
|
1713
|
-
`
|
|
1714
|
-
|
|
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`,
|
|
1715
2187
|
);
|
|
1716
2188
|
}
|
|
1717
2189
|
const repoName = resolveProject(cfg).name;
|
|
@@ -1787,22 +2259,18 @@ async function ensureAgentCliInstalled(cfg) {
|
|
|
1787
2259
|
`AGENT="${agent}"`,
|
|
1788
2260
|
"",
|
|
1789
2261
|
"if command -v \"$BINARY\" >/dev/null 2>&1; then",
|
|
1790
|
-
"
|
|
1791
|
-
"
|
|
1792
|
-
" echo \" ✓ $BINARY already installed and executable at /usr/local/bin/claude\"",
|
|
1793
|
-
" exit 0",
|
|
1794
|
-
" fi",
|
|
1795
|
-
" else",
|
|
1796
|
-
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
1797
|
-
" exit 0",
|
|
1798
|
-
" fi",
|
|
2262
|
+
" echo \" ✓ $BINARY already installed on PATH\"",
|
|
2263
|
+
" exit 0",
|
|
1799
2264
|
"fi",
|
|
1800
2265
|
"",
|
|
1801
2266
|
"echo \"Installing $PACKAGE globally in container...\"",
|
|
1802
2267
|
"if [ \"$AGENT\" = \"claude\" ]; then",
|
|
1803
2268
|
" rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
|
|
1804
2269
|
" npm install -g \"$PACKAGE\"",
|
|
1805
|
-
"
|
|
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",
|
|
1806
2274
|
"else",
|
|
1807
2275
|
" npm install -g \"$PACKAGE\"",
|
|
1808
2276
|
"fi",
|
|
@@ -1864,19 +2332,525 @@ function promptForInstanceName(expected) {
|
|
|
1864
2332
|
});
|
|
1865
2333
|
}
|
|
1866
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
|
+
|
|
1867
2431
|
// =============================================================================
|
|
1868
2432
|
// Subcommand handlers
|
|
1869
2433
|
// =============================================================================
|
|
1870
2434
|
|
|
1871
|
-
|
|
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) {
|
|
1872
2803
|
ensureGcloud();
|
|
1873
2804
|
const flags = parseFlags(args);
|
|
1874
|
-
const cfg = loadOrInitCloudConfig(
|
|
2805
|
+
const cfg = loadOrInitCloudConfig(projectDir, flags);
|
|
1875
2806
|
// Fail loud BEFORE creating a GCE instance if an unprovisionable editor
|
|
1876
2807
|
// variant was requested — otherwise we'd spin up a VM, build a dev image,
|
|
1877
2808
|
// and silently ignore the operator's staging/production intent.
|
|
1878
2809
|
assertVariantProvisionable(resolveEditorConfig(cfg).editorVariant);
|
|
1879
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
|
+
|
|
1880
2854
|
await ensureFirewallRule(cfg);
|
|
1881
2855
|
const { createdThisRun } = await ensureInstance(cfg);
|
|
1882
2856
|
|
|
@@ -1890,7 +2864,21 @@ async function cmdUp(args) {
|
|
|
1890
2864
|
// Write the GitHub token BEFORE compose up so the credentials file exists
|
|
1891
2865
|
// when the bind-mount happens — bind-mounting a missing file would silently
|
|
1892
2866
|
// create a directory instead and the agent's first `git pull` would fail.
|
|
1893
|
-
|
|
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
|
+
}
|
|
1894
2882
|
await seedAgentApiKey(cfg);
|
|
1895
2883
|
await ensureEditorContainerUp(cfg);
|
|
1896
2884
|
// Roster the VM into the fleet dashboard NOW that it's provisioned and the
|
|
@@ -1898,17 +2886,72 @@ async function cmdUp(args) {
|
|
|
1898
2886
|
// dashboard's own `cloud:up --no-tunnel` Add path also rosters (idempotent
|
|
1899
2887
|
// with the dashboard's onSuccess roster — a double-add is a harmless no-op).
|
|
1900
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
|
+
}
|
|
1901
2908
|
// Terminal gate: provisioning is NOT "done" until the editor container is
|
|
1902
2909
|
// verifiably Up. Placed BEFORE the --no-tunnel early-return so the dashboard's
|
|
1903
2910
|
// own `cloud:up --no-tunnel` Add path is covered too (it's the path that bit
|
|
1904
2911
|
// us — a run reported success with no container). A failure here is a loud
|
|
1905
2912
|
// non-zero exit, which the dashboard fail-fast-on-nonzero path acts on.
|
|
1906
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
|
+
}
|
|
1907
2940
|
} catch (err) {
|
|
1908
2941
|
if (createdThisRun) {
|
|
1909
2942
|
console.error(
|
|
1910
2943
|
`✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
|
|
1911
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
|
+
}
|
|
1912
2955
|
try {
|
|
1913
2956
|
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
1914
2957
|
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
@@ -1940,10 +2983,10 @@ async function cmdUp(args) {
|
|
|
1940
2983
|
attachKeepAliveAndTeardown(tunnelChild);
|
|
1941
2984
|
}
|
|
1942
2985
|
|
|
1943
|
-
async function cmdTunnel(args) {
|
|
2986
|
+
async function cmdTunnel(args, projectDir = rootDir) {
|
|
1944
2987
|
ensureGcloud();
|
|
1945
2988
|
const flags = parseFlags(args);
|
|
1946
|
-
const existing = loadCloudConfig(
|
|
2989
|
+
const existing = loadCloudConfig(projectDir);
|
|
1947
2990
|
if (!existing) {
|
|
1948
2991
|
throw new Error(
|
|
1949
2992
|
"No .codeyam/cloud.json found. Run `npm run cloud:up -- --repo <url>` first.",
|
|
@@ -1971,10 +3014,10 @@ async function cmdTunnel(args) {
|
|
|
1971
3014
|
attachKeepAliveAndTeardown(tunnelChild);
|
|
1972
3015
|
}
|
|
1973
3016
|
|
|
1974
|
-
async function cmdStop(args) {
|
|
3017
|
+
async function cmdStop(args, projectDir = rootDir) {
|
|
1975
3018
|
ensureGcloud();
|
|
1976
3019
|
parseFlags(args);
|
|
1977
|
-
const cfg = loadCloudConfig(
|
|
3020
|
+
const cfg = loadCloudConfig(projectDir);
|
|
1978
3021
|
if (!cfg) {
|
|
1979
3022
|
throw new Error("No .codeyam/cloud.json found. Nothing to stop.");
|
|
1980
3023
|
}
|
|
@@ -1990,10 +3033,10 @@ async function cmdStop(args) {
|
|
|
1990
3033
|
);
|
|
1991
3034
|
}
|
|
1992
3035
|
|
|
1993
|
-
async function cmdDestroy(args) {
|
|
3036
|
+
async function cmdDestroy(args, projectDir = rootDir) {
|
|
1994
3037
|
ensureGcloud();
|
|
1995
3038
|
parseFlags(args);
|
|
1996
|
-
const cfg = loadCloudConfig(
|
|
3039
|
+
const cfg = loadCloudConfig(projectDir);
|
|
1997
3040
|
if (!cfg) {
|
|
1998
3041
|
throw new Error("No .codeyam/cloud.json found. Nothing to destroy.");
|
|
1999
3042
|
}
|
|
@@ -2004,12 +3047,28 @@ async function cmdDestroy(args) {
|
|
|
2004
3047
|
return;
|
|
2005
3048
|
}
|
|
2006
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
|
+
|
|
2007
3060
|
console.error(`Deleting instance ${cfg.instanceName}...`);
|
|
2008
3061
|
const del = runGcloud(buildGcloudArgs("instances-delete", cfg));
|
|
2009
3062
|
if (del.status !== 0) {
|
|
2010
3063
|
throw new Error(`Failed to delete instance ${cfg.instanceName}`);
|
|
2011
3064
|
}
|
|
2012
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
|
+
|
|
2013
3072
|
const list = runGcloud(buildGcloudArgs("instances-list-tagged", cfg), {
|
|
2014
3073
|
stdio: ["ignore", "pipe", "ignore"],
|
|
2015
3074
|
});
|
|
@@ -2027,17 +3086,17 @@ async function cmdDestroy(args) {
|
|
|
2027
3086
|
}
|
|
2028
3087
|
|
|
2029
3088
|
try {
|
|
2030
|
-
fs.unlinkSync(cloudConfigPath(
|
|
3089
|
+
fs.unlinkSync(cloudConfigPath(projectDir));
|
|
2031
3090
|
console.error("Deleted .codeyam/cloud.json");
|
|
2032
3091
|
} catch (e) {
|
|
2033
3092
|
if (e.code !== "ENOENT") throw e;
|
|
2034
3093
|
}
|
|
2035
3094
|
}
|
|
2036
3095
|
|
|
2037
|
-
async function cmdStatus(args) {
|
|
3096
|
+
async function cmdStatus(args, projectDir = rootDir) {
|
|
2038
3097
|
ensureGcloud();
|
|
2039
3098
|
parseFlags(args);
|
|
2040
|
-
const cfg = loadCloudConfig(
|
|
3099
|
+
const cfg = loadCloudConfig(projectDir);
|
|
2041
3100
|
if (!cfg) {
|
|
2042
3101
|
console.error("No .codeyam/cloud.json found. Run `npm run cloud:up` first.");
|
|
2043
3102
|
return;
|
|
@@ -2079,12 +3138,269 @@ async function cmdStatus(args) {
|
|
|
2079
3138
|
);
|
|
2080
3139
|
}
|
|
2081
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
|
+
|
|
2082
3393
|
const HANDLERS = {
|
|
2083
3394
|
up: cmdUp,
|
|
2084
3395
|
tunnel: cmdTunnel,
|
|
2085
3396
|
stop: cmdStop,
|
|
2086
3397
|
destroy: cmdDestroy,
|
|
2087
3398
|
status: cmdStatus,
|
|
3399
|
+
"ingress-up": cmdIngressUp,
|
|
3400
|
+
"reconcile-ingress": cmdReconcileIngress,
|
|
3401
|
+
"wire-add-tail": cmdWireAddTail,
|
|
3402
|
+
"reconcile-credentials": cmdReconcileCredentials,
|
|
3403
|
+
access: cmdAccess,
|
|
2088
3404
|
};
|
|
2089
3405
|
|
|
2090
3406
|
async function cloudMain() {
|
|
@@ -2131,6 +3447,7 @@ module.exports = {
|
|
|
2131
3447
|
resolveSeedPreviewOrigin,
|
|
2132
3448
|
previewSameOriginSafeFromStack,
|
|
2133
3449
|
isRottedManagedPreviewOrigin,
|
|
3450
|
+
isWrongModePreviewOrigin,
|
|
2134
3451
|
resolveEditorConfig,
|
|
2135
3452
|
validateEditorVariant,
|
|
2136
3453
|
assertVariantProvisionable,
|
|
@@ -2138,14 +3455,17 @@ module.exports = {
|
|
|
2138
3455
|
reconcileProjectExclusivity,
|
|
2139
3456
|
resolveGhcrToken,
|
|
2140
3457
|
resolveGhcrPushToken,
|
|
3458
|
+
probeUsableEditorImage,
|
|
2141
3459
|
resolveProject,
|
|
2142
3460
|
clientProjectBootstrapArgs,
|
|
2143
3461
|
cloudConfigPath,
|
|
2144
3462
|
loadCloudConfig,
|
|
2145
3463
|
saveCloudConfig,
|
|
3464
|
+
loadOrInitCloudConfig,
|
|
2146
3465
|
buildEditorLocalEnvPatch,
|
|
2147
3466
|
buildEditorLocalSeedWriteCommand,
|
|
2148
3467
|
seedPreviewOrigin,
|
|
3468
|
+
healPreviewOriginAtReady,
|
|
2149
3469
|
buildGcloudArgs,
|
|
2150
3470
|
sshScriptCommand,
|
|
2151
3471
|
bootstrapRunnerScript,
|
|
@@ -2167,14 +3487,36 @@ module.exports = {
|
|
|
2167
3487
|
ensureInstance,
|
|
2168
3488
|
ensureBootstrapped,
|
|
2169
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,
|
|
2170
3503
|
// side-effecting handlers
|
|
2171
3504
|
cmdUp,
|
|
2172
3505
|
cmdTunnel,
|
|
2173
3506
|
cmdStop,
|
|
2174
3507
|
cmdDestroy,
|
|
2175
3508
|
cmdStatus,
|
|
3509
|
+
cmdIngressUp,
|
|
3510
|
+
cmdReconcileIngress,
|
|
3511
|
+
cmdReconcileCredentials,
|
|
3512
|
+
cmdAccess,
|
|
2176
3513
|
cloudMain,
|
|
2177
3514
|
seedGithubCredentials,
|
|
3515
|
+
resolveGithubToken,
|
|
3516
|
+
resolveGithubTokenWithSource,
|
|
3517
|
+
MissingGitPushCredentialsError,
|
|
3518
|
+
classifyGitRemoteError,
|
|
3519
|
+
decideCredentialHeal,
|
|
2178
3520
|
readVmAppPort,
|
|
2179
3521
|
resolveLaptopGitIdentity,
|
|
2180
3522
|
};
|