@shortlink-org/portolan 0.3.0 → 0.4.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/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { newestMtime, pluginsFresh } from "./plugins-fresh.mjs";
|
|
7
|
+
|
|
8
|
+
const roots = [];
|
|
9
|
+
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
|
10
|
+
|
|
11
|
+
function at(path, seconds) {
|
|
12
|
+
utimesSync(path, seconds, seconds);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A workspace with a built wasm and Java classes, all stamped at `built`, over sources stamped at `source`. */
|
|
16
|
+
function workspace({ source, built }) {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), "portolan-fresh-"));
|
|
18
|
+
roots.push(root);
|
|
19
|
+
for (const dir of ["plugins/verify-otel", "plugins/extract-java/src/org", "plugins/extract-java/build/org", "catalog", "plugin"]) mkdirSync(join(root, dir), { recursive: true });
|
|
20
|
+
const files = {
|
|
21
|
+
"go.mod": source, "plugins/verify-otel/verify.go": source, "catalog/model.go": source, "plugin/protocol.go": source,
|
|
22
|
+
"plugins/extract-java/src/org/Main.java": source,
|
|
23
|
+
"plugins/portolan-go.wasm": built, "plugins/extract-java/build/org/Main.class": built,
|
|
24
|
+
};
|
|
25
|
+
for (const [name, stamp] of Object.entries(files)) {
|
|
26
|
+
writeFileSync(join(root, name), "x");
|
|
27
|
+
at(join(root, name), stamp);
|
|
28
|
+
}
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("whether the built plugins are newer than their sources", () => {
|
|
33
|
+
it("is yes when every artefact is at least as new as every source", () => {
|
|
34
|
+
expect(pluginsFresh(workspace({ source: 1000, built: 2000 }))).toBe(true);
|
|
35
|
+
expect(pluginsFresh(workspace({ source: 2000, built: 2000 }))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is no when a Go source, a Java source, or go.mod is newer, or an artefact is missing", () => {
|
|
39
|
+
const go = workspace({ source: 1000, built: 2000 });
|
|
40
|
+
at(join(go, "catalog/model.go"), 3000);
|
|
41
|
+
expect(pluginsFresh(go)).toBe(false);
|
|
42
|
+
|
|
43
|
+
const java = workspace({ source: 1000, built: 2000 });
|
|
44
|
+
at(join(java, "plugins/extract-java/src/org/Main.java"), 3000);
|
|
45
|
+
expect(pluginsFresh(java)).toBe(false);
|
|
46
|
+
|
|
47
|
+
const mod = workspace({ source: 1000, built: 2000 });
|
|
48
|
+
at(join(mod, "go.mod"), 3000);
|
|
49
|
+
expect(pluginsFresh(mod)).toBe(false);
|
|
50
|
+
|
|
51
|
+
const missing = workspace({ source: 1000, built: 2000 });
|
|
52
|
+
rmSync(join(missing, "plugins/portolan-go.wasm"));
|
|
53
|
+
expect(pluginsFresh(missing)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("leaves a plugin's own build output and test data out of the sources", () => {
|
|
57
|
+
const root = workspace({ source: 1000, built: 2000 });
|
|
58
|
+
mkdirSync(join(root, "plugins/extract-rust/target"), { recursive: true });
|
|
59
|
+
writeFileSync(join(root, "plugins/extract-rust/target/out.go"), "x");
|
|
60
|
+
at(join(root, "plugins/extract-rust/target/out.go"), 5000);
|
|
61
|
+
expect(pluginsFresh(root)).toBe(true);
|
|
62
|
+
expect(newestMtime(join(root, "plugins"), (path) => path.endsWith(".go"))).toBe(1000 * 1000);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/scripts/run-builtin.mjs
CHANGED
|
@@ -27,8 +27,9 @@ if (!plugin?.process) {
|
|
|
27
27
|
const command = plugin.process.command;
|
|
28
28
|
const args = [...(plugin.process.args ?? [])];
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const prebuilt = command === "cargo" ? prebuiltCargoBinary(args) : "";
|
|
31
|
+
if (prebuilt) {
|
|
32
|
+
run(prebuilt, []);
|
|
32
33
|
} else if (command === "go") {
|
|
33
34
|
buildAndRunGo(args);
|
|
34
35
|
} else {
|
|
@@ -49,7 +50,14 @@ if (command === "cargo" && existsSync(resolve(installRoot, "plugins/extract-rust
|
|
|
49
50
|
// describe/extract calls cheap; rebuilding also prevents a stale sidecar after
|
|
50
51
|
// an npm upgrade with the same workspace cache.
|
|
51
52
|
function buildAndRunGo(argv) {
|
|
52
|
-
|
|
53
|
+
// `go run [build flags] <package> [args]`: the build flags (-mod=mod, -tags)
|
|
54
|
+
// belong to the build, the package is what is built, the rest is the
|
|
55
|
+
// sidecar's own command line.
|
|
56
|
+
const flags = [];
|
|
57
|
+
let at = 1;
|
|
58
|
+
while (argv[0] === "run" && typeof argv[at] === "string" && argv[at].startsWith("-")) flags.push(argv[at++]);
|
|
59
|
+
const pkg = argv[at];
|
|
60
|
+
if (argv[0] !== "run" || typeof pkg !== "string" || !pkg) {
|
|
53
61
|
console.error(`portolan: built-in ${name} has an unsupported Go command`);
|
|
54
62
|
process.exitCode = 2;
|
|
55
63
|
return;
|
|
@@ -57,7 +65,7 @@ function buildAndRunGo(argv) {
|
|
|
57
65
|
const binDir = resolve(workspace, ".portolan", "bin", "go");
|
|
58
66
|
const executable = resolve(binDir, process.platform === "win32" ? `${name}.exe` : name);
|
|
59
67
|
mkdirSync(binDir, { recursive: true });
|
|
60
|
-
const built = spawnSync("go", ["build", "-mod=
|
|
68
|
+
const built = spawnSync("go", ["build", "-mod=mod", ...flags.filter((flag) => flag !== "-mod=mod"), "-o", executable, pkg], {
|
|
61
69
|
cwd: installRoot,
|
|
62
70
|
env: { ...process.env, GOWORK: "off" },
|
|
63
71
|
stdio: "inherit",
|
|
@@ -71,12 +79,25 @@ function buildAndRunGo(argv) {
|
|
|
71
79
|
process.exitCode = built.status ?? 1;
|
|
72
80
|
return;
|
|
73
81
|
}
|
|
74
|
-
run(executable, argv.slice(
|
|
82
|
+
run(executable, argv.slice(at + 1));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A Rust plugin already built in release mode under its own crate directory,
|
|
86
|
+
// `plugins/extract-rust/target/release/portolan-extract-rust`, is run as it
|
|
87
|
+
// is; the crate is named after its directory, which is what the manifest
|
|
88
|
+
// path names.
|
|
89
|
+
function prebuiltCargoBinary(argv) {
|
|
90
|
+
const manifest = argv[argv.indexOf("--manifest-path") + 1];
|
|
91
|
+
if (!manifest || argv.indexOf("--manifest-path") < 0) return "";
|
|
92
|
+
const crate = dirname(manifest).split("/").pop();
|
|
93
|
+
const binary = resolve(installRoot, dirname(manifest), "target", "release", `portolan-${crate}`);
|
|
94
|
+
return existsSync(binary) ? binary : "";
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
function resolveArgument(executable, index) {
|
|
78
98
|
if (["node", "python", "python3"].includes(executable)) return index === 0;
|
|
79
99
|
if (executable === "java") return index === 1;
|
|
100
|
+
if (executable === "dotnet") return index === 0;
|
|
80
101
|
if (executable === "cargo") return index === 3;
|
|
81
102
|
return false;
|
|
82
103
|
}
|
package/scripts/schema.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Composes the JSON Schema for portolan.json out of what the plugins say about
|
|
2
2
|
// themselves.
|
|
3
3
|
//
|
|
4
|
-
// node scripts/schema.mjs write schema/portolan.schema.json
|
|
5
|
-
//
|
|
4
|
+
// node scripts/schema.mjs write schema/portolan.schema.json and
|
|
5
|
+
// src/lib/plugin-index.json
|
|
6
|
+
// node scripts/schema.mjs --check fail if either file on disk is not what
|
|
6
7
|
// the plugins describe now
|
|
7
8
|
//
|
|
8
9
|
// The manifest tells each plugin the things its source cannot carry - which
|
|
@@ -19,8 +20,14 @@
|
|
|
19
20
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
21
|
|
|
21
22
|
import { describePlugin } from "./plugin-host.mjs";
|
|
23
|
+
import { RULE_ID, SEVERITIES, SUBJECT_NAMES, SUBJECTS } from "../src/lib/problem-rules-cel.mjs";
|
|
24
|
+
|
|
25
|
+
// The built-in problem rules, so the schema can say which ids are switches
|
|
26
|
+
// and which must carry a whole rule.
|
|
27
|
+
const BUILTIN_RULES = JSON.parse(readFileSync("rules/builtin.json", "utf8"));
|
|
22
28
|
|
|
23
29
|
const OUT = "schema/portolan.schema.json";
|
|
30
|
+
const INDEX = "src/lib/plugin-index.json";
|
|
24
31
|
|
|
25
32
|
const check = process.argv.includes("--check");
|
|
26
33
|
// Bootstrap exception: this command produces the schema that loadManifest
|
|
@@ -51,25 +58,65 @@ for (const plugin of manifest.plugins ?? []) {
|
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
const composed = `${JSON.stringify(compose(), null, 2)}\n`;
|
|
61
|
+
const index = `${JSON.stringify(pluginIndex(), null, 2)}\n`;
|
|
62
|
+
|
|
63
|
+
let stale = false;
|
|
64
|
+
for (const [path, contents, directory] of [
|
|
65
|
+
[OUT, composed, "schema"],
|
|
66
|
+
[INDEX, index, "src/lib"],
|
|
67
|
+
]) {
|
|
68
|
+
let current = null;
|
|
69
|
+
try {
|
|
70
|
+
current = readFileSync(path, "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
// Absent, which is the same as out of date.
|
|
73
|
+
}
|
|
54
74
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
if (current === contents) {
|
|
76
|
+
console.log(`${path}: up to date`);
|
|
77
|
+
} else if (check) {
|
|
78
|
+
console.error(
|
|
79
|
+
`${path} is ${current === null ? "missing" : "out of date"}. Run \`npm run schema\`.`,
|
|
80
|
+
);
|
|
81
|
+
stale = true;
|
|
82
|
+
} else {
|
|
83
|
+
mkdirSync(directory, { recursive: true });
|
|
84
|
+
writeFileSync(path, contents);
|
|
85
|
+
console.log(`${path}: written from ${described.size} plugin${described.size === 1 ? "" : "s"}`);
|
|
86
|
+
}
|
|
60
87
|
}
|
|
88
|
+
if (stale) process.exit(1);
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The plugin index: every plugin the package ships, as it describes itself,
|
|
92
|
+
* with how the manifest runs it. The site's /plugins page and the landing's
|
|
93
|
+
* list of inputs are rendered from this, so a plugin added to portolan.json
|
|
94
|
+
* appears there without anyone writing it down twice. Kept under src/ rather
|
|
95
|
+
* than beside the schema because the staged site copies src/ and nothing else.
|
|
96
|
+
*/
|
|
97
|
+
function pluginIndex() {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const plugin of manifest.plugins ?? []) {
|
|
100
|
+
const descriptor = described.get(plugin.name);
|
|
101
|
+
if (!descriptor) continue;
|
|
102
|
+
out.push({
|
|
103
|
+
name: plugin.name,
|
|
104
|
+
plugin: descriptor.name,
|
|
105
|
+
summary: descriptor.summary,
|
|
106
|
+
category: descriptor.category,
|
|
107
|
+
phases: descriptor.phases,
|
|
108
|
+
...(descriptor.needs?.length ? { needs: descriptor.needs } : {}),
|
|
109
|
+
runtime: plugin.wasm ? "wasm" : plugin.host ? "host" : "process",
|
|
110
|
+
// The toolchain a process plugin asks the build for; the sandboxed
|
|
111
|
+
// module and the host's own code ask for nothing.
|
|
112
|
+
...(plugin.process ? { toolchain: plugin.process.command } : {}),
|
|
113
|
+
source: plugin.host
|
|
114
|
+
? `scripts/host-plugins/${plugin.host}.mjs`
|
|
115
|
+
: `plugins/${descriptor.name}`,
|
|
116
|
+
options: descriptor.options ?? {},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
73
120
|
}
|
|
74
121
|
|
|
75
122
|
function compose() {
|
|
@@ -123,6 +170,13 @@ function compose() {
|
|
|
123
170
|
description:
|
|
124
171
|
"CEL policies for reviewed extraction limitations. Expressions are type-checked when the manifest is read and suppression always requires a reason.",
|
|
125
172
|
},
|
|
173
|
+
problemRules: {
|
|
174
|
+
type: "array",
|
|
175
|
+
maxItems: 200,
|
|
176
|
+
items: { $ref: "#/$defs/problemRule" },
|
|
177
|
+
description:
|
|
178
|
+
"The rules the Problems page applies. An entry naming a built-in rule (rules/builtin.json) switches it off or re-grades it, with a reason; any other id is a rule of its own, written in CEL over one subject. Expressions are type-checked when the manifest is read and run in the page over the merged catalog.",
|
|
179
|
+
},
|
|
126
180
|
plugins: {
|
|
127
181
|
type: "array",
|
|
128
182
|
items: { $ref: "#/$defs/plugin" },
|
|
@@ -146,6 +200,7 @@ function compose() {
|
|
|
146
200
|
},
|
|
147
201
|
},
|
|
148
202
|
$defs: {
|
|
203
|
+
problemRule: problemRuleSchema(),
|
|
149
204
|
warningPolicy: {
|
|
150
205
|
type: "object",
|
|
151
206
|
additionalProperties: false,
|
|
@@ -273,7 +328,7 @@ function compose() {
|
|
|
273
328
|
description: "What the steps below call this plugin. It need not be the plugin's own name.",
|
|
274
329
|
},
|
|
275
330
|
host: {
|
|
276
|
-
enum: ["fetch-git", "fetch-bsr", "fetch-csr"],
|
|
331
|
+
enum: ["fetch-git", "fetch-bsr", "fetch-csr", "fetch-k8s", "fetch-argocd"],
|
|
277
332
|
description:
|
|
278
333
|
"A plugin the host runs inside its own process, because it needs a socket or a git binary (portolan.0008). Only the ones shipped with Portolan can be named.",
|
|
279
334
|
},
|
|
@@ -400,3 +455,67 @@ function fail(message) {
|
|
|
400
455
|
console.error(`portolan schema: ${message}`);
|
|
401
456
|
process.exit(1);
|
|
402
457
|
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* One entry of `problemRules`. Two shapes under one id field: a built-in id
|
|
461
|
+
* may only be switched, re-graded and explained; any other id is a whole
|
|
462
|
+
* rule, over one subject, with the expressions the page runs.
|
|
463
|
+
*/
|
|
464
|
+
function problemRuleSchema() {
|
|
465
|
+
const builtinIds = BUILTIN_RULES.map((rule) => rule.id);
|
|
466
|
+
const subjectFields = SUBJECT_NAMES.map(
|
|
467
|
+
(name) => `${name}: ${Object.keys(SUBJECTS[name].schema).join(", ")}`,
|
|
468
|
+
).join("; ");
|
|
469
|
+
const expression = (returns, what) => ({
|
|
470
|
+
type: "string",
|
|
471
|
+
minLength: 1,
|
|
472
|
+
maxLength: 2000,
|
|
473
|
+
description: `${what} CEL returning ${returns}, over the subject named by \`over\` and \`estate\` (services, contexts, stores, channels, externals). Fields by subject: ${subjectFields}.`,
|
|
474
|
+
});
|
|
475
|
+
return {
|
|
476
|
+
type: "object",
|
|
477
|
+
additionalProperties: false,
|
|
478
|
+
required: ["id"],
|
|
479
|
+
properties: {
|
|
480
|
+
id: {
|
|
481
|
+
type: "string",
|
|
482
|
+
pattern: RULE_ID.source,
|
|
483
|
+
description: `The rule. One of the built-in ids - ${builtinIds.join(", ")} - to switch or re-grade that rule; any other id declares a rule of its own.`,
|
|
484
|
+
},
|
|
485
|
+
enabled: {
|
|
486
|
+
type: "boolean",
|
|
487
|
+
default: true,
|
|
488
|
+
description: "Whether the rule produces rows. A disabled rule needs a reason and stays listed on the Settings page.",
|
|
489
|
+
},
|
|
490
|
+
severity: {
|
|
491
|
+
enum: SEVERITIES,
|
|
492
|
+
description: "How wrong a row of this rule is. For a built-in rule this replaces the severity its passport was written with.",
|
|
493
|
+
},
|
|
494
|
+
reason: {
|
|
495
|
+
type: "string",
|
|
496
|
+
minLength: 1,
|
|
497
|
+
description: "Why the rule is off or re-graded, or, for a rule of your own, why the estate holds it.",
|
|
498
|
+
},
|
|
499
|
+
over: {
|
|
500
|
+
enum: SUBJECT_NAMES,
|
|
501
|
+
description: "What one row of the rule is about, and what `when` and `message` read.",
|
|
502
|
+
},
|
|
503
|
+
when: expression("bool", "The condition: a row exists for every subject where it holds."),
|
|
504
|
+
message: expression("string", "The note on the row."),
|
|
505
|
+
peer: expression("string", "The row's far end, when it has one; a service, event or table id links to its page."),
|
|
506
|
+
title: { type: "string", minLength: 1, description: "The rule's name on the Settings page." },
|
|
507
|
+
note: { type: "string", minLength: 1, description: "The short words on every row of the rule; the title when absent." },
|
|
508
|
+
description: { type: "string", description: "What the rule checks and why it matters." },
|
|
509
|
+
action: { type: "string", description: "What a reader does about a row." },
|
|
510
|
+
},
|
|
511
|
+
if: { properties: { id: { enum: builtinIds } } },
|
|
512
|
+
then: {
|
|
513
|
+
not: {
|
|
514
|
+
anyOf: ["over", "when", "message", "peer", "title", "note", "description", "action"].map((key) => ({
|
|
515
|
+
required: [key],
|
|
516
|
+
})),
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
else: { required: ["over", "when", "message", "title"] },
|
|
520
|
+
};
|
|
521
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// A recording uploaded from the page becomes a file beside the project and a
|
|
2
|
+
// verify step that reads it - nothing else. The pure half of that lives here,
|
|
3
|
+
// where a test can hold it: which step reads a project's recordings, where an
|
|
4
|
+
// upload lands, and what a trial run said about it once the verifier ran.
|
|
5
|
+
//
|
|
6
|
+
// The recording is kept in the repository on purpose (portolan.0014). The
|
|
7
|
+
// catalog is regenerated from files, by anyone, on any machine; a recording
|
|
8
|
+
// held anywhere else would verify a flow once and never again.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join, posix } from "node:path";
|
|
12
|
+
|
|
13
|
+
export const TRACE_PLUGIN = "otel";
|
|
14
|
+
export const RECORDINGS_DIR = "telemetry/recordings";
|
|
15
|
+
export const RECORDINGS_GLOB = `${RECORDINGS_DIR}/*.jsonl`;
|
|
16
|
+
export const UPLOAD_LIMIT = 32 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/** The verify step that reads a project's recordings, or null. */
|
|
19
|
+
export function traceStepFor(manifest, project) {
|
|
20
|
+
const steps = (manifest.verify ?? []).map((step, index) => ({ step, index }));
|
|
21
|
+
const mine = steps.filter(({ step }) => step.plugin === TRACE_PLUGIN && step.in === project.root);
|
|
22
|
+
return mine[0] ?? null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The manifest with a step that reads the project's recordings: the one it
|
|
27
|
+
* has, widened to the recordings directory when it does not look there yet,
|
|
28
|
+
* or a new one after the last verify step. Says whether anything changed.
|
|
29
|
+
*/
|
|
30
|
+
export function manifestWithTraceStep(manifest, project) {
|
|
31
|
+
const found = traceStepFor(manifest, project);
|
|
32
|
+
const verify = [...(manifest.verify ?? [])];
|
|
33
|
+
if (found) {
|
|
34
|
+
const traces = found.step.options?.traces ?? [];
|
|
35
|
+
if (traces.includes(RECORDINGS_GLOB)) return { manifest, changed: false, change: "none", step: found.step };
|
|
36
|
+
const step = { ...found.step, options: { ...found.step.options, traces: [...traces, RECORDINGS_GLOB] } };
|
|
37
|
+
verify[found.index] = step;
|
|
38
|
+
return { manifest: { ...manifest, verify }, changed: true, change: "widened", step };
|
|
39
|
+
}
|
|
40
|
+
const step = {
|
|
41
|
+
plugin: TRACE_PLUGIN,
|
|
42
|
+
in: project.root,
|
|
43
|
+
out: posix.join(project.root, "portolan"),
|
|
44
|
+
options: { traces: [RECORDINGS_GLOB], out: "observed.json" },
|
|
45
|
+
};
|
|
46
|
+
verify.push(step);
|
|
47
|
+
return { manifest: { ...manifest, verify }, changed: true, change: "added", step };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The step with the names the page was told to map, merged over what it had. */
|
|
51
|
+
export function stepWithMappings(step, { services, events, routes } = {}) {
|
|
52
|
+
const options = { ...step.options };
|
|
53
|
+
if (services && Object.keys(services).length) options.services = { ...options.services, ...services };
|
|
54
|
+
if (events && Object.keys(events).length) options.events = { ...options.events, ...events };
|
|
55
|
+
if (routes && Object.keys(routes).length) options.routes = { ...options.routes, ...routes };
|
|
56
|
+
return { ...step, options };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where an upload lands, relative to the project root: dated, named after
|
|
61
|
+
* the file it came as, and not on top of a recording already there.
|
|
62
|
+
*/
|
|
63
|
+
export function recordingPath(name, { today = new Date(), taken = () => false } = {}) {
|
|
64
|
+
const base = String(name ?? "").split(/[\\/]/).pop() ?? "";
|
|
65
|
+
const stem = base.replace(/\.(jsonl?|ndjson)$/i, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "recording";
|
|
66
|
+
const date = today.toISOString().slice(0, 10);
|
|
67
|
+
let candidate = `${RECORDINGS_DIR}/${date}-${stem}.jsonl`;
|
|
68
|
+
for (let n = 2; taken(candidate); n += 1) candidate = `${RECORDINGS_DIR}/${date}-${stem}-${n}.jsonl`;
|
|
69
|
+
return candidate;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Says whether the bytes are a recording the verifier can read: OTLP JSON,
|
|
74
|
+
* one batch per file or one per line, with resourceSpans in it.
|
|
75
|
+
*/
|
|
76
|
+
export function checkRecording(content) {
|
|
77
|
+
const text = content.toString("utf8");
|
|
78
|
+
if (!text.trim()) throw new Error("The recording is empty.");
|
|
79
|
+
let batches = 0;
|
|
80
|
+
let spans = 0;
|
|
81
|
+
const read = (value) => {
|
|
82
|
+
if (!value || typeof value !== "object" || !Array.isArray(value.resourceSpans)) throw new Error("The recording is JSON, but not OTLP: no resourceSpans in it.");
|
|
83
|
+
batches += 1;
|
|
84
|
+
for (const rs of value.resourceSpans) for (const ss of rs?.scopeSpans ?? []) spans += (ss?.spans ?? []).length;
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
read(JSON.parse(text));
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
if (cause instanceof SyntaxError) {
|
|
90
|
+
for (const line of text.split("\n")) {
|
|
91
|
+
if (!line.trim()) continue;
|
|
92
|
+
let value;
|
|
93
|
+
try { value = JSON.parse(line); } catch { throw new Error("The recording is not OTLP JSON: a collector's file exporter writes one batch per line."); }
|
|
94
|
+
read(value);
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
throw cause;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!spans) throw new Error("The recording has no spans in it.");
|
|
101
|
+
return { batches, spans };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** What a verifier's warning is about, so the page can offer the mapping it asks for. */
|
|
105
|
+
export function readVerifyWarning(message) {
|
|
106
|
+
let m = message.match(/spans from service\.name "([^"]+)" match no service/);
|
|
107
|
+
if (m) return { kind: "service", name: m[1], message };
|
|
108
|
+
m = message.match(/(?:publishes|consumes) "([^"]+)", which matches no event/);
|
|
109
|
+
if (m) return { kind: "event", name: m[1], message };
|
|
110
|
+
m = message.match(/answers on ([A-Z]* ?\S+), which no interface it provides declares/);
|
|
111
|
+
if (m) return { kind: "route", name: m[1].trim(), message };
|
|
112
|
+
m = message.match(/calls ([A-Z]+ \S+) on (\S+), which no service/);
|
|
113
|
+
if (m) return { kind: "call", name: m[1], message };
|
|
114
|
+
if (/but the catalog says it goes on/.test(message)) return { kind: "channel", message };
|
|
115
|
+
return { kind: "other", message };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What the trial run said about the recording: the flows the verifier wrote
|
|
120
|
+
* with it, and the warnings that name something the page could map.
|
|
121
|
+
*/
|
|
122
|
+
export function summarizeTraceTrial(snapshot, trial, events) {
|
|
123
|
+
const step = trial.step;
|
|
124
|
+
const finished = events.find((event) => event.type === "step-finished" && event.phase === "verify" && event.plugin === step.plugin && event.output === step.out && (event.input ?? step.in) === step.in)
|
|
125
|
+
?? events.find((event) => event.type === "step-finished" && event.phase === "verify" && event.plugin === step.plugin && event.output === step.out);
|
|
126
|
+
const warnings = (finished?.warnings ?? []).map(readVerifyWarning);
|
|
127
|
+
const fragmentName = step.options?.out ?? "observed.json";
|
|
128
|
+
let fragment = { flows: [] };
|
|
129
|
+
try { fragment = JSON.parse(readFileSync(join(snapshot, step.out, fragmentName), "utf8")); } catch {}
|
|
130
|
+
// An example names its recording relative to the repository, the way every
|
|
131
|
+
// `source` in the catalog does; the upload was laid under the step's input.
|
|
132
|
+
const recording = posix.join(step.in, trial.recording);
|
|
133
|
+
const flows = [];
|
|
134
|
+
for (const flow of fragment.flows ?? []) {
|
|
135
|
+
const examples = (flow.examples ?? []).filter((example) => example.recording === recording);
|
|
136
|
+
const steps = [];
|
|
137
|
+
const walk = (nodes) => {
|
|
138
|
+
for (const node of nodes ?? []) {
|
|
139
|
+
if (node.type === "step") steps.push(node);
|
|
140
|
+
else if (node.type === "alt") for (const branch of node.branches ?? []) walk(branch.steps);
|
|
141
|
+
else if (node.type === "parallel") for (const branch of node.branches ?? []) walk(branch);
|
|
142
|
+
else walk(node.steps);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
walk(flow.steps);
|
|
146
|
+
const shown = new Set(examples.flatMap((example) => example.steps.map((s) => s.step)));
|
|
147
|
+
const observed = String(flow.slug ?? "").startsWith("observed-");
|
|
148
|
+
flows.push({
|
|
149
|
+
id: flow.id,
|
|
150
|
+
slug: flow.slug,
|
|
151
|
+
name: flow.name,
|
|
152
|
+
owner: flow.owner,
|
|
153
|
+
kind: observed ? "observed" : "declared",
|
|
154
|
+
inRecording: examples.length > 0,
|
|
155
|
+
traces: examples.length,
|
|
156
|
+
verified: steps.filter((s) => s.status === "verified").length,
|
|
157
|
+
unresolved: steps.filter((s) => s.status === "unresolved").length,
|
|
158
|
+
added: steps.filter((s) => s.seen && !observed && /^seen\d+$/.test(String(s.id))).length,
|
|
159
|
+
shown: shown.size,
|
|
160
|
+
steps: steps.length,
|
|
161
|
+
examples: (flow.examples ?? []).length,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
flows.sort((a, b) => Number(b.inRecording) - Number(a.inRecording) || a.slug.localeCompare(b.slug));
|
|
165
|
+
return {
|
|
166
|
+
project: trial.projectId,
|
|
167
|
+
recording: trial.recording,
|
|
168
|
+
stepAdded: trial.stepAdded,
|
|
169
|
+
stepChange: trial.stepChange ?? (trial.stepAdded ? "added" : "none"),
|
|
170
|
+
status: finished?.status ?? "failed",
|
|
171
|
+
spans: trial.spans,
|
|
172
|
+
flows,
|
|
173
|
+
warnings,
|
|
174
|
+
mappings: { services: step.options?.services ?? {}, events: step.options?.events ?? {}, routes: step.options?.routes ?? {} },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { checkRecording, manifestWithTraceStep, readVerifyWarning, recordingPath, stepWithMappings, summarizeTraceTrial, traceStepFor } from "./trace-trials.mjs";
|
|
7
|
+
|
|
8
|
+
const project = { id: "auth", name: "Auth", root: "examples/auth", context: "auth", service: "auth" };
|
|
9
|
+
|
|
10
|
+
describe("the step that reads a project's recordings", () => {
|
|
11
|
+
it("is added after the last verify step when the project has none", () => {
|
|
12
|
+
const manifest = { sources: ["x/*.json"], projects: [project], verify: [{ plugin: "codeowners", in: ".github", out: "data" }] };
|
|
13
|
+
const next = manifestWithTraceStep(manifest, project);
|
|
14
|
+
|
|
15
|
+
expect(next.changed).toBe(true);
|
|
16
|
+
expect(next.change).toBe("added");
|
|
17
|
+
expect(next.manifest.verify).toHaveLength(2);
|
|
18
|
+
expect(next.step).toEqual({ plugin: "otel", in: "examples/auth", out: "examples/auth/portolan", options: { traces: ["telemetry/recordings/*.jsonl"], out: "observed.json" } });
|
|
19
|
+
expect(manifest.verify).toHaveLength(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is widened to the recordings directory when it reads elsewhere, and left alone when it already does", () => {
|
|
23
|
+
const step = { plugin: "otel", in: "examples/auth", out: "examples/auth/portolan", options: { traces: ["telemetry/traces.jsonl"], out: "observed.json", services: { "auth-api": "auth.auth" } } };
|
|
24
|
+
const manifest = { sources: [], projects: [project], verify: [step] };
|
|
25
|
+
|
|
26
|
+
const widened = manifestWithTraceStep(manifest, project);
|
|
27
|
+
expect(widened.changed).toBe(true);
|
|
28
|
+
expect(widened.change).toBe("widened");
|
|
29
|
+
expect(widened.step.options.traces).toEqual(["telemetry/traces.jsonl", "telemetry/recordings/*.jsonl"]);
|
|
30
|
+
expect(widened.step.options.services).toEqual({ "auth-api": "auth.auth" });
|
|
31
|
+
|
|
32
|
+
const again = manifestWithTraceStep(widened.manifest, project);
|
|
33
|
+
expect(again.changed).toBe(false);
|
|
34
|
+
expect(again.change).toBe("none");
|
|
35
|
+
expect(traceStepFor(again.manifest, project)?.index).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("is the project's own, not another project's", () => {
|
|
39
|
+
const other = { plugin: "otel", in: "examples/shop/cart", out: "examples/shop/cart/portolan", options: { traces: ["telemetry/traces.jsonl"] } };
|
|
40
|
+
expect(traceStepFor({ verify: [other] }, project)).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("takes the names the page mapped over what it had", () => {
|
|
44
|
+
const step = { plugin: "otel", in: "x", out: "y", options: { traces: ["a"], events: { "a.B": "x.y.B" } } };
|
|
45
|
+
const mapped = stepWithMappings(step, { services: { "auth-api": "auth.auth" }, events: { "a.C": "x.y.C" }, routes: { "POST /api/v1/sessions": "login" } });
|
|
46
|
+
expect(mapped.options).toEqual({ traces: ["a"], events: { "a.B": "x.y.B", "a.C": "x.y.C" }, services: { "auth-api": "auth.auth" }, routes: { "POST /api/v1/sessions": "login" } });
|
|
47
|
+
expect(stepWithMappings(step, {})).toEqual(step);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("where an upload lands", () => {
|
|
52
|
+
const today = new Date("2026-09-12T10:00:00Z");
|
|
53
|
+
|
|
54
|
+
it("is dated, named after the file, and never on top of another", () => {
|
|
55
|
+
expect(recordingPath("Login Traces.JSONL", { today })).toBe("telemetry/recordings/2026-09-12-login-traces.jsonl");
|
|
56
|
+
expect(recordingPath("/tmp/export/traces.json", { today })).toBe("telemetry/recordings/2026-09-12-traces.jsonl");
|
|
57
|
+
expect(recordingPath("", { today })).toBe("telemetry/recordings/2026-09-12-recording.jsonl");
|
|
58
|
+
const taken = new Set(["telemetry/recordings/2026-09-12-traces.jsonl", "telemetry/recordings/2026-09-12-traces-2.jsonl"]);
|
|
59
|
+
expect(recordingPath("traces.jsonl", { today, taken: (candidate) => taken.has(candidate) })).toBe("telemetry/recordings/2026-09-12-traces-3.jsonl");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("what an upload has to be", () => {
|
|
64
|
+
const batch = (spans) => JSON.stringify({ resourceSpans: [{ resource: { attributes: [] }, scopeSpans: [{ spans }] }] });
|
|
65
|
+
const span = { traceId: "t", spanId: "s", name: "GET /", kind: 2 };
|
|
66
|
+
|
|
67
|
+
it("reads one value or one per line", () => {
|
|
68
|
+
expect(checkRecording(Buffer.from(batch([span])))).toEqual({ batches: 1, spans: 1 });
|
|
69
|
+
expect(checkRecording(Buffer.from(`${batch([span])}\n${batch([span, span])}\n`))).toEqual({ batches: 2, spans: 3 });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("refuses what the verifier could not read, and says why", () => {
|
|
73
|
+
expect(() => checkRecording(Buffer.from(""))).toThrow(/empty/);
|
|
74
|
+
expect(() => checkRecording(Buffer.from("not json"))).toThrow(/not OTLP JSON/);
|
|
75
|
+
expect(() => checkRecording(Buffer.from('{"traces":[]}'))).toThrow(/no resourceSpans/);
|
|
76
|
+
expect(() => checkRecording(Buffer.from(batch([])))).toThrow(/no spans/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("what a verifier's warning is about", () => {
|
|
81
|
+
it("names the service, event or route the page could map", () => {
|
|
82
|
+
expect(readVerifyWarning('spans from service.name "auth-api" match no service in the catalog; name it under `services` to say which one it is')).toMatchObject({ kind: "service", name: "auth-api" });
|
|
83
|
+
expect(readVerifyWarning('publishes "auth.Ended", which matches no event of its own in the catalog; name it under `events`')).toMatchObject({ kind: "event", name: "auth.Ended" });
|
|
84
|
+
expect(readVerifyWarning('consumes "auth.Ended", which matches no event in the catalog; name it under `events`')).toMatchObject({ kind: "event", name: "auth.Ended" });
|
|
85
|
+
expect(readVerifyWarning("answers on GET /v1/health, which no interface it provides declares; the flow opens on the route rather than an operation")).toMatchObject({ kind: "route", name: "GET /v1/health" });
|
|
86
|
+
expect(readVerifyWarning("calls GET /v1/profiles/42 on profile, which no service in the catalog answers on; the hop is unresolved")).toMatchObject({ kind: "call", name: "GET /v1/profiles/42" });
|
|
87
|
+
expect(readVerifyWarning('publishes "a" on "x", but the catalog says it goes on "y"')).toMatchObject({ kind: "channel" });
|
|
88
|
+
expect(readVerifyWarning("something else")).toEqual({ kind: "other", message: "something else" });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("what a trial run said about the recording", () => {
|
|
93
|
+
const roots = [];
|
|
94
|
+
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
|
95
|
+
|
|
96
|
+
it("lists the flows the recording showed first, with what it raised and added", () => {
|
|
97
|
+
const snapshot = mkdtempSync(join(tmpdir(), "portolan-trace-trial-"));
|
|
98
|
+
roots.push(snapshot);
|
|
99
|
+
mkdirSync(join(snapshot, "examples/auth/portolan"), { recursive: true });
|
|
100
|
+
const recording = "telemetry/recordings/2026-09-12-login.jsonl";
|
|
101
|
+
const named = `examples/auth/${recording}`;
|
|
102
|
+
writeFileSync(join(snapshot, "examples/auth/portolan/observed.json"), JSON.stringify({
|
|
103
|
+
flows: [
|
|
104
|
+
{
|
|
105
|
+
id: "flow.auth-login", slug: "auth-login", name: "Login", owner: "auth", participants: [],
|
|
106
|
+
steps: [
|
|
107
|
+
{ type: "step", id: "s1", status: "verified", seen: { traces: 1 } },
|
|
108
|
+
{ type: "step", id: "s2", status: "declared" },
|
|
109
|
+
{ type: "alt", id: "alt3", branches: [{ title: "blocked", steps: [{ type: "step", id: "seen1", status: "unresolved", seen: { traces: 1 } }] }] },
|
|
110
|
+
],
|
|
111
|
+
examples: [{ id: `${recording}#t1`, recording: named, traceId: "t1", durationMs: 1, steps: [{ step: "s1", durationMs: 1 }, { step: "seen1", durationMs: 1 }] }],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
id: "flow.observed-auth-get-v1-health", slug: "observed-auth-get-v1-health", name: "Observed: GET /v1/health", owner: "auth", participants: [],
|
|
115
|
+
steps: [{ type: "step", id: "s1", status: "verified", seen: { traces: 3 } }],
|
|
116
|
+
examples: [{ id: "telemetry/traces.jsonl#t9", recording: "examples/auth/telemetry/traces.jsonl", traceId: "t9", durationMs: 1, steps: [] }],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
}));
|
|
120
|
+
const step = { plugin: "otel", in: "examples/auth", out: "examples/auth/portolan", options: { traces: ["telemetry/recordings/*.jsonl"], out: "observed.json", services: { "auth-api": "auth.auth" } } };
|
|
121
|
+
const events = [
|
|
122
|
+
{ type: "step-finished", phase: "extract", plugin: "go-domain", output: "examples/auth/portolan", status: "ok", warnings: ["ignored"] },
|
|
123
|
+
{ type: "step-finished", phase: "verify", plugin: "otel", input: "examples/auth", output: "examples/auth/portolan", status: "warning", warnings: ['spans from service.name "risk" match no service in the catalog; name it under `services` to say which one it is'] },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
const summary = summarizeTraceTrial(snapshot, { projectId: "auth", root: "examples/auth", recording, step, stepAdded: true, stepChange: "widened", spans: 7 }, events);
|
|
127
|
+
|
|
128
|
+
expect(summary).toMatchObject({ project: "auth", recording, stepAdded: true, stepChange: "widened", status: "warning", spans: 7, mappings: { services: { "auth-api": "auth.auth" }, events: {}, routes: {} } });
|
|
129
|
+
expect(summary.warnings).toEqual([{ kind: "service", name: "risk", message: events[1].warnings[0] }]);
|
|
130
|
+
expect(summary.flows.map((flow) => flow.slug)).toEqual(["auth-login", "observed-auth-get-v1-health"]);
|
|
131
|
+
expect(summary.flows[0]).toMatchObject({ kind: "declared", inRecording: true, traces: 1, verified: 1, unresolved: 1, added: 1, shown: 2, steps: 3, examples: 1 });
|
|
132
|
+
expect(summary.flows[1]).toMatchObject({ kind: "observed", inRecording: false, traces: 0, verified: 1, added: 0 });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is a failed, empty summary when the verifier wrote nothing", () => {
|
|
136
|
+
const snapshot = mkdtempSync(join(tmpdir(), "portolan-trace-trial-"));
|
|
137
|
+
roots.push(snapshot);
|
|
138
|
+
const step = { plugin: "otel", in: "x", out: "x/portolan", options: { traces: [] } };
|
|
139
|
+
const summary = summarizeTraceTrial(snapshot, { projectId: "p", root: "x", recording: "telemetry/recordings/a.jsonl", step, stepAdded: false, spans: 1 }, []);
|
|
140
|
+
expect(summary).toMatchObject({ status: "failed", flows: [], warnings: [] });
|
|
141
|
+
});
|
|
142
|
+
});
|