@ultimat3/cli 1.1.0 → 1.2.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/package.json +23 -23
- package/src/cmd-dev.ts +4 -1
- package/src/dev-roles.ts +21 -0
- package/src/errors.ts +5 -3
- package/src/index.ts +3 -0
- package/src/metrics-endpoint.ts +72 -0
- package/src/serve.ts +32 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,27 +34,27 @@
|
|
|
34
34
|
"dev": "bun run src/bin.ts dev"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/action": "1.
|
|
38
|
-
"@ultimat3/admin": "1.
|
|
39
|
-
"@ultimat3/ai": "1.
|
|
40
|
-
"@ultimat3/cache": "1.
|
|
41
|
-
"@ultimat3/core": "1.
|
|
42
|
-
"@ultimat3/db": "1.
|
|
43
|
-
"@ultimat3/entity": "1.
|
|
44
|
-
"@ultimat3/http": "1.
|
|
45
|
-
"@ultimat3/i18n": "1.
|
|
46
|
-
"@ultimat3/jobs": "1.
|
|
47
|
-
"@ultimat3/mail": "1.
|
|
48
|
-
"@ultimat3/manifest": "1.
|
|
49
|
-
"@ultimat3/mcp": "1.
|
|
50
|
-
"@ultimat3/policy": "1.
|
|
51
|
-
"@ultimat3/pwa": "1.
|
|
52
|
-
"@ultimat3/query": "1.
|
|
53
|
-
"@ultimat3/realtime": "1.
|
|
54
|
-
"@ultimat3/render": "1.
|
|
55
|
-
"@ultimat3/seo": "1.
|
|
56
|
-
"@ultimat3/storage": "1.
|
|
57
|
-
"@ultimat3/testing": "1.
|
|
58
|
-
"@ultimat3/time": "1.
|
|
37
|
+
"@ultimat3/action": "1.2.0",
|
|
38
|
+
"@ultimat3/admin": "1.2.0",
|
|
39
|
+
"@ultimat3/ai": "1.2.0",
|
|
40
|
+
"@ultimat3/cache": "1.2.0",
|
|
41
|
+
"@ultimat3/core": "1.2.0",
|
|
42
|
+
"@ultimat3/db": "1.2.0",
|
|
43
|
+
"@ultimat3/entity": "1.2.0",
|
|
44
|
+
"@ultimat3/http": "1.2.0",
|
|
45
|
+
"@ultimat3/i18n": "1.2.0",
|
|
46
|
+
"@ultimat3/jobs": "1.2.0",
|
|
47
|
+
"@ultimat3/mail": "1.2.0",
|
|
48
|
+
"@ultimat3/manifest": "1.2.0",
|
|
49
|
+
"@ultimat3/mcp": "1.2.0",
|
|
50
|
+
"@ultimat3/policy": "1.2.0",
|
|
51
|
+
"@ultimat3/pwa": "1.2.0",
|
|
52
|
+
"@ultimat3/query": "1.2.0",
|
|
53
|
+
"@ultimat3/realtime": "1.2.0",
|
|
54
|
+
"@ultimat3/render": "1.2.0",
|
|
55
|
+
"@ultimat3/seo": "1.2.0",
|
|
56
|
+
"@ultimat3/storage": "1.2.0",
|
|
57
|
+
"@ultimat3/testing": "1.2.0",
|
|
58
|
+
"@ultimat3/time": "1.2.0"
|
|
59
59
|
}
|
|
60
60
|
}
|
package/src/cmd-dev.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { watch } from 'node:fs';
|
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { listActions, toRoute } from '@ultimat3/action';
|
|
10
10
|
import type { Role } from '@ultimat3/core';
|
|
11
|
-
import { configureTelemetry, noopExporter } from '@ultimat3/core';
|
|
11
|
+
import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
|
|
12
12
|
import type { Route } from '@ultimat3/http';
|
|
13
13
|
import type { Manifest } from '@ultimat3/manifest';
|
|
14
14
|
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
@@ -249,6 +249,9 @@ export const devCommand: CliCommand = {
|
|
|
249
249
|
url: server.url,
|
|
250
250
|
roles: [...server.roles],
|
|
251
251
|
sync: server.running.syncUrl,
|
|
252
|
+
// The scrape target, on its own port for every role: what an operator points a Prometheus
|
|
253
|
+
// at, and the one url here that must NOT be behind the ingress the app's own url is.
|
|
254
|
+
metrics: `${server.running.metricsUrl}${METRICS_PATH}`,
|
|
252
255
|
stateDir: server.services.stateDir,
|
|
253
256
|
db: server.services.db.url,
|
|
254
257
|
events: server.services.events.url,
|
package/src/dev-roles.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { startReplicator } from './dev-replicator';
|
|
|
29
29
|
import type { RunningServices } from './dev-runtime';
|
|
30
30
|
import type { Env } from './dev-services';
|
|
31
31
|
import { BadFlagError } from './errors';
|
|
32
|
+
import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
32
33
|
|
|
33
34
|
/** The roles `x dev` starts when `--role` names none, in boot order. */
|
|
34
35
|
export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
|
|
@@ -57,6 +58,12 @@ export interface StartRolesOptions {
|
|
|
57
58
|
* probe, which is the same failure in four costumes.
|
|
58
59
|
*/
|
|
59
60
|
readonly http?: WebBinding;
|
|
61
|
+
/**
|
|
62
|
+
* Where the scrape listener binds. Defaults to `DEFAULT_METRICS_PORT`, except when `port` is 0
|
|
63
|
+
* — a caller asking the kernel for an ephemeral HTTP port is a test, and a test that grabbed
|
|
64
|
+
* 9090 would fail the next one to run beside it.
|
|
65
|
+
*/
|
|
66
|
+
readonly metricsPort?: number;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
export interface WebBinding {
|
|
@@ -73,6 +80,8 @@ export interface RunningRoles {
|
|
|
73
80
|
readonly url: string | null;
|
|
74
81
|
/** Where the sync role accepts websockets; null when it was not selected. */
|
|
75
82
|
readonly syncUrl: string | null;
|
|
83
|
+
/** `http://…` — the scrape base. Never null: every role publishes a signal worth scaling on. */
|
|
84
|
+
readonly metricsUrl: string;
|
|
76
85
|
readonly server: ServerHandle | null;
|
|
77
86
|
readonly worker: Worker | null;
|
|
78
87
|
readonly scheduler: Scheduler | null;
|
|
@@ -203,6 +212,15 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
203
212
|
// Without this a failed `sync` leaves the web server bound and unreachable by any caller.
|
|
204
213
|
const started: (() => Promise<void>)[] = [];
|
|
205
214
|
try {
|
|
215
|
+
// First, and for every role rather than only the two that open an HTTP socket: `worker` and
|
|
216
|
+
// `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
|
|
217
|
+
// and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
|
|
218
|
+
const metrics = startMetricsEndpoint({
|
|
219
|
+
port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
|
|
220
|
+
...(options.http === undefined ? {} : { hostname: options.http.hostname }),
|
|
221
|
+
});
|
|
222
|
+
started.push(async () => metrics.stop());
|
|
223
|
+
|
|
206
224
|
const server = selected.includes('web') ? startWeb(options) : null;
|
|
207
225
|
if (server !== null) started.push(() => server.stop());
|
|
208
226
|
|
|
@@ -240,6 +258,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
240
258
|
roles: selected,
|
|
241
259
|
url: server === null ? null : server.url(),
|
|
242
260
|
syncUrl: sync?.url ?? null,
|
|
261
|
+
metricsUrl: metrics.url,
|
|
243
262
|
server,
|
|
244
263
|
worker,
|
|
245
264
|
scheduler,
|
|
@@ -251,6 +270,8 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
251
270
|
await worker?.stop('x dev stopped');
|
|
252
271
|
await sync?.stop();
|
|
253
272
|
await server?.stop();
|
|
273
|
+
// Last: a scrape taken while the roles above drain is the one that explains the drain.
|
|
274
|
+
metrics.stop();
|
|
254
275
|
},
|
|
255
276
|
};
|
|
256
277
|
} catch (error) {
|
package/src/errors.ts
CHANGED
|
@@ -401,11 +401,13 @@ export class RoleUnknownError extends UltimateError {
|
|
|
401
401
|
* reports nothing an operator can act on.
|
|
402
402
|
*/
|
|
403
403
|
export class PortInvalidError extends UltimateError {
|
|
404
|
-
|
|
404
|
+
/** `name` so the scrape port reports itself; the code stays one, because the fault is one. */
|
|
405
|
+
constructor(input: { value: string; name?: string }) {
|
|
406
|
+
const name = input.name ?? 'PORT';
|
|
405
407
|
super({
|
|
406
408
|
code: 'X_PORT_INVALID',
|
|
407
|
-
cause:
|
|
408
|
-
fix:
|
|
409
|
+
cause: `${name}="${input.value}" is not a TCP port number between 0 and 65535`,
|
|
410
|
+
fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090} <image>`,
|
|
409
411
|
docs: docsFor('X_PORT_INVALID'),
|
|
410
412
|
});
|
|
411
413
|
}
|
package/src/index.ts
CHANGED
|
@@ -132,6 +132,8 @@ export { renderJobTable } from './jobs-table';
|
|
|
132
132
|
export type { CliMcpServer, DevHostInput } from './mcp-host';
|
|
133
133
|
export { createDevMcpServer, DEV_TOOL_SCOPES, localCaller } from './mcp-host';
|
|
134
134
|
export { messageKeys, msg } from './messages';
|
|
135
|
+
export type { MetricsEndpoint, MetricsEndpointOptions } from './metrics-endpoint';
|
|
136
|
+
export { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
135
137
|
export { MIGRATIONS_DIR, migrationName, parseMigrationSql, readMigrations } from './migrations';
|
|
136
138
|
export type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
137
139
|
export {
|
|
@@ -153,6 +155,7 @@ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
|
|
|
153
155
|
export {
|
|
154
156
|
CONTAINER_BINDING,
|
|
155
157
|
DEFAULT_PORT,
|
|
158
|
+
metricsPortFromEnv,
|
|
156
159
|
portFromEnv,
|
|
157
160
|
roleFromEnv,
|
|
158
161
|
runMigrations,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Single responsibility: the scrape listener every role opens. `@ultimat3/core` declares the
|
|
2
|
+
// series and renders the body; this is the one place a process answers `METRICS_PATH` with it, so
|
|
3
|
+
// `docker/helm`'s HPAs read a number instead of `<unknown>`.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
logger,
|
|
7
|
+
METRICS_CONTENT_TYPE,
|
|
8
|
+
METRICS_PATH,
|
|
9
|
+
markListening,
|
|
10
|
+
metricsText,
|
|
11
|
+
} from '@ultimat3/core';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
|
|
15
|
+
* `docker/helm/templates/ingress.yaml` routes `path: /` `Prefix` to the web Service, so a
|
|
16
|
+
* `/metrics` mounted beside `/healthz` on port 3000 is `/metrics` on the internet — route
|
|
17
|
+
* patterns, request volumes and error rates, published. Nothing in the chart fronts this port:
|
|
18
|
+
* `service.yaml` only publishes `http`, so the endpoint is cluster-internal by construction
|
|
19
|
+
* rather than by an ingress exclusion somebody has to remember to write.
|
|
20
|
+
*
|
|
21
|
+
* It is also the only thing `worker`, `scheduler` and `replicator` could ever be scraped on —
|
|
22
|
+
* they open no HTTP socket at all, and `queue_depth` is exactly the signal one of them owns.
|
|
23
|
+
* 9090 is Prometheus's own convention, so a scrape config that assumes it needs no edit.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_METRICS_PORT = 9090;
|
|
26
|
+
|
|
27
|
+
export interface MetricsEndpointOptions {
|
|
28
|
+
/** 0 asks the kernel for an ephemeral port, which is what a test wants. */
|
|
29
|
+
readonly port?: number;
|
|
30
|
+
/** A container must bind every interface; a laptop must not. Same decision as the web role. */
|
|
31
|
+
readonly hostname?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MetricsEndpoint {
|
|
35
|
+
/** `http://host:port` — the base the scrape target appends `METRICS_PATH` to. */
|
|
36
|
+
readonly url: string;
|
|
37
|
+
stop(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Answers outside the request pipeline, exactly as `/healthz` and `/readyz` do in
|
|
42
|
+
* `@ultimat3/http`'s `server.ts`: no auth, no rate limit, no locale negotiation. A saturated or
|
|
43
|
+
* draining process must still be able to say how saturated it is — an autoscaler that loses its
|
|
44
|
+
* signal at the moment of load is worse than no autoscaler.
|
|
45
|
+
*/
|
|
46
|
+
export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
|
|
47
|
+
const server = Bun.serve({
|
|
48
|
+
port: options.port ?? DEFAULT_METRICS_PORT,
|
|
49
|
+
hostname: options.hostname ?? 'localhost',
|
|
50
|
+
fetch(request: Request): Response {
|
|
51
|
+
if (new URL(request.url).pathname !== METRICS_PATH) {
|
|
52
|
+
return new Response('not found', { status: 404 });
|
|
53
|
+
}
|
|
54
|
+
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot steal
|
|
55
|
+
// each other's samples — but a cache would hand the second one a stale window.
|
|
56
|
+
return new Response(metricsText(), {
|
|
57
|
+
headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
// Same rule as every other socket the framework opens: announce it, so a request back to it is
|
|
62
|
+
// recognisably this process calling itself rather than egress the test seal must refuse.
|
|
63
|
+
const stopListening = markListening(server.url.origin);
|
|
64
|
+
logger.info('ultimate metrics listening', { url: `${server.url.origin}${METRICS_PATH}` });
|
|
65
|
+
return {
|
|
66
|
+
url: server.url.origin,
|
|
67
|
+
stop(): void {
|
|
68
|
+
server.stop(true);
|
|
69
|
+
stopListening();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { Env } from './dev-services';
|
|
|
22
22
|
import { resolveServices } from './dev-services';
|
|
23
23
|
import { PortInvalidError, RoleUnknownError } from './errors';
|
|
24
24
|
import { holdUntilShutdown } from './hold';
|
|
25
|
+
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
25
26
|
import { readMigrations } from './migrations';
|
|
26
27
|
|
|
27
28
|
export const DEFAULT_PORT = 3000;
|
|
@@ -40,19 +41,32 @@ export function roleFromEnv(env: Env): Role {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* somewhere nobody asked for.
|
|
44
|
+
* `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
|
|
45
|
+
* partially-parsed port is a deploy that binds somewhere nobody asked for.
|
|
46
46
|
*/
|
|
47
|
-
|
|
48
|
-
const raw = env[
|
|
49
|
-
if (raw === undefined || raw.trim().length === 0) return
|
|
47
|
+
function portValue(env: Env, name: string, fallback: number): number {
|
|
48
|
+
const raw = env[name];
|
|
49
|
+
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
50
50
|
const port = Number(raw.trim());
|
|
51
51
|
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
52
|
-
throw new PortInvalidError({ value: raw });
|
|
52
|
+
throw new PortInvalidError({ value: raw, name });
|
|
53
53
|
return port;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Every PaaS injects `PORT` and routes traffic to exactly it. */
|
|
57
|
+
export function portFromEnv(env: Env): number {
|
|
58
|
+
return portValue(env, 'PORT', DEFAULT_PORT);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The scrape port, deliberately its own env var and not `PORT + n`: an operator who moves the app
|
|
63
|
+
* port must not silently move the port their Prometheus is configured against, and the roles that
|
|
64
|
+
* set no `PORT` at all — `worker`, `scheduler`, `replicator` — still need this one.
|
|
65
|
+
*/
|
|
66
|
+
export function metricsPortFromEnv(env: Env): number {
|
|
67
|
+
return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
export interface ServeOptions {
|
|
57
71
|
readonly root: string;
|
|
58
72
|
readonly env: Env;
|
|
@@ -60,6 +74,8 @@ export interface ServeOptions {
|
|
|
60
74
|
readonly role?: Role;
|
|
61
75
|
/** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
|
|
62
76
|
readonly port?: number;
|
|
77
|
+
/** Overrides `METRICS_PORT`, on the same terms. */
|
|
78
|
+
readonly metricsPort?: number;
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
export interface ServedApp {
|
|
@@ -138,9 +154,17 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
|
138
154
|
...assetRoutes({ root: options.root, storage: runtime.storage }),
|
|
139
155
|
...appRoutes({ buildId }),
|
|
140
156
|
];
|
|
157
|
+
const port = options.port ?? portFromEnv(options.env);
|
|
158
|
+
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
159
|
+
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
160
|
+
// still wins — that is the deploy talking.
|
|
161
|
+
const metricsPort =
|
|
162
|
+
options.metricsPort ??
|
|
163
|
+
(port === 0 && options.env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(options.env));
|
|
141
164
|
const running = await startRoles({
|
|
142
165
|
roles: [role],
|
|
143
|
-
port
|
|
166
|
+
port,
|
|
167
|
+
metricsPort,
|
|
144
168
|
buildId,
|
|
145
169
|
runtime,
|
|
146
170
|
routes,
|