@feasibleone/blong-gogo 1.38.0 → 1.39.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/CHANGELOG.md +29 -0
- package/package.json +7 -7
- package/src/ConfigRuntime.ts +8 -1
- package/src/Gateway.ts +77 -13
- package/src/Log.ts +83 -25
- package/src/Registry.ts +22 -0
- package/src/ResolutionK8s.ts +71 -0
- package/src/SemanticLog.test.ts +14 -4
- package/src/SemanticLog.ts +22 -2
- package/src/SystemDebug.test.ts +83 -0
- package/src/SystemDebug.ts +29 -2
- package/src/acl.test.ts +74 -0
- package/src/adapter/schema/knex/types.ts +18 -0
- package/src/adapter/server/acl.ts +6 -1
- package/src/adapter/server/k8s.test.ts +189 -0
- package/src/adapter/server/k8s.ts +540 -172
- package/src/adapter/server/kafka.ts +100 -47
- package/src/adapter/server/knex-connection.test.ts +79 -0
- package/src/adapter/server/knex.ts +70 -12
- package/src/adapter/server/pathRefresh.test.ts +26 -0
- package/src/adapter/server/pathRefresh.ts +30 -6
- package/src/chain.allure.test.ts +55 -0
- package/src/chain.ts +82 -6
- package/src/cliHelp.ts +7 -7
- package/src/codec/server.ts +5 -0
- package/src/devKeys.ts +2 -2
- package/src/exit.intent.test.ts +2 -2
- package/src/frameworkRealms.test.ts +74 -6
- package/src/gatewayKeys.test.ts +104 -0
- package/src/gatewayKeys.ts +68 -0
- package/src/grant.ts +8 -7
- package/src/intentsExclusion.test.ts +71 -0
- package/src/jwt.ts +2 -6
- package/src/lib.test.ts +46 -1
- package/src/lib.ts +31 -5
- package/src/load.ts +224 -9
- package/src/mle.test.ts +124 -0
- package/src/mle.ts +13 -0
- package/src/pino-pretty.test.ts +81 -0
- package/src/pino-pretty.ts +119 -16
- package/src/releaseRc.test.ts +92 -0
- package/src/runServer.test.ts +34 -2
- package/src/runServer.ts +10 -2
- package/src/semanticRecord.ts +20 -8
- package/src/static.test.ts +57 -0
- package/src/static.ts +13 -1
- package/template/.storybook/preview.tsx +8 -14
- package/template/meta/dbTest/accessAuthorizationMerge.yaml +14 -6
- package/template/meta/fixture/$subjectFixture.ts +11 -10
- package/template/src/stories/$Object.stories.tsx +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.39.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.1...blong-gogo-v1.39.0) (2026-10-11)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* blong-kustomize ([0ad6915](https://github.com/feasibleone/blong/commit/0ad6915af0d3c300a8741a509232aec7649999a1))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* address CI issues ([83b8957](https://github.com/feasibleone/blong/commit/83b895773bb020e1e1424513ecffd3e15b13dec6))
|
|
14
|
+
* address CI issues ([d23b652](https://github.com/feasibleone/blong/commit/d23b652b79eebfbaf46716ee4d7e7381730b384e))
|
|
15
|
+
* address CI issues ([10cc9bf](https://github.com/feasibleone/blong/commit/10cc9bfe68f3f8bc40dba97eb627b303e1fe64b5))
|
|
16
|
+
* address CI issues ([a2ff403](https://github.com/feasibleone/blong/commit/a2ff4035086ecb726d9df08798dae4a5556af7dd))
|
|
17
|
+
* address CI issues ([5ce588b](https://github.com/feasibleone/blong/commit/5ce588b763bee75afc2fca603998252d41443da5))
|
|
18
|
+
* address CI issues ([d32fbb1](https://github.com/feasibleone/blong/commit/d32fbb10cc945db43eb492606792f63bfb848ae6))
|
|
19
|
+
* address CI issues ([bea0f62](https://github.com/feasibleone/blong/commit/bea0f62aba2dcef95cc4363b02c4ee3e05c620b1))
|
|
20
|
+
* address CI issues, improve snapshots ([a86310a](https://github.com/feasibleone/blong/commit/a86310a8317e28e610a33072a483afc83b4b5bd2))
|
|
21
|
+
* address latency issue ([7504310](https://github.com/feasibleone/blong/commit/750431029b7345100e4124675f36404b6e1693fa))
|
|
22
|
+
* kustomize layout ([1e0eb50](https://github.com/feasibleone/blong/commit/1e0eb506617862db0e5e19f33d18ee0121c6aa42))
|
|
23
|
+
* test ACL with cucumber ([3a84be9](https://github.com/feasibleone/blong/commit/3a84be99a1544dd7ae599cdd976191cf21cabba9))
|
|
24
|
+
|
|
25
|
+
## [1.38.1](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.0...blong-gogo-v1.38.1) (2026-10-01)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Bug Fixes
|
|
29
|
+
|
|
30
|
+
* error handling ([fc1af53](https://github.com/feasibleone/blong/commit/fc1af536ae814a88a661341936bd9f9c519b38d1))
|
|
31
|
+
|
|
3
32
|
## [1.38.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.37.0...blong-gogo-v1.38.0) (2026-09-29)
|
|
4
33
|
|
|
5
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feasibleone/blong-gogo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "git+https://github.com/feasibleone/blong.git"
|
|
6
6
|
},
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"@feasibleone/blong-lib": "^1.0.0",
|
|
39
39
|
"@feasibleone/blong-mle": "^1.0.0",
|
|
40
40
|
"@feasibleone/blong-mock": "^1.0.0",
|
|
41
|
+
"@feasibleone/blong-template": "1.0.0",
|
|
41
42
|
"@feasibleone/semantic-log": "^1.0.0",
|
|
42
43
|
"@keycloak/keycloak-admin-client": "^26.6.2",
|
|
43
44
|
"@kubernetes/client-node": "^1.4.0",
|
|
@@ -79,26 +80,25 @@
|
|
|
79
80
|
"ut-function.interpolate": "1.1.3",
|
|
80
81
|
"ut-function.merge": "^1.5.6",
|
|
81
82
|
"uuid": "^13.0.2",
|
|
82
|
-
"yaml": "^2.8.3"
|
|
83
|
-
"@feasibleone/blong-template": "1.0.0"
|
|
83
|
+
"yaml": "^2.8.3"
|
|
84
84
|
},
|
|
85
85
|
"devDependencies": {
|
|
86
|
+
"@feasibleone/blong-dev": "1.9.0",
|
|
86
87
|
"@rushstack/heft": "^1.2.6",
|
|
87
88
|
"@rushstack/heft-lint-plugin": "^1.2.6",
|
|
88
89
|
"@rushstack/heft-typescript-plugin": "^1.3.1",
|
|
89
90
|
"@types/cacache": "^20.0.1",
|
|
90
91
|
"@types/node": "^24",
|
|
91
|
-
"playwright": "^1.
|
|
92
|
+
"playwright": "^1.63.0",
|
|
92
93
|
"tap": "^21.6.3",
|
|
93
94
|
"typescript": "^6.0.3",
|
|
94
|
-
"vite": "^8.2.1"
|
|
95
|
-
"@feasibleone/blong-dev": "1.7.1"
|
|
95
|
+
"vite": "^8.2.1"
|
|
96
96
|
},
|
|
97
97
|
"scripts": {
|
|
98
98
|
"browser-check": "node scripts/browser-compat-check.mjs",
|
|
99
99
|
"build": "true",
|
|
100
100
|
"ci-coverage": "./run-coverage.sh",
|
|
101
|
-
"ci-lint": "blong-dev lint",
|
|
101
|
+
"ci-lint": "blong-dev lint && node docker/manifests.mjs --check",
|
|
102
102
|
"ci-publish": "node ../../common/scripts/install-run-rush-pnpm.js publish --access public --provenance",
|
|
103
103
|
"ci-test": "blong-dev test",
|
|
104
104
|
"test": "blong-dev test"
|
package/src/ConfigRuntime.ts
CHANGED
|
@@ -314,7 +314,14 @@ export default class ConfigRuntime implements IConfigRuntime {
|
|
|
314
314
|
}
|
|
315
315
|
|
|
316
316
|
private mergeConfigs(blongConfig: object): object {
|
|
317
|
-
|
|
317
|
+
// The intents travel into `blong-config`, which reads them out of `env`. They decide which
|
|
318
|
+
// rc files a process reads at all — the *trailing* one is the suffix both source names are
|
|
319
|
+
// built from, the framework's existing convention — so a config step that cannot see them
|
|
320
|
+
// leaves every process reading the `dev` file whatever it was asked to be (Phase 15 E).
|
|
321
|
+
const intents = (this.#baseConfig as {configNames?: string[]}).configNames;
|
|
322
|
+
const loaded = loadBlong(
|
|
323
|
+
intents?.length ? {env: intents.join(','), ...blongConfig} : blongConfig,
|
|
324
|
+
);
|
|
318
325
|
return merge({}, this.#baseConfig, ...this.#configs, loaded);
|
|
319
326
|
}
|
|
320
327
|
|
package/src/Gateway.ts
CHANGED
|
@@ -14,7 +14,12 @@ import type {
|
|
|
14
14
|
IPlatformApi,
|
|
15
15
|
} from '@feasibleone/blong/types';
|
|
16
16
|
import {Internal} from '@feasibleone/blong/types';
|
|
17
|
-
import fastify, {
|
|
17
|
+
import fastify, {
|
|
18
|
+
LogController,
|
|
19
|
+
type FastifyReply,
|
|
20
|
+
type FastifyRequest,
|
|
21
|
+
type RouteOptions,
|
|
22
|
+
} from 'fastify';
|
|
18
23
|
import os from 'os';
|
|
19
24
|
import type {LevelWithSilent} from 'pino';
|
|
20
25
|
import {Type, type TSchema} from 'typebox';
|
|
@@ -23,6 +28,7 @@ import {v4} from 'uuid';
|
|
|
23
28
|
|
|
24
29
|
import {TRACE_HEADER, withoutCapabilities} from '@feasibleone/semantic-log/capability';
|
|
25
30
|
import {CALLS_CAPABILITY, GRANT_HEADER} from './callTrace.ts';
|
|
31
|
+
import {GENERATED_KEY_SPEC, GENERATED_PAIR_WARNING} from './gatewayKeys.ts';
|
|
26
32
|
import {grantedCalls, grantKeyFrom} from './grant.ts';
|
|
27
33
|
import {
|
|
28
34
|
callsFor,
|
|
@@ -61,6 +67,17 @@ type GatewayRequest = FastifyRequest;
|
|
|
61
67
|
|
|
62
68
|
const errorMap: IErrorMap = {
|
|
63
69
|
'gateway.jwtMissingHeader': {message: 'Missing bearer authorization header', statusCode: 401},
|
|
70
|
+
// The method-level refusal of RBAC. Typed because a test that probes a denial
|
|
71
|
+
// on purpose declares it in `expect`, and the demotion matches on the type —
|
|
72
|
+
// an untyped `Error` is always logged at error level.
|
|
73
|
+
'gateway.notAllowed': {
|
|
74
|
+
message: 'Authorization denied: method "{methodName}" not allowed',
|
|
75
|
+
statusCode: 403,
|
|
76
|
+
},
|
|
77
|
+
'gateway.noActions': {
|
|
78
|
+
message: 'Authorization denied: no actions resolved',
|
|
79
|
+
statusCode: 403,
|
|
80
|
+
},
|
|
64
81
|
};
|
|
65
82
|
|
|
66
83
|
declare module 'fastify' {
|
|
@@ -229,6 +246,21 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
229
246
|
};
|
|
230
247
|
});
|
|
231
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The browser build this process was configured to serve, as the merge resolved it.
|
|
252
|
+
*
|
|
253
|
+
* `config()` is protected on purpose: that subtree carries the key pair (`sign`, `encrypt`), the
|
|
254
|
+
* exposure D-448 keeps shut. *Which* static root arrived is a different question, and one a test
|
|
255
|
+
* could not ask anywhere — the plugin's own behaviour is covered (`static.test.ts`: it serves a
|
|
256
|
+
* bundle and tolerates a missing one), while nothing could see that a process's own block reaches
|
|
257
|
+
* this port at all. That leg is what a suite's `release` block naming the artifact's browser build
|
|
258
|
+
* depends on, and its failure is silent: the plugin is registered only when the config is there,
|
|
259
|
+
* so `/s` answers nothing while every other surface works (T-246).
|
|
260
|
+
*/
|
|
261
|
+
public staticRoot(): string | undefined {
|
|
262
|
+
return this.#config.static?.root;
|
|
263
|
+
}
|
|
232
264
|
#errorFields: [string, unknown][] = [];
|
|
233
265
|
#plugins: {plugin: unknown; options: unknown}[] = [];
|
|
234
266
|
#platform: IPlatformApi;
|
|
@@ -259,16 +291,22 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
259
291
|
this.#platform = platform!;
|
|
260
292
|
|
|
261
293
|
this.merge(this.#config, config);
|
|
262
|
-
this
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
)
|
|
294
|
+
// A pair reaches this config as ordinary configuration — the deployment's rc file
|
|
295
|
+
// (`gateway.sign` / `gateway.encrypt` under the pod's home), the `dev` block's committed
|
|
296
|
+
// literals, or a `--gateway.sign=…` flag — and a process given none generates its own. That
|
|
297
|
+
// last case is worth a warning rather than silence: two replicas of one suite would then
|
|
298
|
+
// mint tokens the other cannot verify, and the symptom is a rejected session somewhere else,
|
|
299
|
+
// long after the cause. A deployment that mounts the file never sees this line (D-441).
|
|
300
|
+
const generated = !this.#config.sign || !this.#config.encrypt;
|
|
301
|
+
this.#config.sign ||= GENERATED_KEY_SPEC.sign as IConfig['sign'];
|
|
302
|
+
this.#config.encrypt ||= GENERATED_KEY_SPEC.encrypt as IConfig['encrypt'];
|
|
303
|
+
if (generated) {
|
|
304
|
+
// The loader resolves the pair for every process that serves a gateway
|
|
305
|
+
// (`resolveGatewayPair`), so a missing half here means a gateway built *without* one — a
|
|
306
|
+
// component constructed directly by a test. Kept as the last resort it always was, with
|
|
307
|
+
// the same descriptors and the same sentence the loader uses.
|
|
308
|
+
this.#log?.logger('warn', {name: 'gateway'})?.warn?.({}, GENERATED_PAIR_WARNING);
|
|
309
|
+
}
|
|
272
310
|
this.#errorFields = Object.entries({
|
|
273
311
|
type: true,
|
|
274
312
|
message: true,
|
|
@@ -436,6 +474,15 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
436
474
|
id: Type.Optional(Type.Union([Type.String(), Type.Number()])),
|
|
437
475
|
method: isWildcard ? Type.String() : Type.Literal(method),
|
|
438
476
|
params: value.params,
|
|
477
|
+
// A caller may declare the errors it expects. The
|
|
478
|
+
// gateway only *honours* it when configured to
|
|
479
|
+
// (`expectedErrors`), but the field has to survive
|
|
480
|
+
// validation for the body to reach the route at all —
|
|
481
|
+
// without it the declaration was silently dropped and
|
|
482
|
+
// every expected failure was logged as a surprise.
|
|
483
|
+
expect: Type.Optional(
|
|
484
|
+
Type.Union([Type.String(), Type.Array(Type.String())]),
|
|
485
|
+
),
|
|
439
486
|
}),
|
|
440
487
|
}
|
|
441
488
|
: undefined),
|
|
@@ -706,7 +753,12 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
706
753
|
// both more than a reader needs and less than the flow records say.
|
|
707
754
|
// Nothing else depends on those two lines: the request has a flow, the
|
|
708
755
|
// flow has its calls, and both are recorded where they belong.
|
|
709
|
-
|
|
756
|
+
//
|
|
757
|
+
// Passed as a `LogController` instance rather than as fastify's own
|
|
758
|
+
// top-level `disableRequestLogging`, which is deprecated and warned
|
|
759
|
+
// about a line per process — one line per package in a CI run — for a
|
|
760
|
+
// flag whose replacement does exactly the same thing.
|
|
761
|
+
logController: new LogController({disableRequestLogging: true}),
|
|
710
762
|
ajv: {
|
|
711
763
|
customOptions: {
|
|
712
764
|
allErrors: true,
|
|
@@ -739,7 +791,19 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
739
791
|
|
|
740
792
|
this.#server.setErrorHandler(
|
|
741
793
|
(error: Record<string, unknown>, request: FastifyRequest, reply: FastifyReply) => {
|
|
742
|
-
|
|
794
|
+
// A hook that refuses before the route runs (authentication, RBAC)
|
|
795
|
+
// never reaches the handler that resolves `expect` for `$meta`, so
|
|
796
|
+
// the declaration is read from the request body here: the caller
|
|
797
|
+
// asked for this failure and the error stream is for the ones it did
|
|
798
|
+
// not ask for.
|
|
799
|
+
const expected = this.#config.expectedErrors
|
|
800
|
+
? (request.body as {expect?: string | string[]} | undefined)?.expect
|
|
801
|
+
: undefined;
|
|
802
|
+
if (isExpectedError(error.type as string | undefined, expected)) {
|
|
803
|
+
request.log.debug?.({err: error}, 'gateway expected error');
|
|
804
|
+
} else {
|
|
805
|
+
request.log.error({err: error}, 'gateway unhandled error');
|
|
806
|
+
}
|
|
743
807
|
return reply.status((error as {statusCode?: number}).statusCode || 500).send({
|
|
744
808
|
jsonrpc: '2.0',
|
|
745
809
|
id: (request.body as {id?: unknown})?.id,
|
package/src/Log.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {withProgress} from '@feasibleone/blong-lib';
|
|
2
2
|
import {Internal, type ICallLog, type ILog, type ILogger} from '@feasibleone/blong/types';
|
|
3
3
|
import {createCallChannel} from '@feasibleone/semantic-log/capability';
|
|
4
|
-
import {
|
|
4
|
+
import {callerSite, messageMatches} from '@feasibleone/semantic-log/emitter';
|
|
5
|
+
import {pino, type Logger, type LoggerOptions, type TransportSingleOptions} from 'pino';
|
|
5
6
|
import {monotonicFactory} from 'ulidx';
|
|
6
7
|
import {callRecord, configureCallTrace, type CallTraceConfig} from './callTrace.ts';
|
|
7
8
|
import type {CacacheTransportOptions} from './pino-cacache.js';
|
|
@@ -18,6 +19,23 @@ export interface LogConfig extends LoggerOptions {
|
|
|
18
19
|
impl?: string;
|
|
19
20
|
/** The semantic implementation's store. Not a pino option; stripped below. */
|
|
20
21
|
cache?: unknown;
|
|
22
|
+
/**
|
|
23
|
+
* Put the `semlog://` reference group at the end of a human line — the
|
|
24
|
+
* pretty printer renders it, and this implementation has no other way to
|
|
25
|
+
* show one. Not a pino option; stripped below. On by default, off in a
|
|
26
|
+
* released process (`log.refs: false`), whose stdout nobody resolves a
|
|
27
|
+
* reference from.
|
|
28
|
+
*/
|
|
29
|
+
refs?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* The messages whose caller is worth naming — `log.callSite`.
|
|
32
|
+
*
|
|
33
|
+
* Each entry is a regular expression source, so a substring (`unauthorized`, `adapter.ready`)
|
|
34
|
+
* is written as one and `['']` names the caller of every record. The semantic implementation
|
|
35
|
+
* reads the same list, so one entry places an unexplained line whichever logger a process
|
|
36
|
+
* chose, and nothing is captured for a message that matches none of the patterns.
|
|
37
|
+
*/
|
|
38
|
+
callSite?: string | string[];
|
|
21
39
|
/**
|
|
22
40
|
* The call channel: whether a flow's calls are recorded, and whether they
|
|
23
41
|
* are shown.
|
|
@@ -34,6 +52,14 @@ export interface LogConfig extends LoggerOptions {
|
|
|
34
52
|
|
|
35
53
|
const ulid = monotonicFactory();
|
|
36
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The files a pino record's stack runs through before it reaches the call site.
|
|
57
|
+
*
|
|
58
|
+
* This module's own frames — the hook that asks for the site, since it runs inside pino's write
|
|
59
|
+
* path — and pino's, which are between that hook and the caller. Everything else is the call site.
|
|
60
|
+
*/
|
|
61
|
+
const PINO_FRAME = /[\\/](?:Log|semantic-log|pino|thread-stream)[\\/.]/;
|
|
62
|
+
|
|
37
63
|
const ignoreArgPatterns = [
|
|
38
64
|
'--tls-cipher-list=',
|
|
39
65
|
'--v8-pool-size=',
|
|
@@ -73,39 +99,37 @@ const WORKER_OPTS = {
|
|
|
73
99
|
),
|
|
74
100
|
};
|
|
75
101
|
|
|
76
|
-
const PRETTY_TRANSPORT = {
|
|
77
|
-
target: './pino-pretty.mjs',
|
|
102
|
+
const PRETTY_TRANSPORT = (refs?: boolean): TransportSingleOptions<Record<string, unknown>> => ({
|
|
103
|
+
target: './pino-pretty.mjs' as const,
|
|
78
104
|
worker: WORKER_OPTS,
|
|
79
105
|
options: {
|
|
80
106
|
singleLine: true,
|
|
81
107
|
colorizeObjects: true,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
'req',
|
|
90
|
-
'res',
|
|
91
|
-
'config',
|
|
92
|
-
'configBase',
|
|
93
|
-
'id',
|
|
94
|
-
].join(','),
|
|
108
|
+
// The reference group is the printer's to render, so the switch travels
|
|
109
|
+
// with the transport the process chose.
|
|
110
|
+
refs,
|
|
111
|
+
// No `ignore` here: what the header already printed is the printer's own knowledge, and it
|
|
112
|
+
// lives beside the header that prints it (`pino-pretty.ts` → `ALREADY_IN_HEADER`). It used to
|
|
113
|
+
// be a list in this file, and an entry per envelope member meant the emptied
|
|
114
|
+
// `{"$meta":{}}` was printed instead of the two members it had dropped.
|
|
95
115
|
},
|
|
96
|
-
};
|
|
116
|
+
});
|
|
97
117
|
|
|
98
118
|
export default class Log extends Internal implements ILog {
|
|
99
119
|
#logger: Logger;
|
|
100
120
|
#config: LogConfig = {
|
|
101
121
|
level: 'info',
|
|
102
|
-
transport: PRETTY_TRANSPORT,
|
|
122
|
+
transport: PRETTY_TRANSPORT(),
|
|
103
123
|
};
|
|
104
124
|
|
|
105
125
|
public constructor(config: LogConfig) {
|
|
106
126
|
super();
|
|
107
127
|
this.merge(this.#config, config);
|
|
108
128
|
configureCallTrace(this.#config.calls);
|
|
129
|
+
// The printer renders the reference group, so the switch has to be known
|
|
130
|
+
// before the transport is built — the default above is there for a caller
|
|
131
|
+
// that never merges a config at all.
|
|
132
|
+
this.#config.transport = PRETTY_TRANSPORT(this.#config.refs);
|
|
109
133
|
|
|
110
134
|
// Inject a monotonic ULID `id` into every log entry before it reaches any transport
|
|
111
135
|
|
|
@@ -115,7 +139,7 @@ export default class Log extends Internal implements ILog {
|
|
|
115
139
|
const cacacheOptions = this.#config.cacache;
|
|
116
140
|
this.#config.transport = {
|
|
117
141
|
targets: [
|
|
118
|
-
PRETTY_TRANSPORT,
|
|
142
|
+
PRETTY_TRANSPORT(this.#config.refs),
|
|
119
143
|
{
|
|
120
144
|
target: './pino-cacache.mjs',
|
|
121
145
|
worker: WORKER_OPTS,
|
|
@@ -134,6 +158,8 @@ export default class Log extends Internal implements ILog {
|
|
|
134
158
|
cacache: _cacacheConfig,
|
|
135
159
|
impl: _impl,
|
|
136
160
|
cache: _semanticCache,
|
|
161
|
+
refs: _refs,
|
|
162
|
+
callSite: _callSite,
|
|
137
163
|
calls: _calls,
|
|
138
164
|
...pinoConfig
|
|
139
165
|
} = this.#config;
|
|
@@ -158,6 +184,38 @@ export default class Log extends Internal implements ILog {
|
|
|
158
184
|
bindings: object,
|
|
159
185
|
): ReturnType<ILog['logger']> {
|
|
160
186
|
const child = this.#logger.child(bindings, {level});
|
|
187
|
+
// The caller of the messages `log.callSite` names. pino has no hook that sees a message:
|
|
188
|
+
// `logMethod` is not inherited by a child, and a formatter is handed the fields without
|
|
189
|
+
// the text — so the match is made here, where the framework builds the logger it hands
|
|
190
|
+
// out, and every component's logger is covered by construction.
|
|
191
|
+
const locations =
|
|
192
|
+
typeof this.#config.callSite === 'string'
|
|
193
|
+
? [this.#config.callSite]
|
|
194
|
+
: this.#config.callSite;
|
|
195
|
+
const bind = <A extends unknown[]>(
|
|
196
|
+
method: (...args: A) => void,
|
|
197
|
+
): ((...args: A) => void) => {
|
|
198
|
+
if (!locations?.length) return method;
|
|
199
|
+
return (...args: A) => {
|
|
200
|
+
const call = [...args] as unknown[];
|
|
201
|
+
// The message is whichever argument is text: pino's order is `(bag, message)` or
|
|
202
|
+
// `(message, values)`, and the text is the part both of those have.
|
|
203
|
+
const message = [...call].reverse().find(arg => typeof arg === 'string');
|
|
204
|
+
if (message !== undefined && messageMatches(locations, message)) {
|
|
205
|
+
// The site has to sit where pino looks for fields, which is the *first*
|
|
206
|
+
// argument: an object after the message is a printf value, and pino drops one
|
|
207
|
+
// the message has no placeholder left for. A call that already leads with a
|
|
208
|
+
// bag keeps it, and the site joins it.
|
|
209
|
+
const leading = call[0];
|
|
210
|
+
if (leading && typeof leading === 'object' && !Array.isArray(leading)) {
|
|
211
|
+
call[0] = {...(leading as object), site: callerSite(PINO_FRAME)};
|
|
212
|
+
} else {
|
|
213
|
+
call.unshift({site: callerSite(PINO_FRAME)});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return (method as (...args: unknown[]) => void)(...call);
|
|
217
|
+
};
|
|
218
|
+
};
|
|
161
219
|
const result: ILogger = {
|
|
162
220
|
trace: undefined,
|
|
163
221
|
debug: undefined,
|
|
@@ -168,17 +226,17 @@ export default class Log extends Internal implements ILog {
|
|
|
168
226
|
};
|
|
169
227
|
switch (level) {
|
|
170
228
|
case 'trace':
|
|
171
|
-
result.trace = child.trace.bind(child);
|
|
229
|
+
result.trace = bind(child.trace.bind(child));
|
|
172
230
|
case 'debug':
|
|
173
|
-
result.debug = child.debug.bind(child);
|
|
231
|
+
result.debug = bind(child.debug.bind(child));
|
|
174
232
|
case 'info':
|
|
175
|
-
result.info = child.info.bind(child);
|
|
233
|
+
result.info = bind(child.info.bind(child));
|
|
176
234
|
case 'warn':
|
|
177
|
-
result.warn = child.warn.bind(child);
|
|
235
|
+
result.warn = bind(child.warn.bind(child));
|
|
178
236
|
case 'error':
|
|
179
|
-
result.error = child.error.bind(child);
|
|
237
|
+
result.error = bind(child.error.bind(child));
|
|
180
238
|
case 'fatal':
|
|
181
|
-
result.fatal = child.fatal.bind(child);
|
|
239
|
+
result.fatal = bind(child.fatal.bind(child));
|
|
182
240
|
}
|
|
183
241
|
return {
|
|
184
242
|
...result,
|
package/src/Registry.ts
CHANGED
|
@@ -87,6 +87,14 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
87
87
|
#apiSchema: IApiSchema;
|
|
88
88
|
#platform: IPlatformApi;
|
|
89
89
|
#config: IConfig = {};
|
|
90
|
+
/**
|
|
91
|
+
* What each realm declares about itself, keyed by realm name.
|
|
92
|
+
*
|
|
93
|
+
* Handed over by {@link setRealmConfig} rather than read from this component's config, because
|
|
94
|
+
* a realm loads *after* the config of the realm whose child it is has merged: a copy taken while
|
|
95
|
+
* any config is built holds the realms loaded up to that moment and nothing more (D-433).
|
|
96
|
+
*/
|
|
97
|
+
#realmConfig: Record<string, unknown> = {};
|
|
90
98
|
#attachCheckpoint?: (meta: IMeta) => void;
|
|
91
99
|
|
|
92
100
|
#manifest: IManifest | undefined;
|
|
@@ -518,9 +526,23 @@ export default class Registry extends Internal implements IRegistry {
|
|
|
518
526
|
folders: disk?.folders ?? [],
|
|
519
527
|
files: disk?.files ?? [],
|
|
520
528
|
layerFiles: disk?.layerFiles ?? [],
|
|
529
|
+
// What each realm declares about itself, keyed by realm name: the loader records the
|
|
530
|
+
// slice and hands the map here, so a planner reads a realm's own `k8sRealmRole` rather
|
|
531
|
+
// than inferring it from the ports the realm happens to own (D-433).
|
|
532
|
+
realmConfig: this.#realmConfig,
|
|
521
533
|
};
|
|
522
534
|
}
|
|
523
535
|
|
|
536
|
+
/**
|
|
537
|
+
* Publish the per-realm config map.
|
|
538
|
+
*
|
|
539
|
+
* The map is taken by reference, and the loader keeps adding to it as realms load, so a caller
|
|
540
|
+
* that asks later sees the realms that were not loaded yet when the registry was created.
|
|
541
|
+
*/
|
|
542
|
+
public setRealmConfig(realmConfig: Record<string, unknown>): void {
|
|
543
|
+
this.#realmConfig = realmConfig;
|
|
544
|
+
}
|
|
545
|
+
|
|
524
546
|
public async stop(): Promise<IRegistry> {
|
|
525
547
|
if (this.#stopped) return this;
|
|
526
548
|
await this.#watch?.stop();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {Internal, type IManifest} from '@feasibleone/blong/types';
|
|
2
|
+
|
|
3
|
+
import type {IResolution} from './Resolution.ts';
|
|
4
|
+
|
|
5
|
+
interface IConfig {
|
|
6
|
+
/** The port `RpcServer` serves an orchestrator namespace on. */
|
|
7
|
+
portRpc: number;
|
|
8
|
+
/** The gateway port (unused for namespace resolution, kept for symmetry). */
|
|
9
|
+
portGateway: number;
|
|
10
|
+
/** The cluster DNS domain the Service suffix is built from. */
|
|
11
|
+
domain: string;
|
|
12
|
+
/** The namespace the suite is deployed into. */
|
|
13
|
+
namespace: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Kubernetes resolution: a service id resolves to the Service of the same name
|
|
18
|
+
* in the suite's namespace, instead of to a local port.
|
|
19
|
+
*
|
|
20
|
+
* `GatewayCodec` asks for `rpc-<namespace>` and `RpcServer` serves namespaces on
|
|
21
|
+
* the RPC port, so the `rpc-` prefix is stripped and the port is the
|
|
22
|
+
* namespace's, not the gateway's. `blong-kustomize` generates one Service per
|
|
23
|
+
* orchestrator namespace with that name, which is what makes the namespace the
|
|
24
|
+
* suite's abstract name for a process (see the PRD and the deployment docs).
|
|
25
|
+
*
|
|
26
|
+
* The namespace comes from config, then from `BLONG_NAMESPACE` /
|
|
27
|
+
* `KUBERNETES_NAMESPACE` — the downward API that pins a pod to its own
|
|
28
|
+
* namespace — and only then falls back to `default`.
|
|
29
|
+
*
|
|
30
|
+
* Pinned down with `resolution.impl: kubernetes` in the suite's config; the
|
|
31
|
+
* loader then loads this module instead of `ResolutionLocal` (see the
|
|
32
|
+
* `resolution` component in `load.ts`).
|
|
33
|
+
*/
|
|
34
|
+
export default class ResolutionK8s extends Internal implements IResolution {
|
|
35
|
+
#config: IConfig = {
|
|
36
|
+
portRpc: 8091,
|
|
37
|
+
portGateway: 8080,
|
|
38
|
+
domain: 'svc.cluster.local',
|
|
39
|
+
namespace: '',
|
|
40
|
+
};
|
|
41
|
+
#manifest: IManifest | undefined;
|
|
42
|
+
|
|
43
|
+
public constructor(config: IConfig, {manifest}: {manifest?: IManifest} = {}) {
|
|
44
|
+
super();
|
|
45
|
+
this.merge(this.#config, config);
|
|
46
|
+
this.#manifest = manifest;
|
|
47
|
+
if (!this.#config.namespace) {
|
|
48
|
+
const env = (globalThis as {process?: {env?: Record<string, string | undefined>}})
|
|
49
|
+
.process?.env;
|
|
50
|
+
this.#config.namespace = env?.BLONG_NAMESPACE ?? env?.KUBERNETES_NAMESPACE ?? 'default';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public async resolve(service: string): ReturnType<IResolution['resolve']> {
|
|
55
|
+
const isRpc = service.startsWith('rpc-');
|
|
56
|
+
const name = service.replace(/^rpc-/, '');
|
|
57
|
+
// The manifest port is deliberately NOT preferred here: a random dev port
|
|
58
|
+
// would point at a Service port the cluster does not publish. In the
|
|
59
|
+
// cluster the Service is the address, and its port is the component's.
|
|
60
|
+
return {
|
|
61
|
+
hostname: `${name}.${this.#config.namespace}.${this.#config.domain}`,
|
|
62
|
+
port: `${isRpc ? this.#config.portRpc : this.#config.portGateway}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public announce(): void {}
|
|
67
|
+
|
|
68
|
+
public async start(): Promise<void> {}
|
|
69
|
+
|
|
70
|
+
public async stop(): Promise<void> {}
|
|
71
|
+
}
|
package/src/SemanticLog.test.ts
CHANGED
|
@@ -127,12 +127,22 @@ t.test('the argument adapter lifts the framework envelope into the record slots'
|
|
|
127
127
|
t.equal(envelope.fields.operation, 'subject.subjectModelList');
|
|
128
128
|
t.equal(envelope.fields.messageId, 'request');
|
|
129
129
|
t.equal(envelope.fields.extra, 1);
|
|
130
|
-
t.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
'the envelope
|
|
130
|
+
t.same(
|
|
131
|
+
envelope.fields.$meta,
|
|
132
|
+
undefined,
|
|
133
|
+
'and the envelope is not repeated beside the slots that hold its members',
|
|
134
134
|
);
|
|
135
135
|
|
|
136
|
+
// A member the slots did not take is still carried, which is what "a reader still sees the
|
|
137
|
+
// request it came from" means, and why the envelope is pruned rather than dropped.
|
|
138
|
+
const rest = toLogCall([{$meta: {mtid: 'event', method: 'a.b', expect: 'notFound'}}]);
|
|
139
|
+
t.same(rest.fields.$meta, {expect: 'notFound'}, 'the rest of the envelope stays');
|
|
140
|
+
|
|
141
|
+
// And a member that could not become a slot stays too, rather than vanishing: the header shows
|
|
142
|
+
// a `method` only when it is text, so a non-string one is nowhere else on the record.
|
|
143
|
+
const odd = toLogCall([{$meta: {mtid: 1, method: 2}}]);
|
|
144
|
+
t.same(odd.fields.$meta, {mtid: 1, method: 2}, 'a member that did not become a slot is kept');
|
|
145
|
+
|
|
136
146
|
// The envelope wins over the same keys on the bag: it is the structured source.
|
|
137
147
|
const overridden = toLogCall([
|
|
138
148
|
{
|
package/src/SemanticLog.ts
CHANGED
|
@@ -80,6 +80,23 @@ export interface SemanticLogConfig {
|
|
|
80
80
|
format?: Format;
|
|
81
81
|
/** ANSI colour in human format. Opt-in, as the emitter's own option is. */
|
|
82
82
|
color?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Put the `semlog://` reference group at the end of a human line. On by
|
|
85
|
+
* default, off in a released process (`log.refs: false`): a reference is a
|
|
86
|
+
* development aid, and resolving one needs a store open beside the terminal.
|
|
87
|
+
*/
|
|
88
|
+
refs?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* The messages whose caller is worth naming — `log.callSite`.
|
|
91
|
+
*
|
|
92
|
+
* Each entry is a regular expression source, so a substring (`unauthorized`, `adapter.ready`)
|
|
93
|
+
* is written as one and `['']` names the caller of every record. A list rather than a switch,
|
|
94
|
+
* because the interesting line is usually one particular message: a record whose text matches
|
|
95
|
+
* none of the patterns is emitted exactly as it was, with no stack captured for it. A call in
|
|
96
|
+
* pino's printf shape is always named, whatever this says — the repository writes no such
|
|
97
|
+
* call, so one arriving is a dependency's.
|
|
98
|
+
*/
|
|
99
|
+
callSite?: string | string[];
|
|
83
100
|
/** The service name carried on every record; the framework's own by default. */
|
|
84
101
|
service?: string;
|
|
85
102
|
/**
|
|
@@ -139,6 +156,8 @@ export default class SemanticLog extends Internal implements ILog {
|
|
|
139
156
|
level: thresholdOf(merged.level),
|
|
140
157
|
format: merged.format,
|
|
141
158
|
color: merged.color,
|
|
159
|
+
refs: merged.refs,
|
|
160
|
+
callSite: merged.callSite,
|
|
142
161
|
cache: merged.cache,
|
|
143
162
|
// Only the printing switch is the base's business; `enabled`/`off` are
|
|
144
163
|
// the gate's, and are read where a flow is minted.
|
|
@@ -150,9 +169,10 @@ export default class SemanticLog extends Internal implements ILog {
|
|
|
150
169
|
openCluster: async () =>
|
|
151
170
|
(await import('@feasibleone/semantic-log/service')).openCluster,
|
|
152
171
|
// The framework's envelope, on the record and on the call site's own
|
|
153
|
-
// arguments: `{$meta: {mtid, method}}` is what its readers match on
|
|
172
|
+
// arguments: `{$meta: {mtid, method}}` is what its readers match on — and the
|
|
173
|
+
// caller's frame, when the log configuration asks for it.
|
|
154
174
|
envelope: ({leg, phase}) => callRecord(leg, phase),
|
|
155
|
-
translate: args => toLogCall(args),
|
|
175
|
+
translate: args => toLogCall(args, {callSite: merged.callSite}),
|
|
156
176
|
});
|
|
157
177
|
}
|
|
158
178
|
|