@toa.io/extensions.introspection 1.0.0-alpha.259
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 +22 -0
- package/components/introspection.edges/manifest.toa.yaml +48 -0
- package/components/introspection.edges/operations/lib/types.d.ts +28 -0
- package/components/introspection.edges/operations/lib/types.js +3 -0
- package/components/introspection.edges/operations/lib/types.js.map +1 -0
- package/components/introspection.edges/operations/merge.d.ts +9 -0
- package/components/introspection.edges/operations/merge.js +25 -0
- package/components/introspection.edges/operations/merge.js.map +1 -0
- package/components/introspection.edges/operations/tsconfig.tsbuildinfo +1 -0
- package/components/introspection.edges/source/lib/types.ts +33 -0
- package/components/introspection.edges/source/merge.ts +26 -0
- package/components/introspection.edges/tsconfig.json +9 -0
- package/components/introspection.nodes/manifest.toa.yaml +49 -0
- package/components/introspection.nodes/operations/lib/types.d.ts +16 -0
- package/components/introspection.nodes/operations/lib/types.js +3 -0
- package/components/introspection.nodes/operations/lib/types.js.map +1 -0
- package/components/introspection.nodes/operations/merge.d.ts +9 -0
- package/components/introspection.nodes/operations/merge.js +21 -0
- package/components/introspection.nodes/operations/merge.js.map +1 -0
- package/components/introspection.nodes/operations/tsconfig.tsbuildinfo +1 -0
- package/components/introspection.nodes/source/lib/types.ts +18 -0
- package/components/introspection.nodes/source/merge.ts +21 -0
- package/components/introspection.nodes/tsconfig.json +9 -0
- package/cucumber.js +8 -0
- package/features/calls.feature +123 -0
- package/features/nodes.feature +70 -0
- package/features/samples.feature +75 -0
- package/features/shutdown.feature +37 -0
- package/features/site/_app/immutable/asset.js +1 -0
- package/features/site/index.html +6 -0
- package/features/steps/Map.ts +151 -0
- package/features/steps/UI.ts +88 -0
- package/features/steps/components/probe.discreet/manifest.toa.yaml +12 -0
- package/features/steps/components/probe.discreet/operations/ping.js +7 -0
- package/features/steps/components/probe.quiet/manifest.toa.yaml +11 -0
- package/features/steps/components/probe.quiet/operations/ping.js +7 -0
- package/features/steps/components/probe.source/manifest.toa.yaml +27 -0
- package/features/steps/components/probe.source/operations/crash.js +7 -0
- package/features/steps/components/probe.source/operations/fail.js +12 -0
- package/features/steps/components/probe.source/operations/relay.js +7 -0
- package/features/steps/components/probe.target/manifest.toa.yaml +24 -0
- package/features/steps/components/probe.target/operations/compute.js +7 -0
- package/features/steps/components/probe.target/operations/count.js +9 -0
- package/features/steps/config.ts +20 -0
- package/features/steps/tsconfig.json +9 -0
- package/features/ui.feature +85 -0
- package/package.json +32 -0
- package/readme.md +135 -0
- package/schemas/annotation.cos.yaml +21 -0
- package/schemas/declaration.cos.yaml +2 -0
- package/source/Composition.ts +61 -0
- package/source/Explorer.ts +16 -0
- package/source/Factory.ts +119 -0
- package/source/Reporter.ts +210 -0
- package/source/Tenant.ts +47 -0
- package/source/UI.ts +197 -0
- package/source/annotation.ts +83 -0
- package/source/const.ts +32 -0
- package/source/describe.ts +68 -0
- package/source/extension.ts +45 -0
- package/source/index.ts +7 -0
- package/source/keys.ts +0 -0
- package/source/manifest.ts +19 -0
- package/source/model.ts +82 -0
- package/source/sample.ts +49 -0
- package/source/schemas.ts +10 -0
- package/transpiled/Composition.d.ts +21 -0
- package/transpiled/Composition.js +48 -0
- package/transpiled/Composition.js.map +1 -0
- package/transpiled/Explorer.d.ts +9 -0
- package/transpiled/Explorer.js +19 -0
- package/transpiled/Explorer.js.map +1 -0
- package/transpiled/Factory.d.ts +22 -0
- package/transpiled/Factory.js +93 -0
- package/transpiled/Factory.js.map +1 -0
- package/transpiled/Reporter.d.ts +44 -0
- package/transpiled/Reporter.js +187 -0
- package/transpiled/Reporter.js.map +1 -0
- package/transpiled/Tenant.d.ts +18 -0
- package/transpiled/Tenant.js +40 -0
- package/transpiled/Tenant.js.map +1 -0
- package/transpiled/UI.d.ts +25 -0
- package/transpiled/UI.js +186 -0
- package/transpiled/UI.js.map +1 -0
- package/transpiled/annotation.d.ts +40 -0
- package/transpiled/annotation.js +46 -0
- package/transpiled/annotation.js.map +1 -0
- package/transpiled/const.d.ts +23 -0
- package/transpiled/const.js +27 -0
- package/transpiled/const.js.map +1 -0
- package/transpiled/describe.d.ts +9 -0
- package/transpiled/describe.js +64 -0
- package/transpiled/describe.js.map +1 -0
- package/transpiled/extension.d.ts +11 -0
- package/transpiled/extension.js +64 -0
- package/transpiled/extension.js.map +1 -0
- package/transpiled/index.d.ts +6 -0
- package/transpiled/index.js +25 -0
- package/transpiled/index.js.map +1 -0
- package/transpiled/keys.d.ts +9 -0
- package/transpiled/keys.js +0 -0
- package/transpiled/keys.js.map +1 -0
- package/transpiled/manifest.d.ts +9 -0
- package/transpiled/manifest.js +43 -0
- package/transpiled/manifest.js.map +1 -0
- package/transpiled/model.d.ts +71 -0
- package/transpiled/model.js +3 -0
- package/transpiled/model.js.map +1 -0
- package/transpiled/sample.d.ts +7 -0
- package/transpiled/sample.js +44 -0
- package/transpiled/sample.js.map +1 -0
- package/transpiled/schemas.d.ts +4 -0
- package/transpiled/schemas.js +13 -0
- package/transpiled/schemas.js.map +1 -0
- package/transpiled/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { readdirSync } from 'node:fs'
|
|
4
|
+
import { setTimeout } from 'node:timers/promises'
|
|
5
|
+
import { MongoClient } from 'mongodb'
|
|
6
|
+
import { after, before, binding, then, when } from 'cucumber-tsflow'
|
|
7
|
+
import { parse } from '@toa.io/yaml'
|
|
8
|
+
import { match } from '@toa.io/generic'
|
|
9
|
+
import * as boot from '@toa.io/boot'
|
|
10
|
+
import * as stage from '@toa.io/userland/stage'
|
|
11
|
+
import { Factory } from '../../source'
|
|
12
|
+
import type { Component, Connector, Request } from '@toa.io/core'
|
|
13
|
+
|
|
14
|
+
@binding()
|
|
15
|
+
export class Map {
|
|
16
|
+
private service: Connector | null = null
|
|
17
|
+
private composition: Connector | null = null
|
|
18
|
+
private remotes: Record<string, Component> = {}
|
|
19
|
+
|
|
20
|
+
@when('the `{word}` is called with:')
|
|
21
|
+
public async call (endpoint: string, yaml: string): Promise<void> {
|
|
22
|
+
const request = parse<Request>(yaml)
|
|
23
|
+
const [operation, component, namespace = 'default'] = endpoint.split('.').reverse()
|
|
24
|
+
|
|
25
|
+
// an operation may throw, and that is exactly what one of the scenarios is about
|
|
26
|
+
await this.invoke(`${namespace}.${component}`, operation, request).catch(() => undefined)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@then('the map contains a node:')
|
|
30
|
+
public async node (yaml: string): Promise<void> {
|
|
31
|
+
await this.eventually('introspection.nodes', parse(yaml))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@then('the map contains an edge:')
|
|
35
|
+
public async edge (yaml: string): Promise<void> {
|
|
36
|
+
await this.eventually('introspection.edges', parse(yaml))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@then('the map contains no node:')
|
|
40
|
+
public async noNode (yaml: string): Promise<void> {
|
|
41
|
+
await this.never('introspection.nodes', parse(yaml))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@then('the map contains no edge:')
|
|
45
|
+
public async noEdge (yaml: string): Promise<void> {
|
|
46
|
+
await this.never('introspection.edges', parse(yaml))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@when('the components are stopped')
|
|
50
|
+
public async stop (): Promise<void> {
|
|
51
|
+
await this.composition?.disconnect()
|
|
52
|
+
|
|
53
|
+
this.composition = null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@before('not @ui')
|
|
57
|
+
public async run (): Promise<void> {
|
|
58
|
+
await clean()
|
|
59
|
+
|
|
60
|
+
this.service = new Factory(boot).service()!
|
|
61
|
+
|
|
62
|
+
await this.service.connect()
|
|
63
|
+
|
|
64
|
+
// booted here rather than through the stage, so that a scenario can stop it on its own
|
|
65
|
+
this.composition = await boot.composition(components())
|
|
66
|
+
|
|
67
|
+
await this.composition.connect()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@after('not @ui')
|
|
71
|
+
public async shutdown (): Promise<void> {
|
|
72
|
+
this.remotes = {}
|
|
73
|
+
|
|
74
|
+
await this.composition?.disconnect()
|
|
75
|
+
await stage.shutdown()
|
|
76
|
+
await this.service?.disconnect()
|
|
77
|
+
|
|
78
|
+
this.composition = null
|
|
79
|
+
this.service = null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private async invoke (id: string, operation: string, request: Request): Promise<unknown> {
|
|
83
|
+
this.remotes[id] ??= await stage.remote(id)
|
|
84
|
+
|
|
85
|
+
return await this.remotes[id].invoke(operation, request)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The map is eventually consistent: the collector buffers, flushes, and the task is queued. */
|
|
89
|
+
private async eventually (id: string, expected: object): Promise<void> {
|
|
90
|
+
const deadline = Date.now() + DEADLINE
|
|
91
|
+
|
|
92
|
+
let records: any[] = []
|
|
93
|
+
|
|
94
|
+
while (Date.now() < deadline) {
|
|
95
|
+
records = await this.list(id)
|
|
96
|
+
|
|
97
|
+
if (records.some((record) => match(record, expected)))
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
await setTimeout(POLL)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
assert.fail(`No record in '${id}' matches\n${JSON.stringify(expected, null, 2)}\n\n` +
|
|
104
|
+
`Present:\n${JSON.stringify(records, null, 2)}`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Absence has to outlive a flush, otherwise it only proves the map is slow. */
|
|
108
|
+
private async never (id: string, expected: object): Promise<void> {
|
|
109
|
+
await setTimeout(SETTLE)
|
|
110
|
+
|
|
111
|
+
const records = await this.list(id)
|
|
112
|
+
const found = records.find((record) => match(record, expected))
|
|
113
|
+
|
|
114
|
+
assert.equal(found, undefined,
|
|
115
|
+
`Unexpected record in '${id}':\n${JSON.stringify(found, null, 2)}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async list (id: string): Promise<any[]> {
|
|
119
|
+
return await this.invoke(id, 'enumerate', { query: { limit: 100 } }) as any[]
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function clean (): Promise<void> {
|
|
124
|
+
const client = new MongoClient(URL)
|
|
125
|
+
|
|
126
|
+
await client.connect()
|
|
127
|
+
|
|
128
|
+
const db = client.db(DB)
|
|
129
|
+
|
|
130
|
+
await Promise.all([
|
|
131
|
+
db.collection('introspection_nodes').deleteMany({}),
|
|
132
|
+
db.collection('introspection_edges').deleteMany({})
|
|
133
|
+
])
|
|
134
|
+
|
|
135
|
+
await client.close()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function components (): string[] {
|
|
139
|
+
const entries = readdirSync(ROOT, { withFileTypes: true })
|
|
140
|
+
|
|
141
|
+
return entries
|
|
142
|
+
.filter((entry) => entry.isDirectory())
|
|
143
|
+
.map((entry) => resolve(ROOT, entry.name))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ROOT = resolve(__dirname, 'components')
|
|
147
|
+
const URL = 'mongodb://developer:secret@localhost:27017'
|
|
148
|
+
const DB = 'toa-dev'
|
|
149
|
+
const DEADLINE = 20_000
|
|
150
|
+
const POLL = 250
|
|
151
|
+
const SETTLE = 4_000
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from 'node:assert'
|
|
2
|
+
import * as http from 'node:http'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { after, binding, given, then, when } from 'cucumber-tsflow'
|
|
5
|
+
import { UI } from '../../source/UI'
|
|
6
|
+
import { UI_PORT } from '../../source/const'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The scenarios are about the server, not about the page: it is pointed at a fixture
|
|
10
|
+
* directory, so that running them never needs a UI build.
|
|
11
|
+
*/
|
|
12
|
+
@binding()
|
|
13
|
+
export class Site {
|
|
14
|
+
private server: UI | null = null
|
|
15
|
+
private response: Response | null = null
|
|
16
|
+
|
|
17
|
+
@given('the UI is published')
|
|
18
|
+
public async publish (): Promise<void> {
|
|
19
|
+
this.server = new UI(UI_PORT, resolve(__dirname, '..', 'site'))
|
|
20
|
+
|
|
21
|
+
await this.server.connect()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@when('{string} is requested')
|
|
25
|
+
public async request (path: string): Promise<void> {
|
|
26
|
+
this.response = await get(path)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@then('the status is {int}')
|
|
30
|
+
public statusIs (status: number): void {
|
|
31
|
+
assert.equal(this.response?.status, status)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@then('the body contains {string}')
|
|
35
|
+
public bodyContains (text: string): void {
|
|
36
|
+
assert.ok(this.response?.body.includes(text),
|
|
37
|
+
`Expected the body to contain '${text}', got '${this.response?.body}'`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@then('the body is empty')
|
|
41
|
+
public bodyIsEmpty (): void {
|
|
42
|
+
assert.equal(this.response?.body, '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@then('the {string} header is {string}')
|
|
46
|
+
public headerIs (name: string, value: string): void {
|
|
47
|
+
assert.equal(this.response?.headers[name], value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@after()
|
|
51
|
+
public async shutdown (): Promise<void> {
|
|
52
|
+
await this.server?.disconnect()
|
|
53
|
+
|
|
54
|
+
this.server = null
|
|
55
|
+
this.response = null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Raw `http`, not `fetch`: the URL parser normalizes `%2e%2e` away, and one of the
|
|
61
|
+
* scenarios is about exactly that segment reaching the server.
|
|
62
|
+
*/
|
|
63
|
+
async function get (path: string): Promise<Response> {
|
|
64
|
+
return await new Promise((resolve, reject) => {
|
|
65
|
+
// `agent: false` — a keep-alive socket outlives the scenario that opened it
|
|
66
|
+
const request = http.get({ port: UI_PORT, path, agent: false }, (response) => {
|
|
67
|
+
let body = ''
|
|
68
|
+
|
|
69
|
+
response.setEncoding('utf8')
|
|
70
|
+
response.on('data', (chunk: string) => (body += chunk))
|
|
71
|
+
response.on('end', () => {
|
|
72
|
+
resolve({
|
|
73
|
+
status: response.statusCode!,
|
|
74
|
+
headers: response.headers as Record<string, string>,
|
|
75
|
+
body
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
request.on('error', reject)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Response {
|
|
85
|
+
status: number
|
|
86
|
+
headers: Record<string, string>
|
|
87
|
+
body: string
|
|
88
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
namespace: probe
|
|
2
|
+
name: source
|
|
3
|
+
|
|
4
|
+
entity:
|
|
5
|
+
associated: true
|
|
6
|
+
storage: null
|
|
7
|
+
schema:
|
|
8
|
+
value:
|
|
9
|
+
type: integer
|
|
10
|
+
default: 0
|
|
11
|
+
secret: string
|
|
12
|
+
|
|
13
|
+
operations:
|
|
14
|
+
transit:
|
|
15
|
+
concurrency: retry
|
|
16
|
+
input:
|
|
17
|
+
value: .
|
|
18
|
+
secret: .
|
|
19
|
+
relay:
|
|
20
|
+
input:
|
|
21
|
+
a: number
|
|
22
|
+
b: number
|
|
23
|
+
output: number
|
|
24
|
+
crash: {}
|
|
25
|
+
fail:
|
|
26
|
+
errors:
|
|
27
|
+
- NOPE
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
namespace: probe
|
|
2
|
+
name: target
|
|
3
|
+
|
|
4
|
+
entity:
|
|
5
|
+
storage: null
|
|
6
|
+
schema:
|
|
7
|
+
counted:
|
|
8
|
+
type: integer
|
|
9
|
+
default: 0
|
|
10
|
+
|
|
11
|
+
operations:
|
|
12
|
+
compute:
|
|
13
|
+
input:
|
|
14
|
+
a: number
|
|
15
|
+
b: number
|
|
16
|
+
output: number
|
|
17
|
+
count:
|
|
18
|
+
concurrency: retry
|
|
19
|
+
input:
|
|
20
|
+
type: object
|
|
21
|
+
additionalProperties: true
|
|
22
|
+
|
|
23
|
+
receivers:
|
|
24
|
+
probe.source.created: count
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { setDefaultTimeout } from '@cucumber/cucumber'
|
|
2
|
+
import { encode } from '@toa.io/generic'
|
|
3
|
+
|
|
4
|
+
process.env.TOA_DEV = '1'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The extension factory reads the environment once, and the bootloader caches
|
|
8
|
+
* factories per process — so the context level of the annotation is fixed for
|
|
9
|
+
* a whole run. `npm run features` therefore makes a pass per configuration.
|
|
10
|
+
*/
|
|
11
|
+
process.env.TOA_INTROSPECTION = encode({
|
|
12
|
+
samples: process.env.TOA_INTROSPECTION_SAMPLES === '1',
|
|
13
|
+
interval: Number(process.env.TOA_INTROSPECTION_INTERVAL ?? 1),
|
|
14
|
+
threshold: 64,
|
|
15
|
+
|
|
16
|
+
// `ui.feature` starts its own server; the explorer must not take the port first
|
|
17
|
+
ui: false
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
setDefaultTimeout(60 * 1000)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
@ui
|
|
2
|
+
Feature: Publishing the UI
|
|
3
|
+
|
|
4
|
+
The explorer serves the UI on its own port, out of the directory `ui` builds. The
|
|
5
|
+
mount path is baked into the bundle and forwarded by the ingress, so the server
|
|
6
|
+
routes relative to it.
|
|
7
|
+
|
|
8
|
+
Scenario: The root is the way in
|
|
9
|
+
Given the UI is published
|
|
10
|
+
When "/" is requested
|
|
11
|
+
Then the status is 302
|
|
12
|
+
And the "location" header is "/.introspection/"
|
|
13
|
+
|
|
14
|
+
Scenario: The UI answers at its mount path
|
|
15
|
+
Given the UI is published
|
|
16
|
+
When "/.introspection" is requested
|
|
17
|
+
Then the status is 200
|
|
18
|
+
And the body contains "the page"
|
|
19
|
+
|
|
20
|
+
Scenario: A trailing slash is the same resource
|
|
21
|
+
Given the UI is published
|
|
22
|
+
When "/.introspection/" is requested
|
|
23
|
+
Then the status is 200
|
|
24
|
+
And the body contains "the page"
|
|
25
|
+
|
|
26
|
+
Scenario: A query string does not change the resource
|
|
27
|
+
Given the UI is published
|
|
28
|
+
When "/.introspection?tab=edges" is requested
|
|
29
|
+
Then the status is 200
|
|
30
|
+
And the body contains "the page"
|
|
31
|
+
|
|
32
|
+
Scenario: An unknown route is the page
|
|
33
|
+
The client router knows what routes there are; this server does not.
|
|
34
|
+
|
|
35
|
+
Given the UI is published
|
|
36
|
+
When "/.introspection/whatever" is requested
|
|
37
|
+
Then the status is 200
|
|
38
|
+
And the body contains "the page"
|
|
39
|
+
|
|
40
|
+
Scenario: A route that looks like a file is still a route
|
|
41
|
+
A component is named `namespace.component`, so the address of one carries a dot and
|
|
42
|
+
an extension is no way to tell an asset from a route.
|
|
43
|
+
|
|
44
|
+
Given the UI is published
|
|
45
|
+
When "/.introspection/identity.passkeys/" is requested
|
|
46
|
+
Then the status is 200
|
|
47
|
+
And the body contains "the page"
|
|
48
|
+
|
|
49
|
+
Scenario: And with no trailing slash to give it away
|
|
50
|
+
Given the UI is published
|
|
51
|
+
When "/.introspection/identity.passkeys" is requested
|
|
52
|
+
Then the status is 200
|
|
53
|
+
And the body contains "the page"
|
|
54
|
+
|
|
55
|
+
Scenario: A hashed asset is served forever
|
|
56
|
+
Given the UI is published
|
|
57
|
+
When "/.introspection/_app/immutable/asset.js" is requested
|
|
58
|
+
Then the status is 200
|
|
59
|
+
And the "content-type" header is "text/javascript; charset=utf-8"
|
|
60
|
+
And the "cache-control" header is "public, max-age=31536000, immutable"
|
|
61
|
+
|
|
62
|
+
Scenario: The page itself is never cached
|
|
63
|
+
Given the UI is published
|
|
64
|
+
When "/.introspection/" is requested
|
|
65
|
+
Then the "cache-control" header is "no-cache"
|
|
66
|
+
|
|
67
|
+
Scenario: A missing asset is missing
|
|
68
|
+
Given the UI is published
|
|
69
|
+
When "/.introspection/_app/immutable/absent.js" is requested
|
|
70
|
+
Then the status is 404
|
|
71
|
+
|
|
72
|
+
Scenario: The path of the map API is not served by the UI
|
|
73
|
+
Given the UI is published
|
|
74
|
+
When "/introspection/nodes/" is requested
|
|
75
|
+
Then the status is 404
|
|
76
|
+
|
|
77
|
+
Scenario: A path that only looks like the mount path is absent
|
|
78
|
+
Given the UI is published
|
|
79
|
+
When "/.introspectionable" is requested
|
|
80
|
+
Then the status is 404
|
|
81
|
+
|
|
82
|
+
Scenario: Nothing outside the site is reachable
|
|
83
|
+
Given the UI is published
|
|
84
|
+
When "/.introspection/%2e%2e/%2e%2e/package.json" is requested
|
|
85
|
+
Then the status is 404
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toa.io/extensions.introspection",
|
|
3
|
+
"version": "1.0.0-alpha.259",
|
|
4
|
+
"description": "Toa Introspection",
|
|
5
|
+
"author": "temich <tema.gurtovoy@gmail.com>",
|
|
6
|
+
"homepage": "https://github.com/toa-io/toa#readme",
|
|
7
|
+
"main": "transpiled/index.js",
|
|
8
|
+
"types": "transpiled/index.d.ts",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/toa-io/toa.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/toa-io/toa/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@toa.io/core": "1.0.0-alpha.259",
|
|
21
|
+
"@toa.io/generic": "1.0.0-alpha.254",
|
|
22
|
+
"@toa.io/schemas": "1.0.0-alpha.254",
|
|
23
|
+
"openspan": "1.0.0-alpha.254"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"transpile": "npx tsc && npx tsc -p ./components/introspection.nodes && npx tsc -p ./components/introspection.edges",
|
|
27
|
+
"build:ui": "npm --prefix ui ci && npm --prefix ui run build",
|
|
28
|
+
"prepack": "npm run build:ui",
|
|
29
|
+
"features": "npx cucumber-js --tags 'not @samples and not @shutdown' && TOA_INTROSPECTION_SAMPLES=1 npx cucumber-js --tags @samples && TOA_INTROSPECTION_INTERVAL=3600 npx cucumber-js --tags @shutdown"
|
|
30
|
+
},
|
|
31
|
+
"gitHead": "78bb1ba551bea0579ed61a2270dbc37536b12cbb"
|
|
32
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Toa Introspection
|
|
2
|
+
|
|
3
|
+
Introspection collects information about a product's topology and presents it as a graph of
|
|
4
|
+
components and their interactions.
|
|
5
|
+
|
|
6
|
+
The topology includes:
|
|
7
|
+
|
|
8
|
+
- components and their entities, operations, events, and receivers;
|
|
9
|
+
- declared event relations between components;
|
|
10
|
+
- calls observed between components and services at runtime;
|
|
11
|
+
- input and outcome samples for observed calls, when sampling is enabled.
|
|
12
|
+
|
|
13
|
+
This provides a single view of both the product's declared structure and the communication paths
|
|
14
|
+
that are exercised at runtime. The topology is available through a web UI and an HTTP API.
|
|
15
|
+
|
|
16
|
+
Introspection is enabled by default for every component in a composition.
|
|
17
|
+
|
|
18
|
+
## Configuration
|
|
19
|
+
|
|
20
|
+
Configure Introspection in `context.toa.yaml`:
|
|
21
|
+
|
|
22
|
+
```yaml
|
|
23
|
+
introspection:
|
|
24
|
+
samples: false
|
|
25
|
+
interval: 300
|
|
26
|
+
threshold: 1024
|
|
27
|
+
ui: true
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
All properties are optional.
|
|
31
|
+
|
|
32
|
+
| Property | Default | Description |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| `samples` | `false` | Enables collection of call input and outcome samples. |
|
|
35
|
+
| `interval` | `300` | Interval between topology updates, in seconds. |
|
|
36
|
+
| `threshold` | `1024` | Number of distinct observed interactions that triggers an update before the interval expires. |
|
|
37
|
+
| `ui` | `true` | Publishes the web UI. |
|
|
38
|
+
| `resources` | — | Resource requirements for the Introspection deployment. |
|
|
39
|
+
|
|
40
|
+
Resource requirements can be declared specifically for Introspection:
|
|
41
|
+
|
|
42
|
+
```yaml
|
|
43
|
+
introspection:
|
|
44
|
+
resources:
|
|
45
|
+
cpu: [100m, 500m]
|
|
46
|
+
memory: [128Mi, 256Mi]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If omitted, Introspection uses the context-level `resources` declaration. Set `resources: null` to
|
|
50
|
+
run without resource requirements.
|
|
51
|
+
|
|
52
|
+
Disable Introspection for the entire context:
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
introspection: false
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Introspection requires the [Exposition](../exposition) extension to publish its API. A context that
|
|
59
|
+
does not use Exposition must disable Introspection.
|
|
60
|
+
|
|
61
|
+
### Component configuration
|
|
62
|
+
|
|
63
|
+
Sampling can be prohibited for an individual component in `manifest.toa.yaml`:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
introspection:
|
|
67
|
+
samples: false
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This restriction cannot be overridden by the context configuration.
|
|
71
|
+
|
|
72
|
+
Exclude a component from the topology entirely:
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
introspection: false
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Samples
|
|
79
|
+
|
|
80
|
+
A sample contains the input and outcome of an observed call. Sampling is disabled by default
|
|
81
|
+
because call inputs may contain production or personal data.
|
|
82
|
+
|
|
83
|
+
Samples are collected only when they are enabled in the context and not prohibited by the target
|
|
84
|
+
component. Payloads in the `identity` namespace are never collected. Properties whose names match
|
|
85
|
+
known secret patterns are redacted, streams are excluded, and oversized values are replaced with a
|
|
86
|
+
truncation marker.
|
|
87
|
+
|
|
88
|
+
Components that process sensitive data should disable sampling explicitly in their manifests.
|
|
89
|
+
|
|
90
|
+
## Web UI
|
|
91
|
+
|
|
92
|
+
The web UI visualizes the product topology and is enabled by default. It is published at
|
|
93
|
+
`/.introspection/` on the hosts declared in the context:
|
|
94
|
+
|
|
95
|
+
```yaml
|
|
96
|
+
ingress:
|
|
97
|
+
hosts:
|
|
98
|
+
- api.example.com
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Open the following URL after deploying the composition:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
https://api.example.com/.introspection/
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The configured host must also be served by Exposition because the UI reads topology data from the
|
|
108
|
+
Introspection API on the same origin.
|
|
109
|
+
|
|
110
|
+
Access to topology data requires the `system:introspection` role. The UI page itself is public, but
|
|
111
|
+
it cannot display topology data without an authorized API session.
|
|
112
|
+
|
|
113
|
+
To collect topology data without publishing the UI:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
introspection:
|
|
117
|
+
ui: false
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## HTTP API
|
|
121
|
+
|
|
122
|
+
The topology is available through the following endpoints:
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
GET /introspection/nodes/
|
|
126
|
+
GET /introspection/nodes/:id/
|
|
127
|
+
GET /introspection/edges/
|
|
128
|
+
GET /introspection/edges/:id/
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Nodes describe components and their declared interfaces. Edges represent calls observed between
|
|
132
|
+
components or services. Both endpoint groups support listing all records and retrieving an
|
|
133
|
+
individual record by its identifier.
|
|
134
|
+
|
|
135
|
+
API access requires the `system:introspection` role.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# context.toa.yaml introspection annotation
|
|
2
|
+
samples: boolean
|
|
3
|
+
interval:
|
|
4
|
+
type: integer
|
|
5
|
+
minimum: 1
|
|
6
|
+
threshold:
|
|
7
|
+
type: integer
|
|
8
|
+
minimum: 1
|
|
9
|
+
ui: boolean
|
|
10
|
+
resources:
|
|
11
|
+
# `null` deploys the service without any, which every deployment has to state one way
|
|
12
|
+
# or the other — see `migrations/263.md`.
|
|
13
|
+
type: object
|
|
14
|
+
nullable: true
|
|
15
|
+
properties:
|
|
16
|
+
cpu: &constraint
|
|
17
|
+
type: array
|
|
18
|
+
items: { type: string }
|
|
19
|
+
minItems: 2
|
|
20
|
+
maxItems: 2
|
|
21
|
+
memory: *constraint
|