@lensmcp/cluster 1.0.0
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/LICENSE +202 -0
- package/README.md +130 -0
- package/basic-ssl.d.ts +7 -0
- package/basic-ssl.d.ts.map +1 -0
- package/basic-ssl.js +158 -0
- package/build-scope-patterns.d.ts +12 -0
- package/build-scope-patterns.d.ts.map +1 -0
- package/build-scope-patterns.js +40 -0
- package/create-webpack-dev.d.ts +27 -0
- package/create-webpack-dev.d.ts.map +1 -0
- package/create-webpack-dev.js +151 -0
- package/create-webpack-prod.d.ts +28 -0
- package/create-webpack-prod.d.ts.map +1 -0
- package/create-webpack-prod.js +169 -0
- package/executors/build/build.impl.d.ts +19 -0
- package/executors/build/build.impl.d.ts.map +1 -0
- package/executors/build/build.impl.js +98 -0
- package/executors/build/schema.d.ts +35 -0
- package/executors/build/schema.json +135 -0
- package/executors/gateway/gateway.impl.d.ts +23 -0
- package/executors/gateway/gateway.impl.d.ts.map +1 -0
- package/executors/gateway/gateway.impl.js +39 -0
- package/executors/gateway/gateway.lib.d.ts +130 -0
- package/executors/gateway/gateway.lib.d.ts.map +1 -0
- package/executors/gateway/gateway.lib.js +797 -0
- package/executors/gateway/jwks-verify.d.ts +28 -0
- package/executors/gateway/jwks-verify.d.ts.map +1 -0
- package/executors/gateway/jwks-verify.js +121 -0
- package/executors/gateway/main.prod-gateway.d.ts +2 -0
- package/executors/gateway/main.prod-gateway.d.ts.map +1 -0
- package/executors/gateway/main.prod-gateway.js +290 -0
- package/executors/gateway/main.rollout.d.ts +2 -0
- package/executors/gateway/main.rollout.d.ts.map +1 -0
- package/executors/gateway/main.rollout.js +130 -0
- package/executors/gateway/manifest.d.ts +275 -0
- package/executors/gateway/manifest.d.ts.map +1 -0
- package/executors/gateway/manifest.js +344 -0
- package/executors/gateway/prod-gateway.lib.d.ts +58 -0
- package/executors/gateway/prod-gateway.lib.d.ts.map +1 -0
- package/executors/gateway/prod-gateway.lib.js +535 -0
- package/executors/gateway/providers-prod.d.ts +46 -0
- package/executors/gateway/providers-prod.d.ts.map +1 -0
- package/executors/gateway/providers-prod.js +199 -0
- package/executors/gateway/registry-source.d.ts +68 -0
- package/executors/gateway/registry-source.d.ts.map +1 -0
- package/executors/gateway/registry-source.js +131 -0
- package/executors/gateway/rollout-ops.d.ts +54 -0
- package/executors/gateway/rollout-ops.d.ts.map +1 -0
- package/executors/gateway/rollout-ops.js +167 -0
- package/executors/gateway/schema.d.ts +10 -0
- package/executors/gateway/schema.json +30 -0
- package/executors/serve/schema.d.ts +53 -0
- package/executors/serve/schema.json +196 -0
- package/executors/serve/serve.impl.d.ts +25 -0
- package/executors/serve/serve.impl.d.ts.map +1 -0
- package/executors/serve/serve.impl.js +243 -0
- package/executors/trust/schema.d.ts +6 -0
- package/executors/trust/schema.json +20 -0
- package/executors/trust/trust.impl.d.ts +42 -0
- package/executors/trust/trust.impl.d.ts.map +1 -0
- package/executors/trust/trust.impl.js +126 -0
- package/executors.json +24 -0
- package/index.d.ts +4 -0
- package/index.d.ts.map +1 -0
- package/index.js +9 -0
- package/main.devserver.d.ts +16 -0
- package/main.devserver.d.ts.map +1 -0
- package/main.devserver.js +812 -0
- package/package.json +66 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.staticManifestProvider = staticManifestProvider;
|
|
4
|
+
exports.fileManifestProvider = fileManifestProvider;
|
|
5
|
+
exports.rolloutPodProvider = rolloutPodProvider;
|
|
6
|
+
exports.endpointsPodProvider = endpointsPodProvider;
|
|
7
|
+
/**
|
|
8
|
+
* Production providers — the environment-specific halves of the gateway that
|
|
9
|
+
* the dev side fills with workspace-scan + unix-socket pools. Here:
|
|
10
|
+
*
|
|
11
|
+
* - manifest source: a JSON file (or a static array), watched + atomically
|
|
12
|
+
* swapped. A registry-backed provider (Redis key-space / k8s ConfigMaps)
|
|
13
|
+
* plugs in behind the same `ManifestProvider` interface later.
|
|
14
|
+
* - pod resolution: `endpointsPodProvider` round-robins per-service upstream
|
|
15
|
+
* URLs (gateway-side LB). When a manifest carries its own `upstream` (a
|
|
16
|
+
* k8s Service URL that load-balances pods itself), the core uses that
|
|
17
|
+
* directly and no PodProvider is needed.
|
|
18
|
+
*
|
|
19
|
+
* Bad input never takes the gateway down: an unparseable manifest file is
|
|
20
|
+
* rejected and the last-good table keeps serving (versioned swap).
|
|
21
|
+
*/
|
|
22
|
+
const node_fs_1 = require("node:fs");
|
|
23
|
+
const manifest_1 = require("./manifest");
|
|
24
|
+
const toSpec = (v) => Array.isArray(v) ? { rules: [], cohorts: v } : { rules: v.rules ?? [], cohorts: v.cohorts ?? [] };
|
|
25
|
+
/** A fixed set of manifests (tests, single-shot config). */
|
|
26
|
+
function staticManifestProvider(manifests) {
|
|
27
|
+
return { list: () => manifests };
|
|
28
|
+
}
|
|
29
|
+
function parseManifests(raw) {
|
|
30
|
+
let data;
|
|
31
|
+
try {
|
|
32
|
+
data = JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
// Accept either a bare array or `{ manifests: [...] }`.
|
|
38
|
+
const arr = Array.isArray(data) ? data : data?.manifests;
|
|
39
|
+
if (!Array.isArray(arr))
|
|
40
|
+
return undefined;
|
|
41
|
+
const ok = arr.filter((m) => !!m && typeof m === 'object' && typeof m.service === 'string' && Array.isArray(m.mounts));
|
|
42
|
+
// A file that parsed but yielded ZERO valid manifests is treated as bad
|
|
43
|
+
// (likely a truncated/half-written deploy) — keep last-good rather than
|
|
44
|
+
// black-hole all traffic.
|
|
45
|
+
return ok.length > 0 ? ok : undefined;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Watch a manifests JSON file; atomically swap the in-memory set on a valid
|
|
49
|
+
* change, reject and keep last-good on a bad one. `watch` notifies subscribers
|
|
50
|
+
* with the new set. Poll fallback covers editors/atomic-rename + platforms
|
|
51
|
+
* where fs.watch is unreliable.
|
|
52
|
+
*/
|
|
53
|
+
function fileManifestProvider(filePath, opts = {}) {
|
|
54
|
+
const onError = opts.onError ?? (() => undefined);
|
|
55
|
+
let current = [];
|
|
56
|
+
let lastMtimeMs = -1;
|
|
57
|
+
const subs = new Set();
|
|
58
|
+
const reload = () => {
|
|
59
|
+
if (!(0, node_fs_1.existsSync)(filePath))
|
|
60
|
+
return;
|
|
61
|
+
let mtime;
|
|
62
|
+
try {
|
|
63
|
+
mtime = (0, node_fs_1.statSync)(filePath).mtimeMs;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (mtime === lastMtimeMs)
|
|
69
|
+
return;
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = (0, node_fs_1.readFileSync)(filePath, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
onError(e);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const parsed = parseManifests(raw);
|
|
79
|
+
if (!parsed) {
|
|
80
|
+
onError(new Error(`[gateway] rejecting invalid manifest file ${filePath} — keeping last-good`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
lastMtimeMs = mtime;
|
|
84
|
+
current = parsed;
|
|
85
|
+
for (const cb of subs) {
|
|
86
|
+
try {
|
|
87
|
+
cb(current);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
onError(e);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
reload(); // initial load (best-effort; empty until the file appears)
|
|
95
|
+
let watcher;
|
|
96
|
+
try {
|
|
97
|
+
watcher = (0, node_fs_1.watch)(filePath, { persistent: false }, () => reload());
|
|
98
|
+
}
|
|
99
|
+
catch { /* dir may not exist yet — poll covers it */ }
|
|
100
|
+
const timer = setInterval(reload, opts.pollMs ?? 1000);
|
|
101
|
+
timer.unref?.();
|
|
102
|
+
return {
|
|
103
|
+
list: () => current,
|
|
104
|
+
watch: (cb) => { subs.add(cb); return () => subs.delete(cb); },
|
|
105
|
+
stop: () => { try {
|
|
106
|
+
watcher?.close();
|
|
107
|
+
}
|
|
108
|
+
catch { /* ignore */ } clearInterval(timer); },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rollout-aware pod provider: each service is a set of VERSION COHORTS, and
|
|
113
|
+
* `pick` splits traffic across them by weight (smooth weighted round-robin —
|
|
114
|
+
* a 90/10 canary interleaves, never bursts) and round-robins endpoints WITHIN
|
|
115
|
+
* the chosen cohort. `pick(svc, {version})` pins a cohort (sticky canary /
|
|
116
|
+
* debugging). `update` swaps weights/cohorts live — a rollout ramps
|
|
117
|
+
* (10→25→50→100) or rolls back without redeploying the gateway. The returned
|
|
118
|
+
* Upstream carries `version` so the gateway can surface which cohort served.
|
|
119
|
+
*/
|
|
120
|
+
function rolloutPodProvider(table) {
|
|
121
|
+
const svc = new Map();
|
|
122
|
+
const build = (spec) => ({
|
|
123
|
+
cohorts: spec.cohorts.map((c) => ({ ...c, urls: c.urls.slice() })),
|
|
124
|
+
select: (0, manifest_1.smoothWeightedSelector)(spec.cohorts.map((c) => c.weight)),
|
|
125
|
+
rr: spec.cohorts.map(() => -1),
|
|
126
|
+
rules: spec.rules ?? [],
|
|
127
|
+
});
|
|
128
|
+
const load = (t) => {
|
|
129
|
+
for (const [s, v] of Object.entries(t))
|
|
130
|
+
svc.set(s, build(toSpec(v)));
|
|
131
|
+
for (const s of [...svc.keys()])
|
|
132
|
+
if (!t[s])
|
|
133
|
+
svc.delete(s);
|
|
134
|
+
};
|
|
135
|
+
load(table);
|
|
136
|
+
const firstNonEmpty = (st) => st.cohorts.findIndex((c) => c.urls.length > 0);
|
|
137
|
+
return {
|
|
138
|
+
pick(service, opts) {
|
|
139
|
+
const st = svc.get(service);
|
|
140
|
+
if (!st || st.cohorts.length === 0)
|
|
141
|
+
return undefined;
|
|
142
|
+
let ci = -1;
|
|
143
|
+
if (opts?.version)
|
|
144
|
+
ci = st.cohorts.findIndex((c) => c.version === opts.version && c.urls.length > 0);
|
|
145
|
+
if (ci < 0) {
|
|
146
|
+
ci = st.select();
|
|
147
|
+
// chosen cohort drained/empty → fall back to any cohort with endpoints
|
|
148
|
+
if (!st.cohorts[ci] || st.cohorts[ci].urls.length === 0)
|
|
149
|
+
ci = firstNonEmpty(st);
|
|
150
|
+
}
|
|
151
|
+
const cohort = st.cohorts[ci];
|
|
152
|
+
if (!cohort || cohort.urls.length === 0)
|
|
153
|
+
return undefined;
|
|
154
|
+
st.rr[ci] = (st.rr[ci] + 1) % cohort.urls.length;
|
|
155
|
+
return { url: cohort.urls[st.rr[ci]], version: cohort.version };
|
|
156
|
+
},
|
|
157
|
+
// first targeting rule that matches the request's attributes (sticky-by-attribute)
|
|
158
|
+
resolveVersion(service, attrs) {
|
|
159
|
+
const st = svc.get(service);
|
|
160
|
+
return st && st.rules.length ? (0, manifest_1.pickTargetedVersion)(st.rules, attrs) : undefined;
|
|
161
|
+
},
|
|
162
|
+
// consistent-hash a stable key to a cohort version (sticky weighted split)
|
|
163
|
+
bucket(service, key) {
|
|
164
|
+
const st = svc.get(service);
|
|
165
|
+
if (!st || st.cohorts.length === 0)
|
|
166
|
+
return undefined;
|
|
167
|
+
const cohort = st.cohorts[(0, manifest_1.bucketByWeight)(`${key}:${service}`, st.cohorts.map((c) => c.weight))];
|
|
168
|
+
return cohort ? { version: cohort.version } : undefined;
|
|
169
|
+
},
|
|
170
|
+
ensureUp: async () => true, // prod services are always-on (k8s keeps them up)
|
|
171
|
+
drop(service, upstream) {
|
|
172
|
+
const st = svc.get(service);
|
|
173
|
+
if (st && 'url' in upstream)
|
|
174
|
+
for (const c of st.cohorts)
|
|
175
|
+
c.urls = c.urls.filter((u) => u !== upstream.url);
|
|
176
|
+
},
|
|
177
|
+
update: load,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Flat single-version load balancing — the common case (no rollout in
|
|
182
|
+
* progress). A thin wrapper over `rolloutPodProvider` with one synthetic
|
|
183
|
+
* cohort per service, so the rollout path is the only code path. The synthetic
|
|
184
|
+
* version is STRIPPED from the result: flat LB carries no `version`, so a
|
|
185
|
+
* response only gets `x-lensmcp-version` (and a `rollout` trace step) when a
|
|
186
|
+
* genuine multi-cohort rollout served it.
|
|
187
|
+
*/
|
|
188
|
+
function endpointsPodProvider(endpoints) {
|
|
189
|
+
const toCohorts = (eps) => Object.fromEntries(Object.entries(eps).map(([s, urls]) => [s, [{ version: '_flat', weight: 1, urls }]]));
|
|
190
|
+
const inner = rolloutPodProvider(toCohorts(endpoints));
|
|
191
|
+
const unversion = (u) => u && 'url' in u ? { url: u.url } : u;
|
|
192
|
+
return {
|
|
193
|
+
...inner,
|
|
194
|
+
pick: (service, opts) => unversion(inner.pick(service, opts)),
|
|
195
|
+
resolveVersion: () => undefined, // flat LB: no targeting rules
|
|
196
|
+
bucket: () => undefined, // flat LB: single version, nothing to stick to
|
|
197
|
+
update: (next) => inner.update(toCohorts(next)),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry-backed gateway config — true zero-touch discovery. Instead of a
|
|
3
|
+
* mounted file, the manifests + rollout cohorts live in a shared registry
|
|
4
|
+
* (Redis here; k8s ConfigMap/Endpoints can drop in behind `RegistrySource`).
|
|
5
|
+
* A service deploy / the `lensmcp rollout --redis` CD helper writes its keys and
|
|
6
|
+
* publishes a change; the gateway re-reads and live-swaps its route table +
|
|
7
|
+
* cohorts in ~instant — no gateway redeploy, no file mounts.
|
|
8
|
+
*
|
|
9
|
+
* The cluster lib stays dependency-light: `redisRegistrySource` works against a
|
|
10
|
+
* minimal injected `RedisLike` (ioredis satisfies it; the prod entry wires the
|
|
11
|
+
* real client, tests pass a fake). See docs/design/targeted-rollout.md and
|
|
12
|
+
* docs/design/prod-gateway.md (§Distribution).
|
|
13
|
+
*/
|
|
14
|
+
import { type RolloutTable } from './providers-prod';
|
|
15
|
+
import type { ManifestProvider, PodProvider, RouteManifest } from './manifest';
|
|
16
|
+
/** A change-notifying source of the gateway's full config (manifests + rollout). */
|
|
17
|
+
export interface RegistrySource {
|
|
18
|
+
/** Read the current snapshot. */
|
|
19
|
+
read(): Promise<{
|
|
20
|
+
manifests: RouteManifest[];
|
|
21
|
+
endpoints: RolloutTable;
|
|
22
|
+
}>;
|
|
23
|
+
/** Subscribe to change notifications; returns an unsubscribe. Optional. */
|
|
24
|
+
watch?(onChange: () => void): Promise<() => void>;
|
|
25
|
+
/** Release any held connections. */
|
|
26
|
+
stop?(): void;
|
|
27
|
+
}
|
|
28
|
+
/** The minimal Redis surface `redisRegistrySource` uses (ioredis satisfies it). */
|
|
29
|
+
export interface RedisLike {
|
|
30
|
+
keys(pattern: string): Promise<string[]>;
|
|
31
|
+
mget(...keys: string[]): Promise<(string | null)[]>;
|
|
32
|
+
set(key: string, value: string): Promise<unknown>;
|
|
33
|
+
del(key: string): Promise<unknown>;
|
|
34
|
+
publish(channel: string, message: string): Promise<unknown>;
|
|
35
|
+
duplicate(): RedisLike;
|
|
36
|
+
subscribe(channel: string): Promise<unknown>;
|
|
37
|
+
on(event: 'message', cb: (channel: string, message: string) => void): void;
|
|
38
|
+
quit(): Promise<unknown>;
|
|
39
|
+
}
|
|
40
|
+
export declare const DEFAULT_REGISTRY_PREFIX = "lensmcp:gw";
|
|
41
|
+
export declare const manifestKey: (prefix: string, service: string) => string;
|
|
42
|
+
export declare const rolloutKey: (prefix: string, service: string) => string;
|
|
43
|
+
export declare const changeChannel: (prefix: string) => string;
|
|
44
|
+
/**
|
|
45
|
+
* Redis-backed source. Per-service keys (so a deploy touches only its own key,
|
|
46
|
+
* no read-modify-write races): `<prefix>:manifest:<svc>` (a RouteManifest) and
|
|
47
|
+
* `<prefix>:rollout:<svc>` (a VersionCohort[] | RolloutSpec). Writers publish
|
|
48
|
+
* `<prefix>:changed` after a SET; the gateway re-reads on the message.
|
|
49
|
+
*/
|
|
50
|
+
export declare function redisRegistrySource(client: RedisLike, opts?: {
|
|
51
|
+
prefix?: string;
|
|
52
|
+
}): RegistrySource;
|
|
53
|
+
/**
|
|
54
|
+
* Compose a ManifestProvider + PodProvider over a RegistrySource. Both refresh
|
|
55
|
+
* together on every change (source.watch) and on a poll fallback. Call
|
|
56
|
+
* `start()` (awaits the first read so the route table is warm) before handing
|
|
57
|
+
* the providers to `startProdGateway`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createRegistryProviders(source: RegistrySource, opts?: {
|
|
60
|
+
pollMs?: number;
|
|
61
|
+
onError?: (e: unknown) => void;
|
|
62
|
+
}): {
|
|
63
|
+
manifests: ManifestProvider;
|
|
64
|
+
pods: PodProvider;
|
|
65
|
+
start(): Promise<void>;
|
|
66
|
+
stop(): void;
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=registry-source.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry-source.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/registry-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAsB,KAAK,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACzE,OAAO,KAAK,EAAE,gBAAgB,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE/E,oFAAoF;AACpF,MAAM,WAAW,cAAc;IAC7B,iCAAiC;IACjC,IAAI,IAAI,OAAO,CAAC;QAAE,SAAS,EAAE,aAAa,EAAE,CAAC;QAAC,SAAS,EAAE,YAAY,CAAA;KAAE,CAAC,CAAC;IACzE,2EAA2E;IAC3E,KAAK,CAAC,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;IAClD,oCAAoC;IACpC,IAAI,CAAC,IAAI,IAAI,CAAC;CACf;AAED,mFAAmF;AACnF,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACzC,IAAI,CAAC,GAAG,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC,CAAC;IACpD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAClD,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5D,SAAS,IAAI,SAAS,CAAC;IACvB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7C,EAAE,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAAC;IAC3E,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;CAC1B;AAED,eAAO,MAAM,uBAAuB,eAAe,CAAC;AACpD,eAAO,MAAM,WAAW,GAAI,QAAQ,MAAM,EAAE,SAAS,MAAM,WAAoC,CAAC;AAChG,eAAO,MAAM,UAAU,GAAI,QAAQ,MAAM,EAAE,SAAS,MAAM,WAAmC,CAAC;AAC9F,eAAO,MAAM,aAAa,GAAI,QAAQ,MAAM,WAAwB,CAAC;AASrE;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,cAAc,CA6BrG;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,cAAc,EACtB,IAAI,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAA;CAAO,GAC7D;IAAE,SAAS,EAAE,gBAAgB,CAAC;IAAC,IAAI,EAAE,WAAW,CAAC;IAAC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAAC,IAAI,IAAI,IAAI,CAAA;CAAE,CA2B1F"}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.changeChannel = exports.rolloutKey = exports.manifestKey = exports.DEFAULT_REGISTRY_PREFIX = void 0;
|
|
4
|
+
exports.redisRegistrySource = redisRegistrySource;
|
|
5
|
+
exports.createRegistryProviders = createRegistryProviders;
|
|
6
|
+
/**
|
|
7
|
+
* Registry-backed gateway config — true zero-touch discovery. Instead of a
|
|
8
|
+
* mounted file, the manifests + rollout cohorts live in a shared registry
|
|
9
|
+
* (Redis here; k8s ConfigMap/Endpoints can drop in behind `RegistrySource`).
|
|
10
|
+
* A service deploy / the `lensmcp rollout --redis` CD helper writes its keys and
|
|
11
|
+
* publishes a change; the gateway re-reads and live-swaps its route table +
|
|
12
|
+
* cohorts in ~instant — no gateway redeploy, no file mounts.
|
|
13
|
+
*
|
|
14
|
+
* The cluster lib stays dependency-light: `redisRegistrySource` works against a
|
|
15
|
+
* minimal injected `RedisLike` (ioredis satisfies it; the prod entry wires the
|
|
16
|
+
* real client, tests pass a fake). See docs/design/targeted-rollout.md and
|
|
17
|
+
* docs/design/prod-gateway.md (§Distribution).
|
|
18
|
+
*/
|
|
19
|
+
const providers_prod_1 = require("./providers-prod");
|
|
20
|
+
exports.DEFAULT_REGISTRY_PREFIX = 'lensmcp:gw';
|
|
21
|
+
const manifestKey = (prefix, service) => `${prefix}:manifest:${service}`;
|
|
22
|
+
exports.manifestKey = manifestKey;
|
|
23
|
+
const rolloutKey = (prefix, service) => `${prefix}:rollout:${service}`;
|
|
24
|
+
exports.rolloutKey = rolloutKey;
|
|
25
|
+
const changeChannel = (prefix) => `${prefix}:changed`;
|
|
26
|
+
exports.changeChannel = changeChannel;
|
|
27
|
+
function parseManifest(s) {
|
|
28
|
+
try {
|
|
29
|
+
const m = JSON.parse(s);
|
|
30
|
+
return m && typeof m.service === 'string' && Array.isArray(m.mounts) ? m : undefined;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Redis-backed source. Per-service keys (so a deploy touches only its own key,
|
|
38
|
+
* no read-modify-write races): `<prefix>:manifest:<svc>` (a RouteManifest) and
|
|
39
|
+
* `<prefix>:rollout:<svc>` (a VersionCohort[] | RolloutSpec). Writers publish
|
|
40
|
+
* `<prefix>:changed` after a SET; the gateway re-reads on the message.
|
|
41
|
+
*/
|
|
42
|
+
function redisRegistrySource(client, opts = {}) {
|
|
43
|
+
const prefix = opts.prefix ?? exports.DEFAULT_REGISTRY_PREFIX;
|
|
44
|
+
let sub;
|
|
45
|
+
const rolloutPrefix = `${prefix}:rollout:`;
|
|
46
|
+
return {
|
|
47
|
+
async read() {
|
|
48
|
+
const mKeys = await client.keys(`${prefix}:manifest:*`);
|
|
49
|
+
const manifests = mKeys.length
|
|
50
|
+
? (await client.mget(...mKeys)).filter((v) => !!v).map(parseManifest).filter((m) => !!m)
|
|
51
|
+
: [];
|
|
52
|
+
const rKeys = await client.keys(`${rolloutPrefix}*`);
|
|
53
|
+
const endpoints = {};
|
|
54
|
+
if (rKeys.length) {
|
|
55
|
+
const vals = await client.mget(...rKeys);
|
|
56
|
+
rKeys.forEach((k, i) => {
|
|
57
|
+
const v = vals[i];
|
|
58
|
+
if (v)
|
|
59
|
+
try {
|
|
60
|
+
endpoints[k.slice(rolloutPrefix.length)] = JSON.parse(v);
|
|
61
|
+
}
|
|
62
|
+
catch { /* skip a bad entry, keep the rest */ }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return { manifests, endpoints };
|
|
66
|
+
},
|
|
67
|
+
async watch(onChange) {
|
|
68
|
+
sub = client.duplicate();
|
|
69
|
+
await sub.subscribe((0, exports.changeChannel)(prefix));
|
|
70
|
+
sub.on('message', () => onChange());
|
|
71
|
+
return async () => { try {
|
|
72
|
+
await sub?.quit();
|
|
73
|
+
}
|
|
74
|
+
catch { /* ignore */ } };
|
|
75
|
+
},
|
|
76
|
+
stop() { void sub?.quit().catch(() => undefined); },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Compose a ManifestProvider + PodProvider over a RegistrySource. Both refresh
|
|
81
|
+
* together on every change (source.watch) and on a poll fallback. Call
|
|
82
|
+
* `start()` (awaits the first read so the route table is warm) before handing
|
|
83
|
+
* the providers to `startProdGateway`.
|
|
84
|
+
*/
|
|
85
|
+
function createRegistryProviders(source, opts = {}) {
|
|
86
|
+
const onError = opts.onError ?? (() => undefined);
|
|
87
|
+
let current = [];
|
|
88
|
+
const subs = new Set();
|
|
89
|
+
const inner = (0, providers_prod_1.rolloutPodProvider)({});
|
|
90
|
+
let unwatch;
|
|
91
|
+
let timer;
|
|
92
|
+
const refresh = async () => {
|
|
93
|
+
let snap;
|
|
94
|
+
try {
|
|
95
|
+
snap = await source.read();
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
onError(e);
|
|
99
|
+
return;
|
|
100
|
+
} // keep last-good on a transient error
|
|
101
|
+
current = snap.manifests;
|
|
102
|
+
inner.update(snap.endpoints);
|
|
103
|
+
for (const cb of subs) {
|
|
104
|
+
try {
|
|
105
|
+
cb(current);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
onError(e);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
manifests: { list: () => current, watch: (cb) => { subs.add(cb); return () => subs.delete(cb); } },
|
|
114
|
+
pods: inner,
|
|
115
|
+
async start() {
|
|
116
|
+
await refresh();
|
|
117
|
+
if (source.watch) {
|
|
118
|
+
try {
|
|
119
|
+
unwatch = await source.watch(() => void refresh());
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
onError(e);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
timer = setInterval(() => void refresh(), opts.pollMs ?? 5000);
|
|
126
|
+
timer.unref?.();
|
|
127
|
+
},
|
|
128
|
+
stop() { unwatch?.(); if (timer)
|
|
129
|
+
clearInterval(timer); source.stop?.(); },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rollout config operations — the brain behind `lensmcp rollout` (the CD helper).
|
|
3
|
+
* PURE: each op takes the rollout table and returns a NEW one, validating and
|
|
4
|
+
* throwing on bad input. The CLI (`main.rollout.ts`) wraps these with file IO +
|
|
5
|
+
* atomic write so pipelines never hand-edit JSON (and never write a half file).
|
|
6
|
+
*
|
|
7
|
+
* Works on the targeted/cohort shape (`{svc:{rules?,cohorts}}` or `{svc:[cohorts]}`);
|
|
8
|
+
* a flat endpoint list (`{svc:[url,…]}`) is rejected with a clear message — convert
|
|
9
|
+
* it to cohorts first. See docs/design/targeted-rollout.md.
|
|
10
|
+
*/
|
|
11
|
+
import type { RolloutTable } from './providers-prod';
|
|
12
|
+
import type { TargetRule } from './manifest';
|
|
13
|
+
export type RolloutOp = {
|
|
14
|
+
kind: 'add-cohort';
|
|
15
|
+
service: string;
|
|
16
|
+
version: string;
|
|
17
|
+
weight: number;
|
|
18
|
+
urls: string[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'set-weight';
|
|
21
|
+
service: string;
|
|
22
|
+
version: string;
|
|
23
|
+
weight: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'remove-cohort';
|
|
26
|
+
service: string;
|
|
27
|
+
version: string;
|
|
28
|
+
} | {
|
|
29
|
+
kind: 'promote';
|
|
30
|
+
service: string;
|
|
31
|
+
version: string;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'abort';
|
|
34
|
+
service: string;
|
|
35
|
+
version: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'add-rule';
|
|
38
|
+
service: string;
|
|
39
|
+
rule: TargetRule;
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'remove-rule';
|
|
42
|
+
service: string;
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
45
|
+
/** Apply one operation to the rollout table; returns a NEW table. Throws on invalid input. */
|
|
46
|
+
export declare function applyRolloutOp(config: RolloutTable, op: RolloutOp): RolloutTable;
|
|
47
|
+
/** Human-readable status for one service (or all). Read-only. */
|
|
48
|
+
export declare function summarizeRollout(config: RolloutTable, service?: string): string;
|
|
49
|
+
/** Parse CLI argv (after the config-file path) into an op, a status request, or throw a usage error. */
|
|
50
|
+
export declare function parseRolloutCommand(argv: string[]): {
|
|
51
|
+
op?: RolloutOp;
|
|
52
|
+
status?: string | undefined;
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=rollout-ops.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rollout-ops.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/gateway/rollout-ops.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,YAAY,EAAe,MAAM,kBAAkB,CAAC;AAClE,OAAO,KAAK,EAAE,UAAU,EAAyB,MAAM,YAAY,CAAC;AAEpE,MAAM,MAAM,SAAS,GACjB;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,EAAE,CAAA;CAAE,GACxF;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACxE;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC3D;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACrD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,UAAU,CAAA;CAAE,GACvD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAwB3D,8FAA8F;AAC9F,wBAAgB,cAAc,CAAC,MAAM,EAAE,YAAY,EAAE,EAAE,EAAE,SAAS,GAAG,YAAY,CAgDhF;AAiBD,iEAAiE;AACjE,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAe/E;AAUD,wGAAwG;AACxG,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;IAAE,EAAE,CAAC,EAAE,SAAS,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAqBnG"}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.applyRolloutOp = applyRolloutOp;
|
|
4
|
+
exports.summarizeRollout = summarizeRollout;
|
|
5
|
+
exports.parseRolloutCommand = parseRolloutCommand;
|
|
6
|
+
const RULE_OPS = new Set(['eq', 'ne', 'in', 'nin', 'prefix', 'suffix', 'contains', 'regex', 'exists', 'gt', 'lt', 'cidr']);
|
|
7
|
+
const clone = (v) => JSON.parse(JSON.stringify(v));
|
|
8
|
+
/** Normalize a service entry to a `{rules,cohorts}` spec; reject a flat url list. */
|
|
9
|
+
function asSpec(service, v) {
|
|
10
|
+
if (v === undefined)
|
|
11
|
+
return { rules: [], cohorts: [] };
|
|
12
|
+
if (Array.isArray(v)) {
|
|
13
|
+
if (v.length && typeof v[0] === 'string')
|
|
14
|
+
throw new Error(`service "${service}" is a flat endpoint list; convert it to cohorts first (e.g. add-cohort)`);
|
|
15
|
+
return { rules: [], cohorts: v };
|
|
16
|
+
}
|
|
17
|
+
return { rules: v.rules ?? [], cohorts: v.cohorts ?? [] };
|
|
18
|
+
}
|
|
19
|
+
const findCohort = (spec, version) => spec.cohorts.find((c) => c.version === version);
|
|
20
|
+
function reqWeight(w) {
|
|
21
|
+
if (!Number.isFinite(w) || w < 0)
|
|
22
|
+
throw new Error(`weight must be a number ≥ 0 (got ${w})`);
|
|
23
|
+
return w;
|
|
24
|
+
}
|
|
25
|
+
/** Apply one operation to the rollout table; returns a NEW table. Throws on invalid input. */
|
|
26
|
+
function applyRolloutOp(config, op) {
|
|
27
|
+
const next = clone(config);
|
|
28
|
+
const spec = asSpec(op.service, next[op.service]);
|
|
29
|
+
switch (op.kind) {
|
|
30
|
+
case 'add-cohort': {
|
|
31
|
+
if (findCohort(spec, op.version))
|
|
32
|
+
throw new Error(`cohort "${op.version}" already exists on "${op.service}" — use set-weight`);
|
|
33
|
+
if (!op.urls.length)
|
|
34
|
+
throw new Error('add-cohort needs at least one upstream url');
|
|
35
|
+
spec.cohorts.push({ version: op.version, weight: reqWeight(op.weight), urls: op.urls.slice() });
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
case 'set-weight': {
|
|
39
|
+
const c = findCohort(spec, op.version);
|
|
40
|
+
if (!c)
|
|
41
|
+
throw new Error(`no cohort "${op.version}" on "${op.service}"`);
|
|
42
|
+
c.weight = reqWeight(op.weight);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
case 'remove-cohort': {
|
|
46
|
+
if (!findCohort(spec, op.version))
|
|
47
|
+
throw new Error(`no cohort "${op.version}" on "${op.service}"`);
|
|
48
|
+
spec.cohorts = spec.cohorts.filter((c) => c.version !== op.version);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case 'promote': {
|
|
52
|
+
if (!findCohort(spec, op.version))
|
|
53
|
+
throw new Error(`no cohort "${op.version}" on "${op.service}"`);
|
|
54
|
+
for (const c of spec.cohorts)
|
|
55
|
+
c.weight = c.version === op.version ? 100 : 0;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 'abort': {
|
|
59
|
+
const c = findCohort(spec, op.version);
|
|
60
|
+
if (!c)
|
|
61
|
+
throw new Error(`no cohort "${op.version}" on "${op.service}"`);
|
|
62
|
+
c.weight = 0;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case 'add-rule': {
|
|
66
|
+
validateRule(op.rule);
|
|
67
|
+
spec.rules = [...(spec.rules ?? []).filter((r) => !op.rule.name || r.name !== op.rule.name), op.rule]; // replace-by-name (idempotent)
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'remove-rule': {
|
|
71
|
+
const before = (spec.rules ?? []).length;
|
|
72
|
+
spec.rules = (spec.rules ?? []).filter((r) => r.name !== op.name);
|
|
73
|
+
if (spec.rules.length === before)
|
|
74
|
+
throw new Error(`no rule named "${op.name}" on "${op.service}"`);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
next[op.service] = spec.rules && spec.rules.length ? { rules: spec.rules, cohorts: spec.cohorts } : spec.cohorts;
|
|
79
|
+
return next;
|
|
80
|
+
}
|
|
81
|
+
function validateRule(rule) {
|
|
82
|
+
if (!rule || typeof rule.version !== 'string' || !rule.version)
|
|
83
|
+
throw new Error('rule needs a non-empty version');
|
|
84
|
+
if (!rule.when || typeof rule.when !== 'object')
|
|
85
|
+
throw new Error('rule needs a `when` condition');
|
|
86
|
+
const walk = (n) => {
|
|
87
|
+
const node = n;
|
|
88
|
+
if ('all' in node || 'any' in node || 'not' in node) {
|
|
89
|
+
for (const k of ['all', 'any'])
|
|
90
|
+
if (Array.isArray(node[k]))
|
|
91
|
+
node[k].forEach(walk);
|
|
92
|
+
if (node.not)
|
|
93
|
+
walk(node.not);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (typeof node.attr !== 'string' || !RULE_OPS.has(node.op))
|
|
97
|
+
throw new Error(`invalid rule condition (attr/op): ${JSON.stringify(node)}`);
|
|
98
|
+
};
|
|
99
|
+
walk(rule.when);
|
|
100
|
+
}
|
|
101
|
+
/** Human-readable status for one service (or all). Read-only. */
|
|
102
|
+
function summarizeRollout(config, service) {
|
|
103
|
+
const names = service ? [service] : Object.keys(config);
|
|
104
|
+
if (!names.length)
|
|
105
|
+
return '(no services in rollout config)';
|
|
106
|
+
const lines = [];
|
|
107
|
+
for (const s of names) {
|
|
108
|
+
const entry = config[s];
|
|
109
|
+
if (entry === undefined) {
|
|
110
|
+
lines.push(`${s}: (not present)`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(entry) && entry.length && typeof entry[0] === 'string') {
|
|
114
|
+
lines.push(`${s}: flat LB → ${entry.join(', ')}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const spec = asSpec(s, entry);
|
|
118
|
+
const total = spec.cohorts.reduce((a, c) => a + Math.max(0, c.weight), 0) || 1;
|
|
119
|
+
lines.push(`${s}:`);
|
|
120
|
+
for (const c of spec.cohorts)
|
|
121
|
+
lines.push(` • ${c.version.padEnd(16)} weight ${String(c.weight).padStart(3)} (${(Math.max(0, c.weight) / total * 100).toFixed(0)}%) ${c.urls.length} url(s)`);
|
|
122
|
+
for (const r of spec.rules ?? [])
|
|
123
|
+
lines.push(` ▸ rule ${(r.name ?? '(unnamed)').padEnd(16)} → ${r.version} ${ruleSummary(r.when)}`);
|
|
124
|
+
}
|
|
125
|
+
return lines.join('\n');
|
|
126
|
+
}
|
|
127
|
+
function ruleSummary(n) {
|
|
128
|
+
const node = n;
|
|
129
|
+
if ('all' in node)
|
|
130
|
+
return '(' + node.all.map(ruleSummary).join(' AND ') + ')';
|
|
131
|
+
if ('any' in node)
|
|
132
|
+
return '(' + node.any.map(ruleSummary).join(' OR ') + ')';
|
|
133
|
+
if ('not' in node)
|
|
134
|
+
return 'NOT ' + ruleSummary(node.not);
|
|
135
|
+
return `${node.attr} ${node.op}${node.value !== undefined ? ' ' + JSON.stringify(node.value) : ''}`;
|
|
136
|
+
}
|
|
137
|
+
/** Parse CLI argv (after the config-file path) into an op, a status request, or throw a usage error. */
|
|
138
|
+
function parseRolloutCommand(argv) {
|
|
139
|
+
const [cmd, ...a] = argv;
|
|
140
|
+
const need = (n, usage) => { if (a.length < n)
|
|
141
|
+
throw new Error(`usage: ${cmd} ${usage}`); };
|
|
142
|
+
switch (cmd) {
|
|
143
|
+
case 'status': return { status: a[0] };
|
|
144
|
+
case 'add-cohort':
|
|
145
|
+
need(4, '<service> <version> <weight> <url> [url...]');
|
|
146
|
+
return { op: { kind: 'add-cohort', service: a[0], version: a[1], weight: Number(a[2]), urls: a.slice(3) } };
|
|
147
|
+
case 'set-weight':
|
|
148
|
+
need(3, '<service> <version> <weight>');
|
|
149
|
+
return { op: { kind: 'set-weight', service: a[0], version: a[1], weight: Number(a[2]) } };
|
|
150
|
+
case 'remove-cohort':
|
|
151
|
+
need(2, '<service> <version>');
|
|
152
|
+
return { op: { kind: 'remove-cohort', service: a[0], version: a[1] } };
|
|
153
|
+
case 'promote':
|
|
154
|
+
need(2, '<service> <version>');
|
|
155
|
+
return { op: { kind: 'promote', service: a[0], version: a[1] } };
|
|
156
|
+
case 'abort':
|
|
157
|
+
need(2, '<service> <version>');
|
|
158
|
+
return { op: { kind: 'abort', service: a[0], version: a[1] } };
|
|
159
|
+
case 'add-rule':
|
|
160
|
+
need(6, '<service> <name> <attr> <op> <value> <version>');
|
|
161
|
+
return { op: { kind: 'add-rule', service: a[0], rule: { name: a[1], when: { attr: a[2], op: a[3], value: a[4] }, version: a[5] } } };
|
|
162
|
+
case 'remove-rule':
|
|
163
|
+
need(2, '<service> <name>');
|
|
164
|
+
return { op: { kind: 'remove-rule', service: a[0], name: a[1] } };
|
|
165
|
+
default: throw new Error(`unknown command "${cmd ?? ''}". commands: status, add-cohort, set-weight, remove-cohort, promote, abort, add-rule, remove-rule`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface GatewayExecutorSchema {
|
|
2
|
+
/** Listener ports. Default [443]. */
|
|
3
|
+
ports?: number[];
|
|
4
|
+
/** Terminate TLS with the davnx local CA. Default true. */
|
|
5
|
+
https?: boolean;
|
|
6
|
+
/** Traffic-edge aggregation window (ms) before flushing to the lens. Default 5000. */
|
|
7
|
+
trafficFlushMs?: number;
|
|
8
|
+
/** Workspace-relative path to a per-request middleware module (the JWT seam). */
|
|
9
|
+
middleware?: string;
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/schema",
|
|
3
|
+
"version": 2,
|
|
4
|
+
"title": "Gateway executor",
|
|
5
|
+
"description": "Workspace https front door. Reads every project's top-level `davnx` declaration ({ host, port|upstream, prependPrefix?, default? }), builds the hostname route table, serves a local-CA TLS cert with SANs for every host, and proxies requests + websocket upgrades. The optional middleware hook (JWT etc.) runs on every request — service-to-service traffic should use each service's internalPort instead and skip the gateway entirely.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"ports": {
|
|
9
|
+
"type": "array",
|
|
10
|
+
"items": {
|
|
11
|
+
"type": "number"
|
|
12
|
+
},
|
|
13
|
+
"description": "Listener ports. Default [443] so dev domains need no :port."
|
|
14
|
+
},
|
|
15
|
+
"https": {
|
|
16
|
+
"type": "boolean",
|
|
17
|
+
"default": true,
|
|
18
|
+
"description": "Terminate TLS with the davnx local CA (trust once via the trust executor)."
|
|
19
|
+
},
|
|
20
|
+
"middleware": {
|
|
21
|
+
"type": "string",
|
|
22
|
+
"description": "Workspace-relative path to a JS module (req) => void that runs on every gateway request — the JWT/auth seam. Throwing rejects the request with 401."
|
|
23
|
+
},
|
|
24
|
+
"trafficFlushMs": {
|
|
25
|
+
"type": "number",
|
|
26
|
+
"description": "Traffic-edge aggregation window in ms before flushing to the LensMCP bus. Default 5000; lower (e.g. 1000) for near-realtime canvases."
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"additionalProperties": false
|
|
30
|
+
}
|