@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/npm/cloud-branch-switch.js +1 -1
- package/npm/cloud.js +1693 -96
- package/npm/container.js +14 -0
- package/npm/disk-pressure.js +167 -0
- package/npm/editor-dev.js +107 -15
- package/npm/editor.js +97 -37
- package/npm/fleet-auth-health.js +75 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +130 -0
- package/npm/fleet-cleanup.js +145 -7
- package/npm/fleet-destroy-safety.js +55 -0
- package/npm/fleet-effect-status.js +33 -32
- package/npm/fleet-host.js +74 -0
- package/npm/fleet-job-badge.js +46 -2
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +123 -0
- package/npm/fleet-plans.js +231 -0
- package/npm/fleet-prewarm.js +165 -0
- package/npm/fleet-public-url.js +160 -0
- package/npm/fleet-queue.js +38 -1
- package/npm/fleet-reconcile.js +100 -1
- package/npm/fleet-reset.js +298 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-setup-steps.js +105 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-strand.js +150 -0
- package/npm/fleet-stuck-status.js +111 -0
- package/npm/fleet-vm-access.js +234 -0
- package/npm/fleet-vm-readiness.js +104 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +211 -0
- package/npm/ingress-invoke.js +292 -0
- package/npm/launch-config.js +310 -4
- package/npm/scenario-check.js +684 -33
- package/npm/scenario-handlers.js +94 -12
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +20 -0
- package/npm/scenario-playwright.js +366 -29
- package/npm/script-drift.js +81 -0
- package/npm/utils.js +453 -43
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
- package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
- package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
- package/ui/dist/assets/index-CIXZkwfB.css +1 -0
- package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
- package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
- package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// npm/fleet-vm-slugs.js — the per-VM custom-slug sidecar
|
|
4
|
+
// (`.codeyam/logs/fleet-vm-slugs.json`). A slug is an optional human-readable
|
|
5
|
+
// override for a VM's leftmost hostname label, so its public URLs become
|
|
6
|
+
// `<slug>.editor.<domain>` instead of the number-derived `vm-<N>.editor.<domain>`.
|
|
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 that
|
|
10
|
+
// bash consumers (`vm-urls.sh`, the watchdog) depend on. Rather than break that
|
|
11
|
+
// shape, slugs live in a SEPARATE map `{ "<N>": "<slug>" }`, fronted by a
|
|
12
|
+
// read+write seam modeled exactly on fleet-roster.js (atomic temp-file + rename,
|
|
13
|
+
// tolerant of a missing/malformed file).
|
|
14
|
+
//
|
|
15
|
+
// The slug is purely a public-hostname override — internal GCP resource names
|
|
16
|
+
// (NEG / backend service / path matcher) stay keyed off the VM number, so a VM
|
|
17
|
+
// added without a slug keeps its `vm-<N>` URLs unchanged.
|
|
18
|
+
//
|
|
19
|
+
// Env override: `VM_SLUGS_FILE` relocates the file (mirrors fleet-roster.js's
|
|
20
|
+
// `VMS_STATE_FILE`); resolved once at module load.
|
|
21
|
+
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
|
|
25
|
+
const { rootDir } = require('./utils');
|
|
26
|
+
const { normalizeVmNumber, normalizeVmSlug } = require('./vm-urls');
|
|
27
|
+
|
|
28
|
+
const VM_SLUGS_FILE = process.env.VM_SLUGS_FILE
|
|
29
|
+
|| path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-slugs.json');
|
|
30
|
+
|
|
31
|
+
// Read the slug sidecar, returning a `{ [vmNumber]: slug }` object, or null if
|
|
32
|
+
// the file is missing or malformed (treated as "no slugs"). Keys are kept as the
|
|
33
|
+
// string form JSON emits; callers look them up by `String(n)`.
|
|
34
|
+
function readSlugs() {
|
|
35
|
+
try {
|
|
36
|
+
const data = JSON.parse(fs.readFileSync(VM_SLUGS_FILE, 'utf8'));
|
|
37
|
+
if (data && typeof data === 'object' && !Array.isArray(data)) return data;
|
|
38
|
+
} catch { /* missing or malformed */ }
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Persist `map` as the sidecar, atomically: write a temp file then `rename` it
|
|
43
|
+
// over the target so a concurrent reader never observes a half-written file. The
|
|
44
|
+
// temp name carries the pid so two processes writing at once don't clobber each
|
|
45
|
+
// other's temp file before their renames land. Returns the written map.
|
|
46
|
+
function writeSlugs(map) {
|
|
47
|
+
const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {};
|
|
48
|
+
fs.mkdirSync(path.dirname(VM_SLUGS_FILE), { recursive: true });
|
|
49
|
+
const tmp = `${VM_SLUGS_FILE}.${process.pid}.tmp`;
|
|
50
|
+
fs.writeFileSync(tmp, JSON.stringify(next));
|
|
51
|
+
fs.renameSync(tmp, VM_SLUGS_FILE); // atomic replace
|
|
52
|
+
return next;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The slug mapped to VM `n`, or null when it has none (or the file is absent).
|
|
56
|
+
* This is the resolver every hostname derivation funnels through:
|
|
57
|
+
* `slugFor(n) || ('vm-' + n)`. */
|
|
58
|
+
function slugFor(n) {
|
|
59
|
+
const num = normalizeVmNumber(n);
|
|
60
|
+
const map = readSlugs();
|
|
61
|
+
const slug = map && map[String(num)];
|
|
62
|
+
return typeof slug === 'string' && slug.length > 0 ? slug : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The VM number currently mapped to `slug`, or null when no VM owns it. Used to
|
|
66
|
+
* reject a duplicate slug BEFORE it is assigned (a slug must be unique across
|
|
67
|
+
* the fleet, else two VMs would claim the same hostname). `slug` is normalized
|
|
68
|
+
* first so the comparison is case-insensitive. */
|
|
69
|
+
function vmForSlug(slug) {
|
|
70
|
+
const want = normalizeVmSlug(slug);
|
|
71
|
+
const map = readSlugs() || {};
|
|
72
|
+
for (const [k, v] of Object.entries(map)) {
|
|
73
|
+
if (typeof v === 'string' && v.toLowerCase() === want) {
|
|
74
|
+
const num = Number(k);
|
|
75
|
+
if (Number.isInteger(num) && num > 0) return num;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Assign `slug` to VM `n`, validating the slug (via `normalizeVmSlug`) and
|
|
82
|
+
* rejecting a slug already mapped to a DIFFERENT VM. Returns the written map.
|
|
83
|
+
* Re-assigning a VM its own existing slug is an idempotent no-op-shaped write. */
|
|
84
|
+
function setSlug(n, slug) {
|
|
85
|
+
const num = normalizeVmNumber(n);
|
|
86
|
+
const norm = normalizeVmSlug(slug);
|
|
87
|
+
const owner = vmForSlug(norm);
|
|
88
|
+
if (owner != null && owner !== num) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`VM slug "${norm}" is already assigned to VM-${owner} — pick another name`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const map = { ...(readSlugs() || {}) };
|
|
94
|
+
map[String(num)] = norm;
|
|
95
|
+
return writeSlugs(map);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Remove VM `n`'s slug mapping, returning the new map. Removing an absent entry
|
|
99
|
+
* is a no-op. Called on Destroy so a number freed and later re-used doesn't
|
|
100
|
+
* inherit a stale slug. */
|
|
101
|
+
function removeSlug(n) {
|
|
102
|
+
const num = normalizeVmNumber(n);
|
|
103
|
+
const map = { ...(readSlugs() || {}) };
|
|
104
|
+
delete map[String(num)];
|
|
105
|
+
return writeSlugs(map);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
VM_SLUGS_FILE,
|
|
110
|
+
readSlugs,
|
|
111
|
+
writeSlugs,
|
|
112
|
+
slugFor,
|
|
113
|
+
vmForSlug,
|
|
114
|
+
setSlug,
|
|
115
|
+
removeSlug,
|
|
116
|
+
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Deterministic Google Cloud HTTPS-LB resource-name derivation for the fleet.
|
|
3
|
+
//
|
|
4
|
+
// The fleet's public browser ingress moves from cloudflared named tunnels to a
|
|
5
|
+
// single Google external HTTPS Load Balancer (one URL map, host-based routing).
|
|
6
|
+
// Reaching a specific port on a specific VM needs a zonal NEG + backend service
|
|
7
|
+
// per (VM, surface); the URL map then routes each `vm-<N>.<surface>.<domain>`
|
|
8
|
+
// host to that backend. This module is the single source of truth for those
|
|
9
|
+
// Google resource NAMES so the bash provisioners (scripts/gcp/google-ingress.sh,
|
|
10
|
+
// scripts/gcp/vm-ingress.sh) and any node caller derive identical strings and a
|
|
11
|
+
// unit test guards them against drift — exactly the contract npm/vm-urls.js and
|
|
12
|
+
// npm/launch-config.js follow.
|
|
13
|
+
//
|
|
14
|
+
// Pure logic only — no I/O, no env reads beyond the explicit `env` argument a
|
|
15
|
+
// caller passes in, so it stays unit-testable in isolation
|
|
16
|
+
// (npm/google-ingress.test.js). The HOSTNAMES it routes to are reused verbatim
|
|
17
|
+
// from npm/vm-urls.js (vmHostnames / dashboardHostname): the Google migration is
|
|
18
|
+
// a serving-layer swap, not a URL-scheme change, so hostnames must never drift
|
|
19
|
+
// from the cert/DNS that vm-urls.js already defines.
|
|
20
|
+
//
|
|
21
|
+
// Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that
|
|
22
|
+
// runs codeyam-editor itself), not a per-app-stack assumption. A project that
|
|
23
|
+
// never joins a Google fleet never sets FLEET_DOMAIN / FLEET_INGRESS=google and
|
|
24
|
+
// never derives a name here; the legacy cloudflared path stays the fallback.
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
normalizeVmNumber,
|
|
28
|
+
vmHostnames,
|
|
29
|
+
dashboardHostname,
|
|
30
|
+
} = require('./vm-urls');
|
|
31
|
+
|
|
32
|
+
// The two per-VM ingress surfaces. `editor` fronts the in-VM editor control API
|
|
33
|
+
// (a fixed port); `preview` fronts the VM's dev server (a per-project port).
|
|
34
|
+
// Keep this list as the single definition both name derivation and the host-rule
|
|
35
|
+
// builder iterate, so a third surface is one edit, not three.
|
|
36
|
+
const SURFACES = Object.freeze(['editor', 'preview']);
|
|
37
|
+
|
|
38
|
+
// The in-VM editor control API port. Fixed across the fleet (matches
|
|
39
|
+
// named-tunnel-config.sh's EDITOR_PORT default and cloud.js's `-L …:14199`), so
|
|
40
|
+
// the editor backend NEG always targets this port regardless of project.
|
|
41
|
+
const EDITOR_PORT = 14199;
|
|
42
|
+
|
|
43
|
+
/** The fixed in-VM editor control-API port the editor backend NEG targets. A
|
|
44
|
+
* function (not a bare constant export) so callers read it the same way they
|
|
45
|
+
* read the per-VM appPort — a uniform "resolve the port for this surface" shape. */
|
|
46
|
+
function editorPort() {
|
|
47
|
+
return EDITOR_PORT;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validate an ingress surface against SURFACES. The NEG / backend-service /
|
|
51
|
+
* host-rule derivations all key off it, so a typo'd surface would mint a
|
|
52
|
+
* resource that nothing routes to — reject it loudly at the boundary, mirroring
|
|
53
|
+
* how vm-urls.js rejects a bad VM number or domain. Returns the surface. */
|
|
54
|
+
function normalizeSurface(surface) {
|
|
55
|
+
if (!SURFACES.includes(surface)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`surface must be one of ${SURFACES.join('|')}, got: ${surface}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return surface;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The zonal NEG name for a (VM, surface): `vm-<N>-<surface>-neg`. One network
|
|
64
|
+
* endpoint group per surface points at the VM's internal IP + that surface's
|
|
65
|
+
* port — the Google-native analog of a cloudflared ingress rule's local origin. */
|
|
66
|
+
function negName(n, surface) {
|
|
67
|
+
const num = normalizeVmNumber(n);
|
|
68
|
+
return `vm-${num}-${normalizeSurface(surface)}-neg`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The backend-service name for a (VM, surface): `vm-<N>-<surface>-bes`. One
|
|
72
|
+
* backend service per host rule wraps the matching NEG (with IAP + a health
|
|
73
|
+
* check); the URL map's host rule points at this name. */
|
|
74
|
+
function backendServiceName(n, surface) {
|
|
75
|
+
const num = normalizeVmNumber(n);
|
|
76
|
+
return `vm-${num}-${normalizeSurface(surface)}-bes`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The URL-map host rules for a VM: one `{ host, surface, backendService }` per
|
|
80
|
+
* surface, where `host` is the EXACT hostname vm-urls.js derives (so the LB
|
|
81
|
+
* routes the same hostnames the cert covers and DNS resolves) and
|
|
82
|
+
* `backendService` is this module's name for that surface. Reusing
|
|
83
|
+
* `vmHostnames` rather than re-deriving keeps the hostname scheme single-sourced.
|
|
84
|
+
* Returns the surfaces in SURFACES order so the provisioner's output is stable.
|
|
85
|
+
*
|
|
86
|
+
* An optional `slug` overrides the routed `host` with the VM's custom hostname
|
|
87
|
+
* (`<slug>.<surface>.<domain>`) while the NEG / `backendService` names stay
|
|
88
|
+
* keyed off the VM NUMBER — the slug changes only what the URL map matches, not
|
|
89
|
+
* the internal GCP resource names. */
|
|
90
|
+
function urlMapHostRules(n, domain, slug) {
|
|
91
|
+
const hosts = vmHostnames(n, domain, slug); // also validates n, domain, slug
|
|
92
|
+
return SURFACES.map((surface) => ({
|
|
93
|
+
host: hosts[surface],
|
|
94
|
+
surface,
|
|
95
|
+
backendService: backendServiceName(n, surface),
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Decide which gcloud URL-map operation reconciles a single `(host,
|
|
100
|
+
* pathMatcher)` against the map's CURRENT state, given `hostRules` — the
|
|
101
|
+
* flattened `[{ host, pathMatcher }]` projection of the URL map's host rules
|
|
102
|
+
* (one entry per host, since one host rule can list several hosts). Returns
|
|
103
|
+
* `{ op }`, one of:
|
|
104
|
+
* - `skip` — `host` already routes to `pathMatcher`; nothing to do.
|
|
105
|
+
* - `add-host-rule` — `pathMatcher` already exists (some other host points
|
|
106
|
+
* at it, e.g. the numeric `vmN.<surface>` host) but
|
|
107
|
+
* `host` is not attached: attach it. Fixes the
|
|
108
|
+
* `Duplicate path matcher` failure on a reused slug.
|
|
109
|
+
* - `repoint` — `host` exists but aims at a DIFFERENT matcher (a stale
|
|
110
|
+
* rule left by a destroyed VM): remove it, then attach
|
|
111
|
+
* `host` to the correct matcher. Fixes a reused slug
|
|
112
|
+
* silently skipped as "already configured" while it
|
|
113
|
+
* still routed to a dead backend.
|
|
114
|
+
* - `add-path-matcher`— neither `host` nor `pathMatcher` is present: create
|
|
115
|
+
* the matcher and attach the host (the original path).
|
|
116
|
+
*
|
|
117
|
+
* Pure and side-effect-free — no gcloud, no I/O — so vm-ingress.sh can read the
|
|
118
|
+
* URL-map state in bash, ask this helper for the op, and run the matching
|
|
119
|
+
* gcloud command, keeping the four-way decision unit-testable (the same
|
|
120
|
+
* bash↔node contract the name derivations above follow). Tolerates a
|
|
121
|
+
* missing/empty `hostRules`. */
|
|
122
|
+
function hostRuleOperation({ hostRules, host, pathMatcher } = {}) {
|
|
123
|
+
if (!host) throw new Error('hostRuleOperation requires a host');
|
|
124
|
+
if (!pathMatcher) throw new Error('hostRuleOperation requires a pathMatcher');
|
|
125
|
+
const rules = Array.isArray(hostRules) ? hostRules : [];
|
|
126
|
+
const existing = rules.find((r) => r && r.host === host);
|
|
127
|
+
if (existing) {
|
|
128
|
+
return { op: existing.pathMatcher === pathMatcher ? 'skip' : 'repoint' };
|
|
129
|
+
}
|
|
130
|
+
const matcherExists = rules.some((r) => r && r.pathMatcher === pathMatcher);
|
|
131
|
+
return { op: matcherExists ? 'add-host-rule' : 'add-path-matcher' };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Decide whether a NEG must be attached as a backend to its backend service,
|
|
135
|
+
* given `backends` — the BES's CURRENT `backends[].group` self-links (what
|
|
136
|
+
* `gcloud compute backend-services describe --format='value(backends[].group)'`
|
|
137
|
+
* emits) — and `neg`, the bare NEG name this surface expects. Returns `{ op }`:
|
|
138
|
+
* - `skip` — some current backend already resolves to `neg`; nothing to do.
|
|
139
|
+
* - `attach` — `neg` is not yet a backend (an empty BES, or one wired to a
|
|
140
|
+
* different group): `add-backend` it.
|
|
141
|
+
*
|
|
142
|
+
* `backends[].group` is a full self-link
|
|
143
|
+
* (`.../networkEndpointGroups/vm-3-editor-neg`), so the match compares by
|
|
144
|
+
* basename — the bare NEG name — not the whole URL. Pure and side-effect-free
|
|
145
|
+
* (no gcloud, no I/O), so vm-ingress.sh reads the BES's backends in bash, asks
|
|
146
|
+
* this helper for the op, and `add-backend`s only when it returns `attach`: the
|
|
147
|
+
* same bash↔node contract `hostRuleOperation` follows. This is what makes `up`
|
|
148
|
+
* *converge* a BES that pre-exists with zero backends (the permanent-503 empty-
|
|
149
|
+
* backend bug) instead of skipping the attach as "already configured". Throws on
|
|
150
|
+
* a missing `neg` (mirrors `hostRuleOperation`'s guard); tolerates a
|
|
151
|
+
* missing/empty `backends`. */
|
|
152
|
+
function backendNegAttachOperation({ backends, neg } = {}) {
|
|
153
|
+
if (!neg) throw new Error('backendNegAttachOperation requires a neg');
|
|
154
|
+
const groups = Array.isArray(backends) ? backends : [];
|
|
155
|
+
const basename = (g) => String(g).split('/').pop();
|
|
156
|
+
const attached = groups.some((g) => g && basename(g) === neg);
|
|
157
|
+
return { op: attached ? 'skip' : 'attach' };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The dashboard's backend-service name: `dashboard-bes`. The dashboard is a
|
|
161
|
+
* single fleet-wide surface (not per-VM), so its name carries no VM number. The
|
|
162
|
+
* backend itself is only fully wired once the dashboard moves onto GCE
|
|
163
|
+
* (vm-dashboard-on-gce), but the name is defined here so the shared provisioner
|
|
164
|
+
* and that dependent plan agree. */
|
|
165
|
+
function dashboardBackendServiceName() {
|
|
166
|
+
return 'dashboard-bes';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The dashboard's zonal NEG name: `dashboard-neg`. One network endpoint group
|
|
170
|
+
* points at the operator host's internal IP + the dashboard port (8787) — the
|
|
171
|
+
* Google-native analog of the dashboard's old ngrok upstream. Single fleet-wide
|
|
172
|
+
* surface, so (like the backend-service name) it carries no VM number. Wired by
|
|
173
|
+
* scripts/gcp/dashboard-ingress.sh once the dashboard moves onto GCE. */
|
|
174
|
+
function dashboardNegName() {
|
|
175
|
+
return 'dashboard-neg';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The dashboard's dedicated health-check name: `dashboard-hc`. The per-VM
|
|
179
|
+
* surfaces share `fleet-ingress-hc` (port 14199), but the dashboard backend
|
|
180
|
+
* health-checks its own port (8787, path /healthz), so it needs its own check
|
|
181
|
+
* rather than reusing the editor-port one. */
|
|
182
|
+
function dashboardHealthCheckName() {
|
|
183
|
+
return 'dashboard-hc';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The dashboard's single URL-map host rule: `{ host, backendService }`, where
|
|
187
|
+
* `host` is `dashboard.<domain>` from vm-urls.js. Separate from
|
|
188
|
+
* `urlMapHostRules` because the dashboard is fleet-wide, not keyed off a VM
|
|
189
|
+
* number. */
|
|
190
|
+
function dashboardHostRule(domain) {
|
|
191
|
+
return {
|
|
192
|
+
host: dashboardHostname(domain), // validates domain
|
|
193
|
+
backendService: dashboardBackendServiceName(),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
SURFACES,
|
|
199
|
+
EDITOR_PORT,
|
|
200
|
+
editorPort,
|
|
201
|
+
normalizeSurface,
|
|
202
|
+
negName,
|
|
203
|
+
backendServiceName,
|
|
204
|
+
urlMapHostRules,
|
|
205
|
+
hostRuleOperation,
|
|
206
|
+
backendNegAttachOperation,
|
|
207
|
+
dashboardBackendServiceName,
|
|
208
|
+
dashboardNegName,
|
|
209
|
+
dashboardHealthCheckName,
|
|
210
|
+
dashboardHostRule,
|
|
211
|
+
};
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Pure builders for the LOCAL shell invocations that wire the fleet's Google
|
|
3
|
+
// HTTPS Load Balancer ingress: the shared, VM-independent infra
|
|
4
|
+
// (scripts/gcp/google-ingress.sh) and one VM's per-surface backends
|
|
5
|
+
// (scripts/gcp/vm-ingress.sh). cloud.js calls these to decide WHEN to wire and
|
|
6
|
+
// to build the exact `{ command, args, env }` it spawns — keeping the decision
|
|
7
|
+
// and the argv/env shape unit-testable without ever spawning a process, exactly
|
|
8
|
+
// like npm/vm-urls.js and npm/google-ingress.js single-source their derivations.
|
|
9
|
+
//
|
|
10
|
+
// Pure logic only — no I/O, no env reads beyond the explicit `env`/`cfg`
|
|
11
|
+
// arguments callers pass in. The fleet domain governing ingress comes from the
|
|
12
|
+
// project's `.codeyam/cloud.json` (`fleetDomain`, persisted by cloud.js) with a
|
|
13
|
+
// `FLEET_DOMAIN` env fallback; when neither is set every builder reports the
|
|
14
|
+
// legacy quick-tunnel mode (resolveIngressDomain → null) and cloud.js leaves the
|
|
15
|
+
// Add/Destroy path byte-for-byte unchanged.
|
|
16
|
+
//
|
|
17
|
+
// Stack note: this is fleet-operator infrastructure (the cloud GCE fleet that
|
|
18
|
+
// runs codeyam-editor itself), GCP-specific by design — the same posture as the
|
|
19
|
+
// scripts it invokes. A project that never joins the fleet never sets
|
|
20
|
+
// fleetDomain and never reaches a builder here.
|
|
21
|
+
|
|
22
|
+
const { resolveFleetDomain } = require('./vm-urls');
|
|
23
|
+
|
|
24
|
+
// Cloud DNS managed-zone name google-ingress.sh defaults to. Mirrored here so a
|
|
25
|
+
// cloud.json without an explicit `dnsZone` resolves to the same zone the script
|
|
26
|
+
// would, rather than passing an empty DNS_ZONE that shadows the script default.
|
|
27
|
+
const DEFAULT_DNS_ZONE = 'fleet-zone';
|
|
28
|
+
|
|
29
|
+
/** Resolve the fleet domain that governs ingress wiring, or null when none is
|
|
30
|
+
* configured (legacy quick-tunnel mode). `cfg.fleetDomain` (persisted in
|
|
31
|
+
* cloud.json) wins; the `FLEET_DOMAIN` env value is the fallback. Both paths go
|
|
32
|
+
* through vm-urls.js::resolveFleetDomain, so a present-but-malformed value
|
|
33
|
+
* throws here (a typo'd domain must fail loud, never bake a dead host into the
|
|
34
|
+
* URL map) and a blank value reads as "unset". */
|
|
35
|
+
function resolveIngressDomain(cfg = {}, env = {}) {
|
|
36
|
+
const fromCfg = cfg.fleetDomain;
|
|
37
|
+
if (fromCfg != null && String(fromCfg).trim() !== '') {
|
|
38
|
+
return resolveFleetDomain({ FLEET_DOMAIN: fromCfg });
|
|
39
|
+
}
|
|
40
|
+
return resolveFleetDomain(env);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Resolve the Cloud DNS managed-zone name: `cfg.dnsZone`, then a `DNS_ZONE`
|
|
44
|
+
* env override, then the `fleet-zone` default google-ingress.sh itself uses. */
|
|
45
|
+
function resolveDnsZone(cfg = {}, env = {}) {
|
|
46
|
+
const fromCfg = cfg.dnsZone;
|
|
47
|
+
if (fromCfg != null && String(fromCfg).trim() !== '') return String(fromCfg).trim();
|
|
48
|
+
const fromEnv = env.DNS_ZONE;
|
|
49
|
+
if (fromEnv != null && String(fromEnv).trim() !== '') return String(fromEnv).trim();
|
|
50
|
+
return DEFAULT_DNS_ZONE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Derive the GCE region from a zone (`us-central1-a` → `us-central1`), or null
|
|
54
|
+
* when the zone is missing/unparseable so the caller omits REGION and lets
|
|
55
|
+
* google-ingress.sh apply its own default. A GCE zone is a region plus a
|
|
56
|
+
* trailing `-<letter>`. */
|
|
57
|
+
function regionFromZone(zone) {
|
|
58
|
+
if (zone == null) return null;
|
|
59
|
+
const z = String(zone).trim();
|
|
60
|
+
if (z === '') return null;
|
|
61
|
+
const m = /^(.+)-[a-z]$/.exec(z);
|
|
62
|
+
return m ? m[1] : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> <up|down>` —
|
|
66
|
+
* one VM's per-surface NEG → IAP backend → URL-map host rule. Returns
|
|
67
|
+
* `{ command, args, env }`; `env` carries only the keys the script reads beyond
|
|
68
|
+
* its own defaults (GCP_PROJECT, ZONE, FLEET_DOMAIN, APP_PORT, INSTANCE_PREFIX,
|
|
69
|
+
* VM_SLUG). `appPort` is the resolved per-project dev-server port the preview
|
|
70
|
+
* NEG targets; omit it (down, or unknown) to let the script's bootstrap default
|
|
71
|
+
* stand. An optional `slug` becomes `VM_SLUG`, which makes the script route the
|
|
72
|
+
* VM's custom `<slug>.<surface>.<domain>` host (the same slug must be passed on
|
|
73
|
+
* `down` so the matching host rule is removed); omit it to keep the numeric
|
|
74
|
+
* `vm-<N>` host. */
|
|
75
|
+
function vmIngressInvocation({
|
|
76
|
+
scriptPath,
|
|
77
|
+
vmNumber,
|
|
78
|
+
action,
|
|
79
|
+
domain,
|
|
80
|
+
project,
|
|
81
|
+
zone,
|
|
82
|
+
appPort,
|
|
83
|
+
instancePrefix,
|
|
84
|
+
slug,
|
|
85
|
+
} = {}) {
|
|
86
|
+
if (!scriptPath) throw new Error('vmIngressInvocation requires scriptPath');
|
|
87
|
+
if (action !== 'up' && action !== 'down') {
|
|
88
|
+
throw new Error(`vm-ingress action must be 'up' or 'down', got: ${action}`);
|
|
89
|
+
}
|
|
90
|
+
const n = Number(vmNumber);
|
|
91
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
92
|
+
throw new Error(`vm-ingress requires a positive VM number, got: ${vmNumber}`);
|
|
93
|
+
}
|
|
94
|
+
if (!domain) throw new Error('vmIngressInvocation requires a fleet domain');
|
|
95
|
+
const env = { FLEET_DOMAIN: domain };
|
|
96
|
+
if (project) env.GCP_PROJECT = project;
|
|
97
|
+
if (zone) env.ZONE = zone;
|
|
98
|
+
if (appPort != null && String(appPort).trim() !== '') env.APP_PORT = String(appPort);
|
|
99
|
+
if (instancePrefix) env.INSTANCE_PREFIX = instancePrefix;
|
|
100
|
+
if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
|
|
101
|
+
return { command: 'bash', args: [scriptPath, String(n), action], env };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> status` — the
|
|
105
|
+
* read-only probe that reports which of {NEG, backend service, host rule} exist
|
|
106
|
+
* per surface plus an overall `verdict=wired|partial|unwired`. Returns
|
|
107
|
+
* `{ command, args, env }`. Like `vmIngressInvocation` but the action is fixed
|
|
108
|
+
* to `status` and APP_PORT is omitted — status describes resources by NAME, and
|
|
109
|
+
* names key off the VM number, never the port. An optional `slug` rides through
|
|
110
|
+
* as VM_SLUG so the host-rule presence check matches the VM's custom
|
|
111
|
+
* `<slug>.<surface>.<domain>` host rather than the numeric `vm-<N>` one. */
|
|
112
|
+
function vmIngressStatusInvocation({
|
|
113
|
+
scriptPath,
|
|
114
|
+
vmNumber,
|
|
115
|
+
domain,
|
|
116
|
+
project,
|
|
117
|
+
zone,
|
|
118
|
+
slug,
|
|
119
|
+
} = {}) {
|
|
120
|
+
if (!scriptPath) throw new Error('vmIngressStatusInvocation requires scriptPath');
|
|
121
|
+
const n = Number(vmNumber);
|
|
122
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
123
|
+
throw new Error(`vm-ingress status requires a positive VM number, got: ${vmNumber}`);
|
|
124
|
+
}
|
|
125
|
+
if (!domain) throw new Error('vmIngressStatusInvocation requires a fleet domain');
|
|
126
|
+
const env = { FLEET_DOMAIN: domain };
|
|
127
|
+
if (project) env.GCP_PROJECT = project;
|
|
128
|
+
if (zone) env.ZONE = zone;
|
|
129
|
+
if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
|
|
130
|
+
return { command: 'bash', args: [scriptPath, String(n), 'status'], env };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Parse the `verdict=<wired|partial|unwired>` line vm-ingress.sh `status`
|
|
134
|
+
* prints, returning that verdict or null when the output carries none (e.g. the
|
|
135
|
+
* script errored before emitting it). Pure string parsing so the reconcile
|
|
136
|
+
* decision below stays unit-testable without spawning the script. */
|
|
137
|
+
function parseIngressStatusVerdict(stdout) {
|
|
138
|
+
if (typeof stdout !== 'string') return null;
|
|
139
|
+
for (const line of stdout.split('\n')) {
|
|
140
|
+
const m = /^verdict=(wired|partial|unwired)\s*$/.exec(line.trim());
|
|
141
|
+
if (m) return m[1];
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Pick which rostered VMs reconcile must re-wire from their status verdicts.
|
|
147
|
+
* `statuses` is `[{ vmNumber, verdict, ... }]`; a VM is selected iff its verdict
|
|
148
|
+
* is `unwired` or `partial` (it has missing LB resources). `wired` VMs are left
|
|
149
|
+
* untouched — no redundant gcloud churn — and an unknown/null verdict (the probe
|
|
150
|
+
* failed) is conservatively SKIPPED rather than blindly re-wired. Returns the
|
|
151
|
+
* selected entries in their input order, preserving any extra fields (slug) the
|
|
152
|
+
* caller threads through. Pure. */
|
|
153
|
+
function selectIngressReconcileTargets(statuses) {
|
|
154
|
+
if (!Array.isArray(statuses)) return [];
|
|
155
|
+
return statuses.filter((s) => s && (s.verdict === 'unwired' || s.verdict === 'partial'));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// =============================================================================
|
|
159
|
+
// Async add-time wiring (vm--async-add-time-lb-ingress-wiring)
|
|
160
|
+
// =============================================================================
|
|
161
|
+
// The Add used to BLOCK ~10 min on synchronous LB ingress wiring even after the
|
|
162
|
+
// container was healthy and serving. The fix returns the Add at container-healthy
|
|
163
|
+
// and converges the wiring in a DETACHED background tail (wire → access, the same
|
|
164
|
+
// ordering cmdUp ran inline) that outlives `cloud:up` — so the dashboard's reap of
|
|
165
|
+
// `cloud:up` can never SIGTERM an in-flight `backend-services create` (the add-time
|
|
166
|
+
// 503 strand the old 900s synchronous await was protecting against). The
|
|
167
|
+
// already-existing per-VM auto-rewire gather is the convergence backstop; it must
|
|
168
|
+
// NOT race the in-flight detached tail, so the tail drops an in-flight marker the
|
|
169
|
+
// dashboard reads via the pure `isWiringInflight` below.
|
|
170
|
+
|
|
171
|
+
/** Build the local invocation that re-execs THIS node entry (`scriptPath`, i.e.
|
|
172
|
+
* cloud.js) as a DETACHED `wire-add-tail` child: it runs the slow wiring + access
|
|
173
|
+
* grant in the background so the Add returns at container-healthy. Returns
|
|
174
|
+
* `{ command, args, env }`; the child reads `cfg`/`appPort` back out of the env
|
|
175
|
+
* (`CY_WIRE_ADD_CFG` JSON, `CY_WIRE_ADD_PORT`) rather than re-deriving them, so the
|
|
176
|
+
* background tail wires EXACTLY what the foreground Add would have. `nodePath` is
|
|
177
|
+
* the node executable to re-exec (caller passes `process.execPath`); kept a
|
|
178
|
+
* parameter so this stays pure/testable without reading the ambient env. */
|
|
179
|
+
function detachedAddTailInvocation({ nodePath, scriptPath, cfg, appPort } = {}) {
|
|
180
|
+
if (!nodePath) throw new Error('detachedAddTailInvocation requires nodePath');
|
|
181
|
+
if (!scriptPath) throw new Error('detachedAddTailInvocation requires scriptPath');
|
|
182
|
+
const env = { CY_WIRE_ADD_CFG: JSON.stringify(cfg || {}) };
|
|
183
|
+
if (appPort != null && String(appPort).trim() !== '') {
|
|
184
|
+
env.CY_WIRE_ADD_PORT = String(appPort);
|
|
185
|
+
}
|
|
186
|
+
return { command: nodePath, args: [scriptPath, 'wire-add-tail'], env };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The per-VM marker file the detached add-tail holds while its wiring is still
|
|
190
|
+
* running, so the dashboard's auto-rewire gather skips a VM whose FIRST wiring
|
|
191
|
+
* attempt is in flight (never firing a competing `vm-ingress.sh up`). Pure path
|
|
192
|
+
* derivation; the create/unlink/stat I/O lives at the call sites (cloud.js writes
|
|
193
|
+
* it, server.js reads it), mirroring how the status/verdict split keeps the
|
|
194
|
+
* decision testable without touching the filesystem. */
|
|
195
|
+
function ingressInflightMarkerPath(dir, vmNumber) {
|
|
196
|
+
const n = Number(vmNumber);
|
|
197
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
198
|
+
throw new Error(`ingressInflightMarkerPath requires a positive VM number, got: ${vmNumber}`);
|
|
199
|
+
}
|
|
200
|
+
return `${dir.replace(/\/+$/, '')}/cy-ingress-inflight-vm-${n}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Decide whether a freshly-added VM's detached wiring is still in flight, given
|
|
204
|
+
* the marker file's CONTENTS (the unix-ms start timestamp the tail wrote) and the
|
|
205
|
+
* current time. Returns false when the marker is missing/empty (`content` null) —
|
|
206
|
+
* no tail running — or STALE (older than `maxAgeMs`): a tail that crashed without
|
|
207
|
+
* unlinking its marker must not pin auto-rewire off forever, so once the staleness
|
|
208
|
+
* bound (comfortably past the ~10-min worst-case wiring) elapses the backstop takes
|
|
209
|
+
* over. Pure: the caller does the `readFileSync`/`existsSync`. */
|
|
210
|
+
function isWiringInflight(content, now, maxAgeMs) {
|
|
211
|
+
if (content == null) return false;
|
|
212
|
+
const started = Number(String(content).trim());
|
|
213
|
+
if (!Number.isFinite(started) || started <= 0) return false;
|
|
214
|
+
return (Number(now) - started) < Number(maxAgeMs);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Build the local invocation for `scripts/gcp/iap-access.sh <N> <grant|revoke>`
|
|
218
|
+
* — grant or revoke `roles/iap.httpsResourceAccessor` on one VM's per-surface
|
|
219
|
+
* backend services for a set of principals (the per-VM access whitelist).
|
|
220
|
+
* Returns `{ command, args, env }`; `env.VM_ACCESS` is the comma-joined,
|
|
221
|
+
* trimmed principal list the script reads (omitted when empty, so a revoke with
|
|
222
|
+
* nothing to remove is a clean no-op), plus GCP_PROJECT / ZONE when supplied.
|
|
223
|
+
* Mirrors `vmIngressInvocation` so the spawn shape stays unit-testable without
|
|
224
|
+
* ever invoking gcloud. The principal STRINGS are passed through verbatim —
|
|
225
|
+
* validation/normalization is the sidecar's job (npm/fleet-vm-access.js); this
|
|
226
|
+
* builder only shapes the argv/env. */
|
|
227
|
+
function iapAccessInvocation({
|
|
228
|
+
scriptPath,
|
|
229
|
+
vmNumber,
|
|
230
|
+
action,
|
|
231
|
+
principals,
|
|
232
|
+
project,
|
|
233
|
+
zone,
|
|
234
|
+
} = {}) {
|
|
235
|
+
if (!scriptPath) throw new Error('iapAccessInvocation requires scriptPath');
|
|
236
|
+
if (action !== 'grant' && action !== 'revoke') {
|
|
237
|
+
throw new Error(`iap-access action must be 'grant' or 'revoke', got: ${action}`);
|
|
238
|
+
}
|
|
239
|
+
const n = Number(vmNumber);
|
|
240
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
241
|
+
throw new Error(`iap-access requires a positive VM number, got: ${vmNumber}`);
|
|
242
|
+
}
|
|
243
|
+
const list = Array.isArray(principals)
|
|
244
|
+
? principals
|
|
245
|
+
: String(principals == null ? '' : principals).split(',');
|
|
246
|
+
const joined = list.map((p) => String(p).trim()).filter(Boolean).join(',');
|
|
247
|
+
const env = {};
|
|
248
|
+
if (joined) env.VM_ACCESS = joined;
|
|
249
|
+
if (project) env.GCP_PROJECT = project;
|
|
250
|
+
if (zone) env.ZONE = zone;
|
|
251
|
+
return { command: 'bash', args: [scriptPath, String(n), action], env };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Build the local invocation for `scripts/gcp/google-ingress.sh` — the shared,
|
|
255
|
+
* VM-independent LB infra (static IP, wildcard cert, Cloud DNS, URL map, IAP
|
|
256
|
+
* brand). Returns `{ command, args, env }`; `env` carries only the keys the
|
|
257
|
+
* script reads beyond its defaults (GCP_PROJECT, REGION, ZONE, FLEET_DOMAIN,
|
|
258
|
+
* DNS_ZONE). The script is idempotent, so re-invoking against existing infra is
|
|
259
|
+
* a safe no-op. */
|
|
260
|
+
function sharedIngressInvocation({
|
|
261
|
+
scriptPath,
|
|
262
|
+
domain,
|
|
263
|
+
project,
|
|
264
|
+
zone,
|
|
265
|
+
dnsZone,
|
|
266
|
+
region,
|
|
267
|
+
} = {}) {
|
|
268
|
+
if (!scriptPath) throw new Error('sharedIngressInvocation requires scriptPath');
|
|
269
|
+
if (!domain) throw new Error('sharedIngressInvocation requires a fleet domain');
|
|
270
|
+
const env = { FLEET_DOMAIN: domain };
|
|
271
|
+
if (project) env.GCP_PROJECT = project;
|
|
272
|
+
if (region) env.REGION = region;
|
|
273
|
+
if (zone) env.ZONE = zone;
|
|
274
|
+
if (dnsZone) env.DNS_ZONE = dnsZone;
|
|
275
|
+
return { command: 'bash', args: [scriptPath], env };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
module.exports = {
|
|
279
|
+
DEFAULT_DNS_ZONE,
|
|
280
|
+
resolveIngressDomain,
|
|
281
|
+
resolveDnsZone,
|
|
282
|
+
regionFromZone,
|
|
283
|
+
vmIngressInvocation,
|
|
284
|
+
vmIngressStatusInvocation,
|
|
285
|
+
parseIngressStatusVerdict,
|
|
286
|
+
selectIngressReconcileTargets,
|
|
287
|
+
detachedAddTailInvocation,
|
|
288
|
+
ingressInflightMarkerPath,
|
|
289
|
+
isWiringInflight,
|
|
290
|
+
iapAccessInvocation,
|
|
291
|
+
sharedIngressInvocation,
|
|
292
|
+
};
|