@codeyam-editor/codeyam-editor 0.1.4 → 0.1.6
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-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- 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/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- 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-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- 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-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -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/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/react-CSS0HapR.js +0 -28
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure stuck / needs-you attention policy for the Fleet Dashboard's per-VM card
|
|
4
|
+
// (scripts/operator/fleet-dashboard/server.js requires this). It answers a
|
|
5
|
+
// single question the existing status label deliberately does NOT: given how
|
|
6
|
+
// long a VM has been parked on its CURRENT workflow step, is it progressing
|
|
7
|
+
// normally, slowing down, or likely STUCK — and if it is merely waiting on a
|
|
8
|
+
// human, has that wait dragged on long enough to flag "needs you"?
|
|
9
|
+
//
|
|
10
|
+
// Why the STEP timer, not the feature timer: the workflow cursor advances
|
|
11
|
+
// (resetting `started_at` in crates/types/src/step.rs) every time a signature
|
|
12
|
+
// command runs, so step-elapsed is the tightest "is it making progress?" proxy
|
|
13
|
+
// we have. A long feature is normal; a long single step is the smell.
|
|
14
|
+
//
|
|
15
|
+
// This is an ADDITIVE overlay — it never changes the building/waiting/idle
|
|
16
|
+
// status label (that stays in fleet-live-status.js). It only decorates a card
|
|
17
|
+
// with a soft 'slow' note, a hard 'stuck' alarm, or a distinct 'needs you'
|
|
18
|
+
// badge. The split mirrors the established dashboard pure-module pattern
|
|
19
|
+
// (fleet-live-status.js, fleet-broker-status.js, fleet-vm-readiness.js):
|
|
20
|
+
// server.js does the Date.now() I/O and passes the verdict to buildVmEnvelope;
|
|
21
|
+
// the browser renders it.
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SLOW_MINUTES = 15;
|
|
24
|
+
const DEFAULT_STUCK_MINUTES = 30;
|
|
25
|
+
const DEFAULT_NEEDS_YOU_MINUTES = 10;
|
|
26
|
+
|
|
27
|
+
// Parse an env var that should hold a positive number of minutes, falling back
|
|
28
|
+
// to `fallback` when it is unset, empty, non-numeric, or non-positive. Keeping
|
|
29
|
+
// this tolerant means a fat-fingered override degrades to the documented
|
|
30
|
+
// default rather than producing a NaN threshold that no elapsed time can cross.
|
|
31
|
+
function minutesFromEnv(raw, fallback) {
|
|
32
|
+
if (raw == null || raw === '') return fallback;
|
|
33
|
+
const n = Number(raw);
|
|
34
|
+
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Resolve the three thresholds (in milliseconds) from a process-env-shaped
|
|
39
|
+
// object. Defaults live here so they are documented and unit-testable in one
|
|
40
|
+
// place: slow 15m, stuck 30m, needs-you 10m. Env overrides:
|
|
41
|
+
// FLEET_STUCK_STEP_SLOW_MINUTES, FLEET_STUCK_STEP_STUCK_MINUTES,
|
|
42
|
+
// FLEET_NEEDS_YOU_MINUTES.
|
|
43
|
+
function resolveThresholds(env) {
|
|
44
|
+
const e = env || {};
|
|
45
|
+
return {
|
|
46
|
+
slowMs: minutesFromEnv(e.FLEET_STUCK_STEP_SLOW_MINUTES, DEFAULT_SLOW_MINUTES) * 60_000,
|
|
47
|
+
stuckMs: minutesFromEnv(e.FLEET_STUCK_STEP_STUCK_MINUTES, DEFAULT_STUCK_MINUTES) * 60_000,
|
|
48
|
+
needsYouMs: minutesFromEnv(e.FLEET_NEEDS_YOU_MINUTES, DEFAULT_NEEDS_YOU_MINUTES) * 60_000,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Compute elapsed ms from an ISO timestamp to nowMs, or null when the timestamp
|
|
53
|
+
// is missing / unparseable. A null elapsed is the graceful-degrade path for an
|
|
54
|
+
// older editor whose /api/session-info predates startedAt/featureStartedAt.
|
|
55
|
+
function elapsedMs(iso, nowMs) {
|
|
56
|
+
if (!iso) return null;
|
|
57
|
+
const started = Date.parse(iso);
|
|
58
|
+
if (!Number.isFinite(started)) return null;
|
|
59
|
+
const delta = nowMs - started;
|
|
60
|
+
return delta >= 0 ? delta : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Derive the attention verdict for one VM.
|
|
64
|
+
//
|
|
65
|
+
// deriveAttention({ status, stepStartedAt, nowMs, thresholds })
|
|
66
|
+
// -> { stepElapsedMs, level, needsYou, needsYouElapsedMs }
|
|
67
|
+
//
|
|
68
|
+
// Rules:
|
|
69
|
+
// - `level` ('ok' | 'slow' | 'stuck') is computed ONLY for an actively-
|
|
70
|
+
// building VM (status === 'building'). Thresholds are inclusive:
|
|
71
|
+
// >= stuckMs -> 'stuck', else >= slowMs -> 'slow', else 'ok'.
|
|
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.
|
|
81
|
+
// - Missing/unparseable stepStartedAt degrades to stepElapsedMs:null,
|
|
82
|
+
// level:'ok', needsYou:false (older editor, no crash).
|
|
83
|
+
function deriveAttention(opts) {
|
|
84
|
+
const o = opts || {};
|
|
85
|
+
const thresholds = o.thresholds || resolveThresholds({});
|
|
86
|
+
const nowMs = o.nowMs;
|
|
87
|
+
const stepElapsed = elapsedMs(o.stepStartedAt, nowMs);
|
|
88
|
+
|
|
89
|
+
let level = 'ok';
|
|
90
|
+
let needsYou = false;
|
|
91
|
+
let needsYouElapsedMs = null;
|
|
92
|
+
|
|
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.
|
|
96
|
+
needsYouElapsedMs = stepElapsed;
|
|
97
|
+
needsYou = stepElapsed != null && stepElapsed >= thresholds.needsYouMs;
|
|
98
|
+
} else if (o.status === 'building' && stepElapsed != null) {
|
|
99
|
+
if (stepElapsed >= thresholds.stuckMs) level = 'stuck';
|
|
100
|
+
else if (stepElapsed >= thresholds.slowMs) level = 'slow';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
stepElapsedMs: stepElapsed,
|
|
105
|
+
level,
|
|
106
|
+
needsYou,
|
|
107
|
+
needsYouElapsedMs,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
deriveAttention,
|
|
113
|
+
resolveThresholds,
|
|
114
|
+
DEFAULT_SLOW_MINUTES,
|
|
115
|
+
DEFAULT_STUCK_MINUTES,
|
|
116
|
+
DEFAULT_NEEDS_YOU_MINUTES,
|
|
117
|
+
};
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-vm-access.js — the per-VM IAP access whitelist sidecar
|
|
4
|
+
// (`.codeyam/logs/fleet-vm-access.json`). It records which principals may reach
|
|
5
|
+
// a specific fleet VM's IAP-secured surfaces, keyed by VM number:
|
|
6
|
+
// `{ "<N>": ["user:a@x.com", "group:team@x.com", "domain:partner.com"], ... }`.
|
|
7
|
+
//
|
|
8
|
+
// Why a sidecar and not the roster: the roster (`npm/fleet-roster.js`) is a flat
|
|
9
|
+
// integer array with a hard "sorted, de-duped set of integers" invariant bash
|
|
10
|
+
// consumers depend on. Access is a per-VM list, a different shape, so it lives in
|
|
11
|
+
// a SEPARATE map fronted by a read+write seam modeled exactly on
|
|
12
|
+
// npm/fleet-vm-slugs.js (atomic temp-file + rename, tolerant of a missing or
|
|
13
|
+
// malformed file). The slug sidecar maps a VM number to one hostname label; this
|
|
14
|
+
// one maps a VM number to an access list — same persistence contract, sibling
|
|
15
|
+
// concern.
|
|
16
|
+
//
|
|
17
|
+
// IAP access is granted at the resource level on a VM's per-surface backend
|
|
18
|
+
// services (`vm-<N>-<surface>-bes`); this file is only the source-of-truth list,
|
|
19
|
+
// the gcloud binding is applied by scripts/gcp/iap-access.sh. The owner's
|
|
20
|
+
// project-wide grant (scripts/gcp/google-ingress.sh) is independent — these lists
|
|
21
|
+
// only ADD collaborators to specific VMs.
|
|
22
|
+
//
|
|
23
|
+
// Env override: `VM_ACCESS_FILE` relocates the file (mirrors fleet-vm-slugs.js's
|
|
24
|
+
// `VM_SLUGS_FILE`); resolved once at module load.
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const { rootDir } = require('./utils');
|
|
30
|
+
const { normalizeVmNumber } = require('./vm-urls');
|
|
31
|
+
|
|
32
|
+
const VM_ACCESS_FILE = process.env.VM_ACCESS_FILE
|
|
33
|
+
|| path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-access.json');
|
|
34
|
+
|
|
35
|
+
// The IAM principal types IAP accepts on a backend-service binding. A bare email
|
|
36
|
+
// (no type prefix) defaults to `user:`.
|
|
37
|
+
const PRINCIPAL_TYPES = Object.freeze(['user', 'group', 'domain']);
|
|
38
|
+
|
|
39
|
+
// A DNS domain: at least two dot-separated labels, each a lowercase
|
|
40
|
+
// letters/digits/hyphen label (no leading/trailing hyphen). Used to validate a
|
|
41
|
+
// `domain:` principal and the domain half of a `user:`/`group:` email — a typo'd
|
|
42
|
+
// principal must fail loud before reaching gcloud.
|
|
43
|
+
const DOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
|
44
|
+
|
|
45
|
+
/** Validate + normalize one IAM principal into the `<type>:<value>` form gcloud
|
|
46
|
+
* expects. Accepts `user:a@x.com`, `group:team@x.com`, `domain:x.com`; a bare
|
|
47
|
+
* `a@x.com` defaults to `user:`. Rejects (loudly) a malformed value so junk
|
|
48
|
+
* never reaches `gcloud iap web add-iam-policy-binding` — the same
|
|
49
|
+
* fail-at-the-boundary posture as normalizeVmSlug. Returns the lowercased,
|
|
50
|
+
* trimmed, type-prefixed principal. */
|
|
51
|
+
function normalizePrincipal(raw) {
|
|
52
|
+
if (raw == null) throw new Error('access principal is required');
|
|
53
|
+
const trimmed = String(raw).trim();
|
|
54
|
+
if (trimmed === '') throw new Error('access principal must not be empty');
|
|
55
|
+
const lower = trimmed.toLowerCase();
|
|
56
|
+
const colon = lower.indexOf(':');
|
|
57
|
+
const hasType = colon !== -1 && PRINCIPAL_TYPES.includes(lower.slice(0, colon));
|
|
58
|
+
const type = hasType ? lower.slice(0, colon) : 'user';
|
|
59
|
+
const value = hasType ? lower.slice(colon + 1) : lower;
|
|
60
|
+
if (value === '') {
|
|
61
|
+
throw new Error(`access principal "${raw}" has an empty value after "${type}:"`);
|
|
62
|
+
}
|
|
63
|
+
if (type === 'domain') {
|
|
64
|
+
if (value.includes('@') || !DOMAIN_RE.test(value)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`access principal "${raw}" is not a valid domain (e.g. domain:example.com)`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const at = value.indexOf('@');
|
|
71
|
+
if (at <= 0 || value.indexOf('@', at + 1) !== -1) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`access principal "${raw}" is not a valid ${type} email ` +
|
|
74
|
+
`(exactly one "@", e.g. ${type}:a@example.com)`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const domain = value.slice(at + 1);
|
|
78
|
+
if (!DOMAIN_RE.test(domain)) {
|
|
79
|
+
throw new Error(`access principal "${raw}" has an invalid email domain "${domain}"`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return `${type}:${value}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Normalize a whole access list — an array of principals or a comma-separated
|
|
86
|
+
* string — into a deduped, validated array of `<type>:<value>` principals.
|
|
87
|
+
* Blank entries are skipped; a single malformed entry throws (the list is
|
|
88
|
+
* rejected as a whole rather than silently dropping the bad one). Order of first
|
|
89
|
+
* appearance is preserved. */
|
|
90
|
+
function normalizeAccessList(input) {
|
|
91
|
+
const raw = Array.isArray(input)
|
|
92
|
+
? input
|
|
93
|
+
: String(input == null ? '' : input).split(',');
|
|
94
|
+
const out = [];
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const item of raw) {
|
|
97
|
+
const s = String(item).trim();
|
|
98
|
+
if (s === '') continue;
|
|
99
|
+
const principal = normalizePrincipal(s);
|
|
100
|
+
if (!seen.has(principal)) {
|
|
101
|
+
seen.add(principal);
|
|
102
|
+
out.push(principal);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
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
|
+
|
|
234
|
+
// Read the access sidecar, returning a `{ [vmNumber]: principal[] }` object, or
|
|
235
|
+
// null if the file is missing or malformed (treated as "no access lists"). Keys
|
|
236
|
+
// are the string form JSON emits; callers look them up by `String(n)`.
|
|
237
|
+
function readAccess() {
|
|
238
|
+
try {
|
|
239
|
+
const data = JSON.parse(fs.readFileSync(VM_ACCESS_FILE, 'utf8'));
|
|
240
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) return data;
|
|
241
|
+
} catch { /* missing or malformed */ }
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Persist `map` as the sidecar, atomically: write a pid-stamped temp file then
|
|
246
|
+
// `rename` it over the target so a concurrent reader never observes a
|
|
247
|
+
// half-written file (mirrors fleet-vm-slugs.js::writeSlugs). Returns the written
|
|
248
|
+
// map.
|
|
249
|
+
function writeAccess(map) {
|
|
250
|
+
const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {};
|
|
251
|
+
fs.mkdirSync(path.dirname(VM_ACCESS_FILE), { recursive: true });
|
|
252
|
+
const tmp = `${VM_ACCESS_FILE}.${process.pid}.tmp`;
|
|
253
|
+
fs.writeFileSync(tmp, JSON.stringify(next));
|
|
254
|
+
fs.renameSync(tmp, VM_ACCESS_FILE); // atomic replace
|
|
255
|
+
return next;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The access list for VM `n` — the principals whitelisted onto its surfaces —
|
|
259
|
+
* or an empty array when it has none (or the file is absent). The number is
|
|
260
|
+
* validated so junk can't sneak through as a lookup key. */
|
|
261
|
+
function accessFor(n) {
|
|
262
|
+
const num = normalizeVmNumber(n);
|
|
263
|
+
const map = readAccess();
|
|
264
|
+
const list = map && map[String(num)];
|
|
265
|
+
return Array.isArray(list) ? list.filter((p) => typeof p === 'string' && p) : [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Replace VM `n`'s access list wholesale with `principals` (validated + deduped
|
|
269
|
+
* via normalizeAccessList). An empty result removes the VM's entry entirely so
|
|
270
|
+
* the sidecar never accumulates empty arrays. Returns the stored list. */
|
|
271
|
+
function setAccess(n, principals) {
|
|
272
|
+
const num = normalizeVmNumber(n);
|
|
273
|
+
const list = normalizeAccessList(principals);
|
|
274
|
+
const map = { ...(readAccess() || {}) };
|
|
275
|
+
if (list.length === 0) delete map[String(num)];
|
|
276
|
+
else map[String(num)] = list;
|
|
277
|
+
writeAccess(map);
|
|
278
|
+
return list;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Add a single principal to VM `n`'s access list (validated + deduped). Adding
|
|
282
|
+
* one already present is an idempotent no-op-shaped write. Returns the new list. */
|
|
283
|
+
function addAccess(n, principal) {
|
|
284
|
+
const num = normalizeVmNumber(n);
|
|
285
|
+
const p = normalizePrincipal(principal);
|
|
286
|
+
const next = accessFor(num);
|
|
287
|
+
if (!next.includes(p)) next.push(p);
|
|
288
|
+
const map = { ...(readAccess() || {}) };
|
|
289
|
+
map[String(num)] = next;
|
|
290
|
+
writeAccess(map);
|
|
291
|
+
return next;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Remove a single principal from VM `n`'s access list. Removing an absent
|
|
295
|
+
* principal is a no-op; emptying the list removes the VM's entry. Returns the
|
|
296
|
+
* new list. */
|
|
297
|
+
function removeAccess(n, principal) {
|
|
298
|
+
const num = normalizeVmNumber(n);
|
|
299
|
+
const p = normalizePrincipal(principal);
|
|
300
|
+
const next = accessFor(num).filter((x) => x !== p);
|
|
301
|
+
const map = { ...(readAccess() || {}) };
|
|
302
|
+
if (next.length === 0) delete map[String(num)];
|
|
303
|
+
else map[String(num)] = next;
|
|
304
|
+
writeAccess(map);
|
|
305
|
+
return next;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Parse the raw stdout of `gcloud projects get-iam-policy ...
|
|
309
|
+
* --format='value(bindings.members)'` into a deduped, trimmed array of
|
|
310
|
+
* principals. gcloud separates members within a binding by `;` and (when not
|
|
311
|
+
* flattened) bindings by newline, so we split on BOTH. Order of first
|
|
312
|
+
* appearance is preserved.
|
|
313
|
+
*
|
|
314
|
+
* This is deliberately NOT normalizePrincipal: it is a *read* of whatever IAM
|
|
315
|
+
* already holds, so it must surface principals verbatim (lowercased only for
|
|
316
|
+
* dedup-stability) — including `serviceAccount:` members that the strict
|
|
317
|
+
* write-path validator would reject. The one thing it drops, besides blanks, is
|
|
318
|
+
* gcloud's `deleted:` tombstone members (e.g.
|
|
319
|
+
* `deleted:user:x@x.com?uid=123`), which name principals that no longer exist
|
|
320
|
+
* and can't be acted on. */
|
|
321
|
+
function parseProjectMembers(stdout) {
|
|
322
|
+
const out = [];
|
|
323
|
+
const seen = new Set();
|
|
324
|
+
for (const raw of String(stdout == null ? '' : stdout).split(/[;\n]/)) {
|
|
325
|
+
const s = raw.trim();
|
|
326
|
+
if (s === '') continue;
|
|
327
|
+
if (s.toLowerCase().startsWith('deleted:')) continue;
|
|
328
|
+
const principal = s.toLowerCase();
|
|
329
|
+
if (!seen.has(principal)) {
|
|
330
|
+
seen.add(principal);
|
|
331
|
+
out.push(principal);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
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
|
+
|
|
375
|
+
/** Drop VM `n`'s entire access list, returning the new map. Clearing an absent
|
|
376
|
+
* entry is a no-op. Called on Destroy so a number freed and later re-used
|
|
377
|
+
* doesn't inherit a stale whitelist. */
|
|
378
|
+
function clearAccess(n) {
|
|
379
|
+
const num = normalizeVmNumber(n);
|
|
380
|
+
const map = { ...(readAccess() || {}) };
|
|
381
|
+
delete map[String(num)];
|
|
382
|
+
return writeAccess(map);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = {
|
|
386
|
+
VM_ACCESS_FILE,
|
|
387
|
+
PRINCIPAL_TYPES,
|
|
388
|
+
DEFAULT_OPERATOR_ENV,
|
|
389
|
+
normalizePrincipal,
|
|
390
|
+
normalizeAccessList,
|
|
391
|
+
resolveDefaultOperator,
|
|
392
|
+
defaultCloudConfigRead,
|
|
393
|
+
noDefaultOperatorDegradedReasons,
|
|
394
|
+
withDefaultOperator,
|
|
395
|
+
parseProjectMembers,
|
|
396
|
+
reconcileVmAccess,
|
|
397
|
+
readAccess,
|
|
398
|
+
writeAccess,
|
|
399
|
+
accessFor,
|
|
400
|
+
setAccess,
|
|
401
|
+
addAccess,
|
|
402
|
+
removeAccess,
|
|
403
|
+
clearAccess,
|
|
404
|
+
};
|