@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7
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/cli.js +5 -1
- package/npm/cloud.js +2079 -242
- package/npm/codesign-plan.js +68 -0
- package/npm/compose-conflict.js +59 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +361 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +243 -9
- package/npm/fleet-destroy-safety.js +230 -13
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-live-status.js +40 -8
- package/npm/fleet-memory.js +289 -9
- package/npm/fleet-plans.js +98 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +87 -11
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +234 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reset.js +32 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +115 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +19 -12
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-stuck-status.js +13 -7
- package/npm/fleet-vm-access.js +170 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +136 -3
- package/npm/google-ingress.js +15 -0
- package/npm/ingress-invoke.js +478 -0
- package/npm/launch-config.js +302 -10
- package/npm/scenario-check.js +318 -12
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +330 -22
- package/npm/script-drift.js +72 -1
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-CjxWhkSy.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
- package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
- package/ui/dist/assets/index-CIXZkwfB.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Push-based fleet-status read for the operator dashboard
|
|
3
|
+
// (scripts/operator/fleet-dashboard/server.js requires this).
|
|
4
|
+
//
|
|
5
|
+
// Each VM's editor publishes its own status to a git ref
|
|
6
|
+
// `refs/codeyam/fleet-status/<machine>` on its plan-claim heartbeat (see the
|
|
7
|
+
// Rust `control-api::fleet_status_refs` writer + the `plan_claims_sync`
|
|
8
|
+
// pusher). This module is the READ half: the dashboard fetches that WHOLE
|
|
9
|
+
// namespace in ONE `git fetch` and reads every VM's status from the local
|
|
10
|
+
// mirror — O(1) in fleet size, and crucially spawning ZERO `gcloud ssh`.
|
|
11
|
+
//
|
|
12
|
+
// That is the entire point of the push model: the old gather fanned out one
|
|
13
|
+
// heavyweight `gcloud compute ssh … docker exec` per VM per poll (Python +
|
|
14
|
+
// IAP tunnel, ~100 MB apiece), so monitoring cost O(N) SSH round-trips and
|
|
15
|
+
// the operator host had to be over-provisioned just to stay responsive. One
|
|
16
|
+
// fetch replaces all of it.
|
|
17
|
+
//
|
|
18
|
+
// Pure orchestration with injected I/O (`spawn` for the fetch, `readRefs` for
|
|
19
|
+
// the mirror read) so it stays unit-testable with no network and no git —
|
|
20
|
+
// exactly like the other fleet-* modules. Any real gcloud/SSH I/O is
|
|
21
|
+
// DELIBERATELY absent here; a test asserts no spawn command matches /gcloud/.
|
|
22
|
+
|
|
23
|
+
// Force refspec mirroring the whole fleet-status namespace into the local
|
|
24
|
+
// mirror. The leading `+` prunes a machine that stopped publishing (its ref
|
|
25
|
+
// deleted upstream), so a decommissioned VM's status can't linger. Kept
|
|
26
|
+
// byte-identical to the Rust `FLEET_STATUS_FETCH_REFSPEC` constant.
|
|
27
|
+
const FLEET_STATUS_FETCH_REFSPEC =
|
|
28
|
+
'+refs/codeyam/fleet-status/*:refs/codeyam/fleet-status/*';
|
|
29
|
+
|
|
30
|
+
// Parse `git for-each-ref --format='%(refname)%00%(contents)'` output over the
|
|
31
|
+
// fleet-status namespace into an array of payload objects. A ref whose body is
|
|
32
|
+
// not valid JSON is skipped (never throws) so one corrupt ref can't blind the
|
|
33
|
+
// dashboard to the rest of the fleet. NUL separates the ref name from its
|
|
34
|
+
// single-line JSON body; records are newline-separated.
|
|
35
|
+
function parseForEachRef(stdout) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
38
|
+
if (!line.trim()) continue;
|
|
39
|
+
const sep = line.indexOf('\u0000');
|
|
40
|
+
const json = sep >= 0 ? line.slice(sep + 1) : line;
|
|
41
|
+
try {
|
|
42
|
+
const payload = JSON.parse(json);
|
|
43
|
+
if (payload && typeof payload === 'object') out.push(payload);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// Skip an unparseable ref body; the rest of the fleet still reads.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Index an array of status payloads by their `machine`, keeping the LAST
|
|
52
|
+
// occurrence for a machine (there should only be one ref per machine, but be
|
|
53
|
+
// deterministic if a duplicate slips in). Payloads with no `machine` are
|
|
54
|
+
// dropped — they can't be attributed to a VM.
|
|
55
|
+
function indexByMachine(payloads) {
|
|
56
|
+
const vms = {};
|
|
57
|
+
for (const p of payloads || []) {
|
|
58
|
+
if (p && p.machine) vms[p.machine] = p;
|
|
59
|
+
}
|
|
60
|
+
return vms;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Read the whole fleet's pushed status in ONE fetch, then index it by machine.
|
|
64
|
+
//
|
|
65
|
+
// spawn(command, args) — run a process (the single git fetch). May return a
|
|
66
|
+
// promise; awaited. NEVER used for gcloud/SSH.
|
|
67
|
+
// readRefs() — return (or resolve to) the parsed status payloads
|
|
68
|
+
// from the local mirror. Injected so tests need no
|
|
69
|
+
// git; production passes a reader that runs
|
|
70
|
+
// `git for-each-ref` + parseForEachRef.
|
|
71
|
+
// remote — remote to fetch from (default 'origin').
|
|
72
|
+
// projectDir — cwd for the fetch, threaded to spawn's options.
|
|
73
|
+
//
|
|
74
|
+
// Returns `{ vms: { <machine>: payload }, count }`. A fetch failure is
|
|
75
|
+
// swallowed (best-effort, same posture as the Rust sync): the read falls back
|
|
76
|
+
// to whatever is already in the local mirror, so a transient network blip
|
|
77
|
+
// degrades to slightly-stale status rather than a blank dashboard.
|
|
78
|
+
async function gatherFleetStatus({ spawn, readRefs, remote = 'origin', projectDir } = {}) {
|
|
79
|
+
if (typeof spawn !== 'function') throw new Error('gatherFleetStatus requires a spawn function');
|
|
80
|
+
if (typeof readRefs !== 'function') throw new Error('gatherFleetStatus requires a readRefs function');
|
|
81
|
+
try {
|
|
82
|
+
await spawn('git', ['fetch', '--no-write-fetch-head', remote, FLEET_STATUS_FETCH_REFSPEC], { cwd: projectDir });
|
|
83
|
+
} catch (e) {
|
|
84
|
+
// Fall through to reading the (possibly stale) local mirror.
|
|
85
|
+
}
|
|
86
|
+
const payloads = await readRefs();
|
|
87
|
+
const vms = indexByMachine(payloads);
|
|
88
|
+
return { vms, count: Object.keys(vms).length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
FLEET_STATUS_FETCH_REFSPEC,
|
|
93
|
+
parseForEachRef,
|
|
94
|
+
indexByMachine,
|
|
95
|
+
gatherFleetStatus,
|
|
96
|
+
};
|
package/npm/fleet-strand.js
CHANGED
|
@@ -24,10 +24,25 @@
|
|
|
24
24
|
// Stack note: this is fleet-operator infrastructure (the GCE fleet that runs
|
|
25
25
|
// codeyam-editor itself). A project that never joins the fleet never reaches it.
|
|
26
26
|
|
|
27
|
-
// The boot
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// The boot guards whose presence makes a binary safe. Single source so the
|
|
28
|
+
// classifier, the capability field the editor advertises, and any log line
|
|
29
|
+
// agree on the strings.
|
|
30
|
+
// SERVE_CWD_GUARD — safe from the launcher-mode strand (serves its cwd
|
|
31
|
+
// project on the contract port instead of re-execing
|
|
32
|
+
// into the Project Launcher on an ephemeral port).
|
|
33
|
+
// CAPTURE_AUTH_GUARD — enforces the capture-session auth guard, so component
|
|
34
|
+
// captures don't 401 when the editor binds a
|
|
35
|
+
// non-loopback address.
|
|
30
36
|
const SERVE_CWD_GUARD = 'serve_cwd_directly';
|
|
37
|
+
const CAPTURE_AUTH_GUARD = 'capture_session_auth';
|
|
38
|
+
|
|
39
|
+
// Per-guard impact clause folded into the stale reason, so a flagged VM's badge
|
|
40
|
+
// names WHY the missing guard matters (not a generic "stale") and the operator
|
|
41
|
+
// knows the concrete failure to expect.
|
|
42
|
+
const GUARD_IMPACT = {
|
|
43
|
+
[SERVE_CWD_GUARD]: 'will strand in Project Launcher mode on its next re-exec',
|
|
44
|
+
[CAPTURE_AUTH_GUARD]: 'component captures will 401 on a non-loopback bind',
|
|
45
|
+
};
|
|
31
46
|
|
|
32
47
|
/** Classify a VM's in-container probe into the launcher-strand vocabulary. The
|
|
33
48
|
* DECISIVE, trigger-independent signal is observable in-VM regardless of what
|
|
@@ -116,19 +131,34 @@ function strandRecoveryAction({ binaryCurrent } = {}) {
|
|
|
116
131
|
* the editor advertising the capability (the `bootGuards` array on the
|
|
117
132
|
* health/session-info payload it already serves) rather than the dashboard
|
|
118
133
|
* guessing from commit ancestry:
|
|
119
|
-
* - `bootGuards` IS an array → authoritative: stale iff it
|
|
134
|
+
* - `bootGuards` IS an array → authoritative: stale iff it is missing ANY
|
|
135
|
+
* required guard. A binary that has `serve_cwd_directly` yet predates
|
|
136
|
+
* `capture_session_auth` (VM-13) must still be flagged, so a single
|
|
137
|
+
* required guard is not enough — the check is over the whole required set,
|
|
138
|
+
* naming the FIRST missing guard (with its impact clause) in the reason.
|
|
120
139
|
* - `bootGuards` ABSENT (an older binary that predates the field — which are
|
|
121
140
|
* exactly the at-risk ones) → fall back to the build-commit comparison the
|
|
122
141
|
* dashboard already computes: flag stale only when the editor source is
|
|
123
142
|
* `editorBehind > 0` commits behind origin, so a current binary that merely
|
|
124
143
|
* predates the FIELD isn't falsely flagged while a genuinely-behind one is.
|
|
125
|
-
*
|
|
126
|
-
|
|
144
|
+
* `requiredGuard` accepts an array (the new multi-guard shape, default
|
|
145
|
+
* `[SERVE_CWD_GUARD, CAPTURE_AUTH_GUARD]`) or a single string (legacy /
|
|
146
|
+
* future-guard reuse). Returns `{ stale, reason }`; `reason` is null when not
|
|
147
|
+
* stale. */
|
|
148
|
+
function classifyBinaryGuard({
|
|
149
|
+
bootGuards,
|
|
150
|
+
editorBehind,
|
|
151
|
+
requiredGuard = [SERVE_CWD_GUARD, CAPTURE_AUTH_GUARD],
|
|
152
|
+
} = {}) {
|
|
153
|
+
const required = Array.isArray(requiredGuard) ? requiredGuard : [requiredGuard];
|
|
127
154
|
if (Array.isArray(bootGuards)) {
|
|
128
|
-
|
|
155
|
+
const missing = required.find((guard) => !bootGuards.includes(guard));
|
|
156
|
+
if (!missing) return { stale: false, reason: null };
|
|
157
|
+
const impact = GUARD_IMPACT[missing];
|
|
158
|
+
const because = impact ? ` — ${impact}` : '';
|
|
129
159
|
return {
|
|
130
160
|
stale: true,
|
|
131
|
-
reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${
|
|
161
|
+
reason: `running binary advertises bootGuards [${bootGuards.join(', ')}] without '${missing}'${because}; rebuild to converge`,
|
|
132
162
|
};
|
|
133
163
|
}
|
|
134
164
|
const behind = Number(editorBehind);
|
|
@@ -143,6 +173,7 @@ function classifyBinaryGuard({ bootGuards, editorBehind, requiredGuard = SERVE_C
|
|
|
143
173
|
|
|
144
174
|
module.exports = {
|
|
145
175
|
SERVE_CWD_GUARD,
|
|
176
|
+
CAPTURE_AUTH_GUARD,
|
|
146
177
|
classifyLauncherStrand,
|
|
147
178
|
shouldAutoRecoverStrand,
|
|
148
179
|
strandRecoveryAction,
|
|
@@ -62,16 +62,22 @@ function elapsedMs(iso, nowMs) {
|
|
|
62
62
|
|
|
63
63
|
// Derive the attention verdict for one VM.
|
|
64
64
|
//
|
|
65
|
-
// deriveAttention({ status, stepStartedAt,
|
|
65
|
+
// deriveAttention({ status, stepStartedAt, nowMs, thresholds })
|
|
66
66
|
// -> { stepElapsedMs, level, needsYou, needsYouElapsedMs }
|
|
67
67
|
//
|
|
68
68
|
// Rules:
|
|
69
69
|
// - `level` ('ok' | 'slow' | 'stuck') is computed ONLY for an actively-
|
|
70
70
|
// building VM (status === 'building'). Thresholds are inclusive:
|
|
71
71
|
// >= stuckMs -> 'stuck', else >= slowMs -> 'slow', else 'ok'.
|
|
72
|
-
// -
|
|
73
|
-
//
|
|
74
|
-
//
|
|
72
|
+
// - "Needs you" flips true ONLY for the genuinely-blocked `waiting` state (a
|
|
73
|
+
// running task awaiting a human), once its elapsed >= needsYouMs. An 'idle'
|
|
74
|
+
// VM (parked at its prompt, no task running) is deliberately NOT flagged no
|
|
75
|
+
// matter how long it sits — idle means "ready for you to start something",
|
|
76
|
+
// not "a task is blocked on you" (observed live 2026-06-30, VM-1, which
|
|
77
|
+
// false-flagged "Needs You" while merely idle). `awaitingInput` alone no
|
|
78
|
+
// longer drives this: liveSessionStatus already collapses an idle awaiting
|
|
79
|
+
// agent to 'idle' and a blocked one to 'waiting', so the status IS the
|
|
80
|
+
// discriminator.
|
|
75
81
|
// - Missing/unparseable stepStartedAt degrades to stepElapsedMs:null,
|
|
76
82
|
// level:'ok', needsYou:false (older editor, no crash).
|
|
77
83
|
function deriveAttention(opts) {
|
|
@@ -79,14 +85,14 @@ function deriveAttention(opts) {
|
|
|
79
85
|
const thresholds = o.thresholds || resolveThresholds({});
|
|
80
86
|
const nowMs = o.nowMs;
|
|
81
87
|
const stepElapsed = elapsedMs(o.stepStartedAt, nowMs);
|
|
82
|
-
const awaiting = o.status === 'waiting' || !!o.awaitingInput;
|
|
83
88
|
|
|
84
89
|
let level = 'ok';
|
|
85
90
|
let needsYou = false;
|
|
86
91
|
let needsYouElapsedMs = null;
|
|
87
92
|
|
|
88
|
-
if (
|
|
89
|
-
//
|
|
93
|
+
if (o.status === 'waiting') {
|
|
94
|
+
// A task genuinely blocked on a human — never "stuck"; flag "needs you" past
|
|
95
|
+
// its threshold. An 'idle' VM never reaches this branch.
|
|
90
96
|
needsYouElapsedMs = stepElapsed;
|
|
91
97
|
needsYou = stepElapsed != null && stepElapsed >= thresholds.needsYouMs;
|
|
92
98
|
} else if (o.status === 'building' && stepElapsed != null) {
|
package/npm/fleet-vm-access.js
CHANGED
|
@@ -105,6 +105,132 @@ function normalizeAccessList(input) {
|
|
|
105
105
|
return out;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// The env/config key holding the standing default-operator principal(s) — the
|
|
109
|
+
// fleet owner / ops identity that must ALWAYS be able to reach an IAP-secured VM.
|
|
110
|
+
// Comma-separated `user:`/`group:`/`domain:` principals (a bare email defaults to
|
|
111
|
+
// `user:`), e.g. `VM_ACCESS_DEFAULT=user:jared@codeyam.com`. Read from config, not
|
|
112
|
+
// hardcoded here, so the default operator is tenant-configurable.
|
|
113
|
+
const DEFAULT_OPERATOR_ENV = 'VM_ACCESS_DEFAULT';
|
|
114
|
+
|
|
115
|
+
// The default gcloud-account resolver, split out so it is the ONLY function that
|
|
116
|
+
// touches child_process — the module stays side-effect-free at load, and
|
|
117
|
+
// resolveDefaultOperator takes an injectable `exec` so its fallback is unit
|
|
118
|
+
// testable without a real gcloud. Returns the active account email, or '' when
|
|
119
|
+
// gcloud is absent / errors (never throws to the caller).
|
|
120
|
+
function defaultGcloudAccountExec() {
|
|
121
|
+
const { spawnSync } = require('child_process');
|
|
122
|
+
const r = spawnSync('gcloud', ['config', 'get-value', 'account'], { encoding: 'utf8' });
|
|
123
|
+
if (!r || r.status !== 0) return '';
|
|
124
|
+
return String(r.stdout || '').trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Read the standing default-operator principal(s) from `.codeyam/cloud.json`'s
|
|
128
|
+
// `vmAccessDefault` key — the durable per-project config that resolves the default
|
|
129
|
+
// operator even when a launchd/systemd env was never re-baked with
|
|
130
|
+
// `VM_ACCESS_DEFAULT`. Returns the raw config value ('' when the file is missing or
|
|
131
|
+
// malformed, or the key is absent / not a string) so a broken cloud.json is treated
|
|
132
|
+
// as "no config" and never throws. Split out (like defaultGcloudAccountExec) so the
|
|
133
|
+
// cloud.json layer of resolveDefaultOperator is injectable/unit-testable without a
|
|
134
|
+
// real file. `.codeyam/cloud.json` is resolved via `rootDir` — the same repo root
|
|
135
|
+
// the access sidecar and other state files use. `cloudJsonPath` is a parameter
|
|
136
|
+
// (defaulting to that resolved path) so the reader is unit-testable against a temp
|
|
137
|
+
// file, the same injectable-for-testability posture as defaultGcloudAccountExec.
|
|
138
|
+
// The path also honors `VM_ACCESS_CLOUD_JSON` — the same env-override isolation
|
|
139
|
+
// posture as `VM_ACCESS_FILE` above — so a test process (or an operator) can point
|
|
140
|
+
// this read at a throwaway path and never pick up the developer's real
|
|
141
|
+
// `.codeyam/cloud.json` `vmAccessDefault`.
|
|
142
|
+
function defaultCloudConfigRead(
|
|
143
|
+
cloudJsonPath = process.env.VM_ACCESS_CLOUD_JSON || path.join(rootDir, '.codeyam', 'cloud.json'),
|
|
144
|
+
) {
|
|
145
|
+
try {
|
|
146
|
+
const raw = fs.readFileSync(cloudJsonPath, 'utf8');
|
|
147
|
+
const data = JSON.parse(raw);
|
|
148
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
149
|
+
return typeof data.vmAccessDefault === 'string' ? data.vmAccessDefault : '';
|
|
150
|
+
}
|
|
151
|
+
} catch { /* missing or malformed cloud.json → no config */ }
|
|
152
|
+
return '';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolve the standing default-operator principal(s) that must be granted IAP
|
|
156
|
+
* access whenever a VM's IAP is enabled — the fix for "IAP enabled but nobody
|
|
157
|
+
* granted" (the Error-51-by-default footgun). Config first, most-specific to
|
|
158
|
+
* least:
|
|
159
|
+
* 1. `VM_ACCESS_DEFAULT` (the env knob) — normalized and returned.
|
|
160
|
+
* 2. `.codeyam/cloud.json`'s `vmAccessDefault` — the DURABLE per-project config,
|
|
161
|
+
* consulted when the env var is unset. This closes the gap that made every
|
|
162
|
+
* fleet Add grant nobody: the dashboard/Add runs as the automation SERVICE
|
|
163
|
+
* ACCOUNT, and with no `VM_ACCESS_DEFAULT` in the (un-re-baked) launchd env
|
|
164
|
+
* the resolver used to fall straight through to the gcloud fallback and skip
|
|
165
|
+
* the SA — resolving `[]`. Reading cloud.json here means every caller
|
|
166
|
+
* (iap-access.sh, cloud.js, the dashboard) inherits the default operator
|
|
167
|
+
* without an env re-bake.
|
|
168
|
+
* 3. Fallback: the active gcloud account, so a HUMAN operator running these
|
|
169
|
+
* scripts locally gets access with no extra config. A SERVICE-ACCOUNT
|
|
170
|
+
* session (the automation SA, whose account ends in `gserviceaccount.com`)
|
|
171
|
+
* is skipped — granting the automation SA to itself is a pointless no-op,
|
|
172
|
+
* and the human/ops identity it should grant is unknowable from the SA
|
|
173
|
+
* session, so config (env or cloud.json) is REQUIRED there.
|
|
174
|
+
* Returns a normalized principal array (possibly empty — callers treat empty as
|
|
175
|
+
* "no default operator configured" and keep their prior behavior). `exec` and
|
|
176
|
+
* `readCloudConfig` are injectable so the gcloud and cloud.json fallbacks are
|
|
177
|
+
* unit-testable without a real gcloud / file. */
|
|
178
|
+
function resolveDefaultOperator({ env = process.env, exec, readCloudConfig } = {}) {
|
|
179
|
+
const configured = env[DEFAULT_OPERATOR_ENV];
|
|
180
|
+
if (configured != null && String(configured).trim() !== '') {
|
|
181
|
+
try { return normalizeAccessList(configured); } catch { return []; }
|
|
182
|
+
}
|
|
183
|
+
let fromCloud = '';
|
|
184
|
+
try { fromCloud = String((readCloudConfig || defaultCloudConfigRead)() || '').trim(); }
|
|
185
|
+
catch { fromCloud = ''; }
|
|
186
|
+
if (fromCloud !== '') {
|
|
187
|
+
try { return normalizeAccessList(fromCloud); } catch { return []; }
|
|
188
|
+
}
|
|
189
|
+
let account = '';
|
|
190
|
+
try { account = String((exec || defaultGcloudAccountExec)() || '').trim(); }
|
|
191
|
+
catch { account = ''; }
|
|
192
|
+
if (account === '' || /gserviceaccount\.com$/i.test(account)) return [];
|
|
193
|
+
try { return normalizeAccessList(account); } catch { return []; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The loud degraded reason(s) to attach to a freshly-added VM whose IAP would
|
|
197
|
+
* authorize NOBODY. Fires only for a SUCCESSFUL `add` (`ok`) in LB mode (`lbMode`
|
|
198
|
+
* — a FLEET_DOMAIN is configured so the VM's public URL is IAP-gated) when the
|
|
199
|
+
* default operator resolved empty (`resolvedOperatorCount === 0`): the automation-
|
|
200
|
+
* SA identity with no `VM_ACCESS_DEFAULT` env AND no `.codeyam/cloud.json`
|
|
201
|
+
* `vmAccessDefault` fallback (the 2026-07-09 footgun). In that case the URL comes
|
|
202
|
+
* up authorizing nobody yet the add reads as "done" — so we surface the exact
|
|
203
|
+
* remedy on the card instead of a silently gated URL. `resolvedOperatorCount` is
|
|
204
|
+
* `null` when the caller skipped the (gcloud) resolve because the cheap gates
|
|
205
|
+
* already failed; the `!== 0` test covers both null and a positive count. Returns a
|
|
206
|
+
* single actionable reason or [] so the dashboard can spread it into
|
|
207
|
+
* `degradedReasons` with no conditional at the call site. Pure (no I/O): the string
|
|
208
|
+
* lives in exactly one place and the trigger is unit-testable. */
|
|
209
|
+
function noDefaultOperatorDegradedReasons({ ok, action, lbMode, resolvedOperatorCount, vmNumber } = {}) {
|
|
210
|
+
if (!ok || action !== 'add' || !lbMode || resolvedOperatorCount !== 0) return [];
|
|
211
|
+
return [
|
|
212
|
+
'iap-default-operator-missing: IAP default operator not configured — set '
|
|
213
|
+
+ 'VM_ACCESS_DEFAULT or cloud.json vmAccessDefault (the public URL will '
|
|
214
|
+
+ `authorize nobody; run scripts/gcp/iap-access.sh ${vmNumber} grant after configuring).`,
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Union the default-operator principal(s) into an access list, deduped and
|
|
219
|
+
* order-preserving (explicit collaborators first, the operator appended if not
|
|
220
|
+
* already present). Both inputs are normalized. The result is what gets GRANTED;
|
|
221
|
+
* the per-VM sidecar still records only the explicit collaborators, so the
|
|
222
|
+
* always-present operator never pollutes the per-VM whitelist display or a
|
|
223
|
+
* reused number's stale-revoke computation. */
|
|
224
|
+
function withDefaultOperator(list, defaultOperator) {
|
|
225
|
+
const base = normalizeAccessList(list);
|
|
226
|
+
const seen = new Set(base);
|
|
227
|
+
const out = base.slice();
|
|
228
|
+
for (const p of normalizeAccessList(defaultOperator)) {
|
|
229
|
+
if (!seen.has(p)) { seen.add(p); out.push(p); }
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
108
234
|
// Read the access sidecar, returning a `{ [vmNumber]: principal[] }` object, or
|
|
109
235
|
// null if the file is missing or malformed (treated as "no access lists"). Keys
|
|
110
236
|
// are the string form JSON emits; callers look them up by `String(n)`.
|
|
@@ -208,6 +334,44 @@ function parseProjectMembers(stdout) {
|
|
|
208
334
|
return out;
|
|
209
335
|
}
|
|
210
336
|
|
|
337
|
+
/** Reconcile a VM's LOCAL access sidecar against LIVE IAM. PURE — the caller
|
|
338
|
+
* does the two reads (the sidecar file, the gcloud IAP probe) and hands both in,
|
|
339
|
+
* so this decision logic is unit-testable without the fleet dashboard's
|
|
340
|
+
* untestable server shell around it.
|
|
341
|
+
*
|
|
342
|
+
* `sidecar` is the recorded principal list (what someone asked to grant);
|
|
343
|
+
* `iapMembers` is the aggregated live-IAM result from
|
|
344
|
+
* fleet-public-url.js::aggregateIapMembers, or null/undefined when that read has
|
|
345
|
+
* not landed or a surface errored. Returns:
|
|
346
|
+
*
|
|
347
|
+
* live — the principals IAM actually authorizes. The display truth.
|
|
348
|
+
* drift — sidecar principals live IAM does NOT confirm. These are failed
|
|
349
|
+
* grants; a caller renders them flagged, never as access.
|
|
350
|
+
* pending — the live read is unavailable, so NEITHER list is verified.
|
|
351
|
+
* partial — a principal holds the role on some ingress surfaces but not all.
|
|
352
|
+
*
|
|
353
|
+
* The pending distinction is the whole point. Treating an unavailable read as an
|
|
354
|
+
* empty policy would render a genuinely-granted collaborator as missing — the
|
|
355
|
+
* same false report as the bug this replaces, merely inverted. So when
|
|
356
|
+
* `iapMembers` is absent, `live` is empty, every sidecar principal lands in
|
|
357
|
+
* `drift`, and `pending` is true to tell the caller not to assert either way.
|
|
358
|
+
*
|
|
359
|
+
* Comparison is case-insensitive: parseProjectMembers lowercases for
|
|
360
|
+
* dedup-stability while the sidecar holds whatever normalizePrincipal produced. */
|
|
361
|
+
function reconcileVmAccess(sidecar, iapMembers) {
|
|
362
|
+
const recorded = Array.isArray(sidecar) ? sidecar : [];
|
|
363
|
+
if (!iapMembers || !Array.isArray(iapMembers.all)) {
|
|
364
|
+
return { live: [], drift: recorded, pending: true, partial: false };
|
|
365
|
+
}
|
|
366
|
+
const live = new Set(iapMembers.all.map((m) => String(m).toLowerCase()));
|
|
367
|
+
return {
|
|
368
|
+
live: iapMembers.all,
|
|
369
|
+
drift: recorded.filter((p) => !live.has(String(p).toLowerCase())),
|
|
370
|
+
pending: false,
|
|
371
|
+
partial: iapMembers.partial === true,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
211
375
|
/** Drop VM `n`'s entire access list, returning the new map. Clearing an absent
|
|
212
376
|
* entry is a no-op. Called on Destroy so a number freed and later re-used
|
|
213
377
|
* doesn't inherit a stale whitelist. */
|
|
@@ -221,9 +385,15 @@ function clearAccess(n) {
|
|
|
221
385
|
module.exports = {
|
|
222
386
|
VM_ACCESS_FILE,
|
|
223
387
|
PRINCIPAL_TYPES,
|
|
388
|
+
DEFAULT_OPERATOR_ENV,
|
|
224
389
|
normalizePrincipal,
|
|
225
390
|
normalizeAccessList,
|
|
391
|
+
resolveDefaultOperator,
|
|
392
|
+
defaultCloudConfigRead,
|
|
393
|
+
noDefaultOperatorDegradedReasons,
|
|
394
|
+
withDefaultOperator,
|
|
226
395
|
parseProjectMembers,
|
|
396
|
+
reconcileVmAccess,
|
|
227
397
|
readAccess,
|
|
228
398
|
writeAccess,
|
|
229
399
|
accessFor,
|