@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
package/scripts/README.md
CHANGED
|
@@ -37,7 +37,9 @@ and validates the union. Nothing here draws anything.
|
|
|
37
37
|
- Read a plugin's stdout for anything but the one message, or its stderr for
|
|
38
38
|
anything but notes; a warning is kept beside the step in the build report.
|
|
39
39
|
- Draw the C4 model: `gen-likec4.mjs` writes `likec4/` from the same merged
|
|
40
|
-
catalog, and the site draws.
|
|
40
|
+
catalog, and the site draws. It runs as the last step of `gen.mjs` -
|
|
41
|
+
written, or in check mode held to the catalog like every generated page -
|
|
42
|
+
and on its own before `dev`.
|
|
41
43
|
|
|
42
44
|
## Files
|
|
43
45
|
|
|
@@ -79,11 +79,23 @@ function repositoryName(remote, workspace) {
|
|
|
79
79
|
return clean.split(/[/:]/).filter(Boolean).pop() || relative(dirname(workspace), workspace) || "repository";
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// One ref listing answers three questions (where origin/HEAD points, whether
|
|
83
|
+
// main or master exists) instead of one git process per question: a preview
|
|
84
|
+
// spawns git while the test suite and the site build are already competing
|
|
85
|
+
// for the machine, and every spawn costs seconds there.
|
|
82
86
|
function defaultBranch(workspace) {
|
|
83
|
-
const
|
|
84
|
-
|
|
87
|
+
const refs = new Map();
|
|
88
|
+
for (const line of git(workspace, [
|
|
89
|
+
"for-each-ref", "--format=%(refname)%09%(symref)",
|
|
90
|
+
"refs/remotes/origin/HEAD", "refs/heads/main", "refs/heads/master",
|
|
91
|
+
]).split("\n")) {
|
|
92
|
+
const [name, symref = ""] = line.split("\t");
|
|
93
|
+
if (name) refs.set(name, symref);
|
|
94
|
+
}
|
|
95
|
+
const remoteHead = refs.get("refs/remotes/origin/HEAD") ?? "";
|
|
96
|
+
if (remoteHead.startsWith("refs/remotes/origin/")) return remoteHead.slice("refs/remotes/origin/".length);
|
|
85
97
|
for (const candidate of ["main", "master"]) {
|
|
86
|
-
if (
|
|
98
|
+
if (refs.has(`refs/heads/${candidate}`)) return candidate;
|
|
87
99
|
}
|
|
88
100
|
return git(workspace, ["branch", "--show-current"]) || "main";
|
|
89
101
|
}
|
|
@@ -316,8 +328,29 @@ function mergeGitlab(existing, features) {
|
|
|
316
328
|
return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
|
|
317
329
|
}
|
|
318
330
|
|
|
331
|
+
// A file that is entirely new or entirely gone needs no diff algorithm: every
|
|
332
|
+
// line is added or removed. Writing that hunk here saves a git process per
|
|
333
|
+
// file, and a GitHub preset is mostly such files.
|
|
334
|
+
function wholeFileDiff(path, content, sign) {
|
|
335
|
+
const body = content.endsWith("\n") ? content.slice(0, -1) : content;
|
|
336
|
+
const lines = body.split("\n");
|
|
337
|
+
const count = lines.length === 1 ? "1" : `1,${lines.length}`;
|
|
338
|
+
const range = sign === "+" ? `-0,0 +${count}` : `-${count} +0,0`;
|
|
339
|
+
return [
|
|
340
|
+
`diff --git a/${path} b/${path}`,
|
|
341
|
+
`--- a/${path}`,
|
|
342
|
+
`+++ b/${path}`,
|
|
343
|
+
`@@ ${range} @@`,
|
|
344
|
+
...lines.map((line) => `${sign}${line}`),
|
|
345
|
+
...(content.endsWith("\n") ? [] : ["\"]),
|
|
346
|
+
"",
|
|
347
|
+
].join("\n");
|
|
348
|
+
}
|
|
349
|
+
|
|
319
350
|
function textDiff(path, before, after) {
|
|
320
351
|
if (before === after) return "";
|
|
352
|
+
if (before === "") return wholeFileDiff(path, after, "+");
|
|
353
|
+
if (after === "") return wholeFileDiff(path, before, "-");
|
|
321
354
|
const holder = mkdtempSync(join(tmpdir(), "portolan-preset-diff-"));
|
|
322
355
|
const left = join(holder, "before");
|
|
323
356
|
const right = join(holder, "after");
|
package/scripts/gen-likec4.mjs
CHANGED
|
@@ -7,16 +7,24 @@
|
|
|
7
7
|
//
|
|
8
8
|
// node scripts/gen-likec4.mjs
|
|
9
9
|
|
|
10
|
-
import { writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { writeFileSync, mkdirSync, realpathSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
11
13
|
|
|
12
14
|
import { loadCatalog } from "./catalog-sources.mjs";
|
|
13
15
|
import reserved from "../src/likec4/reserved.json" with { type: "json" };
|
|
14
16
|
import { catalogProfiles } from "../src/catalog-profile.ts";
|
|
17
|
+
import { allDeployments, deploys, environmentOf } from "../src/catalog-model.ts";
|
|
15
18
|
|
|
16
19
|
// Every source, not one file: a service that publishes its own facts gets a
|
|
17
20
|
// C4 view like any other, and generating from a single file would leave it out
|
|
18
21
|
// of the pictures while the rest of the app knows about it.
|
|
19
|
-
|
|
22
|
+
//
|
|
23
|
+
// The sources come back as files rather than being written here, so that
|
|
24
|
+
// `gen` can settle them the way it settles every generated page - written,
|
|
25
|
+
// or in check mode compared and reported as drift - and `likec4:gen` can
|
|
26
|
+
// still write them on its own before the dev server starts.
|
|
27
|
+
export async function likec4Sources({ catalog, manifest }) {
|
|
20
28
|
const profiles = catalogProfiles(manifest);
|
|
21
29
|
|
|
22
30
|
// --- ids (mirrors src/likec4/ids.ts; kept in step by src/likec4/ids.test.ts) ---
|
|
@@ -36,6 +44,8 @@ const flowCrossViewId = (flow) => `${flowViewId(flow)}_cross`;
|
|
|
36
44
|
const contextViewId = (c) => `ctx_${safeId(c.id)}`;
|
|
37
45
|
const serviceViewId = (s) => `svc_${safeId(s.id)}`;
|
|
38
46
|
const serviceInsideViewId = (s) => `${serviceViewId(s)}_inside`;
|
|
47
|
+
const deploymentViewId = (environment) => `deploy_${safeId(environment)}`;
|
|
48
|
+
const serviceDeployViewId = (s) => `deploy_svc_${safeId(s.id)}`;
|
|
39
49
|
const LANDSCAPE_VIEW = "landscape";
|
|
40
50
|
const CONTAINERS_VIEW = "containers";
|
|
41
51
|
const profileLandscapeViewId = (profile) =>
|
|
@@ -248,6 +258,18 @@ spec.push(" element unknown {");
|
|
|
248
258
|
spec.push(" style { shape rectangle color unresolved border dashed }");
|
|
249
259
|
spec.push(" }");
|
|
250
260
|
spec.push("");
|
|
261
|
+
// Where a service runs (portolan.0012): three nested places, drawn as
|
|
262
|
+
// frames around the instances they hold, from the deployer's snapshot.
|
|
263
|
+
spec.push(" deploymentNode environment {");
|
|
264
|
+
spec.push(" style { shape rectangle opacity 5% border dashed }");
|
|
265
|
+
spec.push(" }");
|
|
266
|
+
spec.push(" deploymentNode cluster {");
|
|
267
|
+
spec.push(" style { shape rectangle opacity 10% border dashed color muted }");
|
|
268
|
+
spec.push(" }");
|
|
269
|
+
spec.push(" deploymentNode namespace {");
|
|
270
|
+
spec.push(" style { shape rectangle opacity 15% color muted }");
|
|
271
|
+
spec.push(" }");
|
|
272
|
+
spec.push("");
|
|
251
273
|
for (const kind of RELATION_KINDS) spec.push(` relationship ${kind}`);
|
|
252
274
|
spec.push("}");
|
|
253
275
|
|
|
@@ -1050,20 +1072,131 @@ for (const flow of catalog.flows) {
|
|
|
1050
1072
|
views.push(" }");
|
|
1051
1073
|
views.push("");
|
|
1052
1074
|
}
|
|
1075
|
+
// ---------------------------------------------------------------------------
|
|
1076
|
+
// deployment: where the services run (portolan.0012)
|
|
1077
|
+
// ---------------------------------------------------------------------------
|
|
1078
|
+
// One tree per environment, the cluster and the namespace as frames inside
|
|
1079
|
+
// it, and an instance of the service in the namespace it stands in. The
|
|
1080
|
+
// relations are the model's own: LikeC4 draws between two instances what the
|
|
1081
|
+
// model declares between their elements, so an environment's picture shows
|
|
1082
|
+
// the calls both ends of which are deployed there, and nothing a reader
|
|
1083
|
+
// would have to fold by hand. One view per environment, and one per service
|
|
1084
|
+
// that runs somewhere: its instances with the frames around them.
|
|
1085
|
+
const deployment = [];
|
|
1086
|
+
const deployTree = new Map(); // environment -> cluster -> namespace -> Map(service id -> {service, deployment})
|
|
1087
|
+
const placesOfService = new Map(); // service id -> [fqn of instance]
|
|
1088
|
+
const framesOfEnvironment = new Map(); // environment -> Set(fqn of every node under it)
|
|
1089
|
+
for (const placed of allDeployments(catalog)) {
|
|
1090
|
+
for (const service of allServices) {
|
|
1091
|
+
if (!deploys(placed, service)) continue;
|
|
1092
|
+
const env = environmentOf(placed);
|
|
1093
|
+
const clusters = deployTree.get(env) ?? new Map();
|
|
1094
|
+
deployTree.set(env, clusters);
|
|
1095
|
+
const namespaces = clusters.get(placed.cluster) ?? new Map();
|
|
1096
|
+
clusters.set(placed.cluster, namespaces);
|
|
1097
|
+
const instances = namespaces.get(placed.namespace) ?? new Map();
|
|
1098
|
+
namespaces.set(placed.namespace, instances);
|
|
1099
|
+
// Two Applications of one service in one place is one instance: the
|
|
1100
|
+
// picture is about where it runs, and it runs there once.
|
|
1101
|
+
if (!instances.has(service.id)) instances.set(service.id, { service, deployment: placed });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
const environments = [...deployTree.keys()].sort();
|
|
1105
|
+
const sorted = (map) => [...map.keys()].sort();
|
|
1106
|
+
if (environments.length > 0) {
|
|
1107
|
+
deployment.push("deployment {");
|
|
1108
|
+
for (const env of environments) {
|
|
1109
|
+
const envId = safeId(env);
|
|
1110
|
+
deployment.push(` environment ${envId} ${q(env)} {`);
|
|
1111
|
+
const clusters = deployTree.get(env);
|
|
1112
|
+
for (const cluster of sorted(clusters)) {
|
|
1113
|
+
const path = [envId];
|
|
1114
|
+
let indent = " ";
|
|
1115
|
+
if (cluster) {
|
|
1116
|
+
path.push(safeId(cluster));
|
|
1117
|
+
deployment.push(`${indent}cluster ${safeId(cluster)} ${q(cluster)} {`);
|
|
1118
|
+
indent += " ";
|
|
1119
|
+
}
|
|
1120
|
+
const namespaces = clusters.get(cluster);
|
|
1121
|
+
for (const namespace of sorted(namespaces)) {
|
|
1122
|
+
const nsPath = [...path];
|
|
1123
|
+
let nsIndent = indent;
|
|
1124
|
+
if (namespace) {
|
|
1125
|
+
nsPath.push(safeId(namespace));
|
|
1126
|
+
deployment.push(`${nsIndent}namespace ${safeId(namespace)} ${q(namespace)} {`);
|
|
1127
|
+
nsIndent += " ";
|
|
1128
|
+
}
|
|
1129
|
+
const instances = namespaces.get(namespace);
|
|
1130
|
+
for (const serviceId of sorted(instances)) {
|
|
1131
|
+
const { service, deployment: placed } = instances.get(serviceId);
|
|
1132
|
+
const instanceId = safeId(service.id);
|
|
1133
|
+
const revision = /^[0-9a-f]{40}$/.test(placed.revision) ? placed.revision.slice(0, 7) : placed.revision;
|
|
1134
|
+
const about = [placed.name, revision ? `at ${revision}` : ""].filter(Boolean).join(" ");
|
|
1135
|
+
deployment.push(`${nsIndent}${instanceId} = instanceOf ${fqn(service.id)} {`);
|
|
1136
|
+
deployment.push(`${nsIndent} description ${q(about)}`);
|
|
1137
|
+
deployment.push(`${nsIndent}}`);
|
|
1138
|
+
const places = placesOfService.get(service.id) ?? [];
|
|
1139
|
+
places.push([...nsPath, instanceId].join("."));
|
|
1140
|
+
placesOfService.set(service.id, places);
|
|
1141
|
+
const frames = framesOfEnvironment.get(env) ?? new Set();
|
|
1142
|
+
for (let depth = 2; depth <= nsPath.length; depth += 1) frames.add(nsPath.slice(0, depth).join("."));
|
|
1143
|
+
frames.add([...nsPath, instanceId].join("."));
|
|
1144
|
+
framesOfEnvironment.set(env, frames);
|
|
1145
|
+
}
|
|
1146
|
+
if (namespace) deployment.push(`${indent}}`);
|
|
1147
|
+
}
|
|
1148
|
+
if (cluster) deployment.push(" }");
|
|
1149
|
+
}
|
|
1150
|
+
deployment.push(" }");
|
|
1151
|
+
}
|
|
1152
|
+
deployment.push("}");
|
|
1153
|
+
|
|
1154
|
+
views.push("");
|
|
1155
|
+
for (const env of environments) {
|
|
1156
|
+
// Every frame under the environment, named one by one rather than as
|
|
1157
|
+
// `env.**`: a descendant wildcard draws the instances alone and folds
|
|
1158
|
+
// the cluster and namespace away, and where a thing runs is the point.
|
|
1159
|
+
views.push(` deployment view ${deploymentViewId(env)} {`);
|
|
1160
|
+
views.push(` title ${q(`${env} — deployed`)}`);
|
|
1161
|
+
views.push(` include ${[...framesOfEnvironment.get(env)].join(", ")}`);
|
|
1162
|
+
views.push(" }");
|
|
1163
|
+
}
|
|
1164
|
+
for (const service of allServices) {
|
|
1165
|
+
const places = placesOfService.get(service.id);
|
|
1166
|
+
if (!places) continue;
|
|
1167
|
+
// The frames around each instance, named one by one: a node named with
|
|
1168
|
+
// its children is drawn as the frame it is, and a reader can tell the
|
|
1169
|
+
// two boxes apart by where they sit.
|
|
1170
|
+
const frames = new Set();
|
|
1171
|
+
for (const place of places) {
|
|
1172
|
+
const segments = place.split(".");
|
|
1173
|
+
for (let depth = 1; depth <= segments.length; depth += 1) frames.add(segments.slice(0, depth).join("."));
|
|
1174
|
+
}
|
|
1175
|
+
views.push(` deployment view ${serviceDeployViewId(service)} {`);
|
|
1176
|
+
views.push(` title ${q(`${service.name} — where it runs`)}`);
|
|
1177
|
+
views.push(` include ${[...frames].join(", ")}`);
|
|
1178
|
+
views.push(" }");
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1053
1181
|
views.push("}");
|
|
1054
1182
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
"
|
|
1059
|
-
`// GENERATED — do not edit.\n${
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
"likec4/views.c4",
|
|
1063
|
-
`// GENERATED — do not edit.\n${views.join("\n")}\n`,
|
|
1064
|
-
);
|
|
1183
|
+
return [
|
|
1184
|
+
{ name: "deployment.c4", contents: `// GENERATED — do not edit.\n${deployment.join("\n")}\n` },
|
|
1185
|
+
{ name: "spec.c4", contents: `${spec.join("\n")}\n` },
|
|
1186
|
+
{ name: "model.c4", contents: `// GENERATED — do not edit.\n${model.join("\n")}\n` },
|
|
1187
|
+
{ name: "views.c4", contents: `// GENERATED — do not edit.\n${views.join("\n")}\n` },
|
|
1188
|
+
];
|
|
1189
|
+
}
|
|
1065
1190
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
);
|
|
1191
|
+
// Run as a script - `npm run likec4:gen`, before the dev server starts - the
|
|
1192
|
+
// sources are written under likec4/ here and now.
|
|
1193
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
1194
|
+
const bundle = await loadCatalog();
|
|
1195
|
+
const files = await likec4Sources(bundle);
|
|
1196
|
+
mkdirSync("likec4", { recursive: true });
|
|
1197
|
+
for (const file of files) writeFileSync(join("likec4", file.name), file.contents);
|
|
1198
|
+
console.log(
|
|
1199
|
+
`wrote ${files.map((file) => `likec4/${file.name}`).join(", ")} ` +
|
|
1200
|
+
`(${bundle.catalog.flows.length * 2} dynamic views)`,
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
@@ -6,6 +6,8 @@ import { execFileSync } from "node:child_process";
|
|
|
6
6
|
|
|
7
7
|
import { describe, expect, it } from "vitest";
|
|
8
8
|
|
|
9
|
+
import { likec4Sources } from "./gen-likec4.mjs";
|
|
10
|
+
|
|
9
11
|
const generator = fileURLToPath(new URL("./gen-likec4.mjs", import.meta.url));
|
|
10
12
|
const likec4 = join(
|
|
11
13
|
dirname(dirname(generator)),
|
|
@@ -38,9 +40,22 @@ function generate(catalog) {
|
|
|
38
40
|
spec: readFileSync(join(root, "likec4", "spec.c4"), "utf8"),
|
|
39
41
|
model: readFileSync(join(root, "likec4", "model.c4"), "utf8"),
|
|
40
42
|
views: readFileSync(join(root, "likec4", "views.c4"), "utf8"),
|
|
43
|
+
deployment: readFileSync(join(root, "likec4", "deployment.c4"), "utf8"),
|
|
41
44
|
};
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
describe("the LikeC4 generator as a step of gen", () => {
|
|
48
|
+
it("hands back the four sources as files rather than writing them, so gen can settle them", async () => {
|
|
49
|
+
const files = await likec4Sources({
|
|
50
|
+
catalog: { generatedAt: "2026-09-06T00:00:00Z", commit: "0", adrs: [], defs: {}, contexts: [], flows: [] },
|
|
51
|
+
manifest: { sources: ["data/*.json"] },
|
|
52
|
+
});
|
|
53
|
+
expect(files.map((file) => file.name)).toEqual(["deployment.c4", "spec.c4", "model.c4", "views.c4"]);
|
|
54
|
+
expect(files.find((file) => file.name === "views.c4")?.contents).toContain("view landscape");
|
|
55
|
+
expect(files.every((file) => file.contents.endsWith("\n"))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
44
59
|
describe("the LikeC4 generator", () => {
|
|
45
60
|
it("emits valid fallback views for an empty catalog", () => {
|
|
46
61
|
const { model, views } = generate({ contexts: [], flows: [] });
|
|
@@ -432,4 +447,85 @@ describe("the LikeC4 generator", () => {
|
|
|
432
447
|
/view svc_shop_oms of shop\.oms \{\n title[^\n]*\n include \*, -> \*, \* ->\n \}/,
|
|
433
448
|
);
|
|
434
449
|
});
|
|
450
|
+
|
|
451
|
+
it("places every deployed service in its environment, cluster and namespace, and draws the frames by name", () => {
|
|
452
|
+
const service = (slug, path) => ({
|
|
453
|
+
id: `shop.${slug}`,
|
|
454
|
+
slug,
|
|
455
|
+
name: slug,
|
|
456
|
+
repo: "github.com/acme/shop",
|
|
457
|
+
path,
|
|
458
|
+
readme: "",
|
|
459
|
+
provides: [],
|
|
460
|
+
consumes: [],
|
|
461
|
+
aggregates: [],
|
|
462
|
+
});
|
|
463
|
+
const placed = (name, environment, cluster, namespace, path) => ({
|
|
464
|
+
id: `argocd/${name}`,
|
|
465
|
+
name,
|
|
466
|
+
project: "shop",
|
|
467
|
+
environment,
|
|
468
|
+
cluster,
|
|
469
|
+
namespace,
|
|
470
|
+
repo: "github.com/acme/shop",
|
|
471
|
+
path,
|
|
472
|
+
targetRevision: "main",
|
|
473
|
+
revision: "a".repeat(40),
|
|
474
|
+
tool: "kustomize",
|
|
475
|
+
url: `https://argocd.example.com/applications/argocd/${name}`,
|
|
476
|
+
});
|
|
477
|
+
const { spec, deployment, views } = generate({
|
|
478
|
+
contexts: [
|
|
479
|
+
{
|
|
480
|
+
id: "shop",
|
|
481
|
+
slug: "shop",
|
|
482
|
+
name: "Shop",
|
|
483
|
+
summary: "",
|
|
484
|
+
services: [service("cart", "services/cart"), service("oms", "services/oms")],
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
flows: [],
|
|
488
|
+
deployments: [
|
|
489
|
+
placed("cart", "prod", "in-cluster", "shop", "services/cart/deploy"),
|
|
490
|
+
placed("cart-staging", "staging", "staging-eu", "shop", "services/cart/deploy"),
|
|
491
|
+
placed("oms", "prod", "in-cluster", "shop", "services/oms/deploy"),
|
|
492
|
+
// Deploys a repository nobody in the estate claims: placed nowhere.
|
|
493
|
+
placed("grafana", "prod", "in-cluster", "monitoring", "charts/grafana"),
|
|
494
|
+
],
|
|
495
|
+
});
|
|
496
|
+
expect(spec).toContain("deploymentNode environment");
|
|
497
|
+
expect(deployment).toContain(
|
|
498
|
+
" environment prod 'prod' {\n" +
|
|
499
|
+
" cluster in_cluster 'in-cluster' {\n" +
|
|
500
|
+
" namespace shop 'shop' {\n" +
|
|
501
|
+
" shop_cart = instanceOf shop.cart {\n" +
|
|
502
|
+
" description 'cart at aaaaaaa'\n" +
|
|
503
|
+
" }\n" +
|
|
504
|
+
" shop_oms = instanceOf shop.oms {",
|
|
505
|
+
);
|
|
506
|
+
expect(deployment).toContain("environment staging 'staging'");
|
|
507
|
+
expect(deployment).not.toContain("grafana");
|
|
508
|
+
expect(deployment).not.toContain("monitoring");
|
|
509
|
+
// The environment view names every frame under it; a descendant wildcard
|
|
510
|
+
// would fold the cluster and the namespace away.
|
|
511
|
+
expect(views).toContain(
|
|
512
|
+
" deployment view deploy_prod {\n" +
|
|
513
|
+
" title 'prod — deployed'\n" +
|
|
514
|
+
" include prod.in_cluster, prod.in_cluster.shop, prod.in_cluster.shop.shop_cart, prod.in_cluster.shop.shop_oms\n" +
|
|
515
|
+
" }",
|
|
516
|
+
);
|
|
517
|
+
expect(views).toContain(
|
|
518
|
+
" deployment view deploy_svc_shop_cart {\n" +
|
|
519
|
+
" title 'cart — where it runs'\n" +
|
|
520
|
+
" include prod, prod.in_cluster, prod.in_cluster.shop, prod.in_cluster.shop.shop_cart, staging, staging.staging_eu, staging.staging_eu.shop, staging.staging_eu.shop.shop_cart\n" +
|
|
521
|
+
" }",
|
|
522
|
+
);
|
|
523
|
+
expect(views).not.toContain("deploy_svc_shop_grafana");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("writes an empty deployment file and no deployment view when nothing is placed", () => {
|
|
527
|
+
const { deployment, views } = generate({ contexts: [], flows: [] });
|
|
528
|
+
expect(deployment.trim()).toBe("// GENERATED — do not edit.");
|
|
529
|
+
expect(views).not.toContain("deployment view");
|
|
530
|
+
});
|
|
435
531
|
});
|
package/scripts/gen.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "./output-path.mjs";
|
|
37
37
|
import { builtinPlugin } from "./builtin-plugins.mjs";
|
|
38
38
|
import { diagnoseWarnings } from "./warning-policy.mjs";
|
|
39
|
+
import { likec4Sources } from "./gen-likec4.mjs";
|
|
39
40
|
|
|
40
41
|
const PORTOLAN_VERSION = "0.1.0";
|
|
41
42
|
const EVENTS = process.env.PORTOLAN_EVENTS === "1";
|
|
@@ -63,6 +64,10 @@ function event(value) {
|
|
|
63
64
|
// in one run is drift that never happened.
|
|
64
65
|
const MANIFEST = ".portolan-manifest";
|
|
65
66
|
|
|
67
|
+
// The one step no manifest declares: the app's own pictures, generated from
|
|
68
|
+
// every source, keyed by its own name in likec4/.portolan-manifest.
|
|
69
|
+
const LIKEC4_STEP = { plugin: "likec4", out: "likec4", key: "likec4" };
|
|
70
|
+
|
|
66
71
|
const check = process.argv.includes("--check");
|
|
67
72
|
const manifestSha256 = createHash("sha256")
|
|
68
73
|
.update(readFileSync("portolan.json"))
|
|
@@ -194,6 +199,15 @@ async function generate() {
|
|
|
194
199
|
{ inputs: generated.sources.map((source) => source.path), excludes: [] });
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
// The LikeC4 sources are pages of the catalog like any the generators
|
|
203
|
+
// write: committed, reviewed as a diff, and held to the catalog by the
|
|
204
|
+
// check. They used to be written only before the dev server started,
|
|
205
|
+
// which left a flow a verifier had just changed with a picture of the
|
|
206
|
+
// flow before - and a run from the page with no picture of a new flow.
|
|
207
|
+
await executeStep("generate", LIKEC4_STEP, "likec4 → likec4", async () => ({
|
|
208
|
+
files: await likec4Sources({ catalog, manifest }),
|
|
209
|
+
}), { inputs: sources.map((source) => source.path), excludes: [] });
|
|
210
|
+
|
|
197
211
|
sweepAll();
|
|
198
212
|
}
|
|
199
213
|
|
|
@@ -208,6 +222,17 @@ function sweepAll() {
|
|
|
208
222
|
}
|
|
209
223
|
}
|
|
210
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The keys of the steps that write into a directory this run: the manifest's,
|
|
227
|
+
* and the likec4 step's in its own directory, which no manifest declares and
|
|
228
|
+
* the sweep would otherwise take for a step that was dropped.
|
|
229
|
+
*/
|
|
230
|
+
function liveKeysIn(out) {
|
|
231
|
+
const live = new Set(keys.liveIn(out));
|
|
232
|
+
if (out === LIKEC4_STEP.out) live.add(LIKEC4_STEP.key);
|
|
233
|
+
return live;
|
|
234
|
+
}
|
|
235
|
+
|
|
211
236
|
/** Every file the sweep would remove, in every directory a step writes into. */
|
|
212
237
|
function staleAll() {
|
|
213
238
|
const outs = new Set(
|
|
@@ -230,7 +255,7 @@ function staleAll() {
|
|
|
230
255
|
*/
|
|
231
256
|
function staleIn(out) {
|
|
232
257
|
const listing = previous(out);
|
|
233
|
-
const live =
|
|
258
|
+
const live = liveKeysIn(out);
|
|
234
259
|
const dead = Object.keys(listing).filter((key) => !live.has(key));
|
|
235
260
|
if (dead.length === 0) return [];
|
|
236
261
|
|
|
@@ -267,7 +292,7 @@ async function executeStep(phase, step, label, work, reads) {
|
|
|
267
292
|
project: projectForStep(step),
|
|
268
293
|
phase,
|
|
269
294
|
});
|
|
270
|
-
const changes = apply(files, step.out, keys.keyOf(step), check);
|
|
295
|
+
const changes = apply(files, step.out, step.key ?? keys.keyOf(step), check);
|
|
271
296
|
const since = changes.length > 0 ? whyChanged(step, files.map((file) => join(step.out, file.name)), reads) : null;
|
|
272
297
|
const changed = summarise(label, files, changes, diagnostics, since);
|
|
273
298
|
drifted = changed || drifted;
|
|
@@ -436,6 +461,8 @@ function whyChanged(step, outputs, reads) {
|
|
|
436
461
|
|
|
437
462
|
/** Whether the manifest at `commit` told this step the same thing it is told now. */
|
|
438
463
|
function stepEntryChanged(commit, step) {
|
|
464
|
+
// A step no manifest declares has no entry to have changed.
|
|
465
|
+
if (step.key) return false;
|
|
439
466
|
let then;
|
|
440
467
|
try {
|
|
441
468
|
then = JSON.parse(fileAt(process.cwd(), commit, "portolan.json"));
|
|
@@ -552,7 +579,7 @@ function apply(files, out, key, checkOnly) {
|
|
|
552
579
|
function sweep(out, checkOnly) {
|
|
553
580
|
const changes = [];
|
|
554
581
|
const listing = previous(out);
|
|
555
|
-
const live =
|
|
582
|
+
const live = liveKeysIn(out);
|
|
556
583
|
const dead = Object.keys(listing).filter((key) => !live.has(key));
|
|
557
584
|
if (dead.length === 0) return changes;
|
|
558
585
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The example GitOps tree, rendered the way Argo CD would render it.
|
|
2
|
+
//
|
|
3
|
+
// Every overlay under examples/gitops/envs/*/shop/* is built with kustomize
|
|
4
|
+
// - the binary when it is on the PATH, kubectl's copy otherwise - and what
|
|
5
|
+
// the environment says (the tag, the replica count, the variable) has to be
|
|
6
|
+
// what comes out. Then the tree is held to the snapshot in examples/argocd:
|
|
7
|
+
// an overlay that no Application in the snapshot deploys, or an Application
|
|
8
|
+
// pointing at a directory that is not here, is the two examples drifting
|
|
9
|
+
// apart, which is the one thing an example must not do.
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
17
|
+
const gitops = join(root, "examples", "gitops");
|
|
18
|
+
|
|
19
|
+
/** kustomize itself, else the copy kubectl ships; the output is the same. */
|
|
20
|
+
function kustomizeCommand() {
|
|
21
|
+
for (const candidate of ["kustomize"]) {
|
|
22
|
+
try {
|
|
23
|
+
execFileSync(candidate, ["version"], { stdio: "ignore" });
|
|
24
|
+
return [candidate, "build"];
|
|
25
|
+
} catch {
|
|
26
|
+
// Not on the PATH.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return ["kubectl", "kustomize"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [command, ...args] = kustomizeCommand();
|
|
33
|
+
const build = (dir) => execFileSync(command, [...args, dir], { encoding: "utf8" });
|
|
34
|
+
|
|
35
|
+
/** `<env>/shop/<service>` for every overlay in the tree, sorted. */
|
|
36
|
+
function overlays() {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const env of readdirSync(join(gitops, "envs")).sort()) {
|
|
39
|
+
const shop = join(gitops, "envs", env, "shop");
|
|
40
|
+
if (!existsSync(shop)) continue;
|
|
41
|
+
for (const service of readdirSync(shop).sort()) {
|
|
42
|
+
if (existsSync(join(shop, service, "kustomization.yaml"))) out.push({ env, service, dir: join(shop, service) });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("the example GitOps tree", () => {
|
|
49
|
+
it("has an overlay for cart in both environments and pricing in prod only", () => {
|
|
50
|
+
expect(overlays().map((o) => `${o.env}/${o.service}`)).toEqual(["prod/cart", "prod/pricing", "staging/cart"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders cart in prod at the tag prod runs, three replicas, labelled with the environment", () => {
|
|
54
|
+
const rendered = build(join(gitops, "envs", "prod", "shop", "cart"));
|
|
55
|
+
expect(rendered).toContain("image: ghcr.io/shortlink-org/cart:2.1.0");
|
|
56
|
+
expect(rendered).toContain("replicas: 3");
|
|
57
|
+
expect(rendered).toContain("env: prod");
|
|
58
|
+
expect(rendered).toContain("app.kubernetes.io/part-of: shop");
|
|
59
|
+
// The base's names and namespace survive the overlay.
|
|
60
|
+
expect(rendered).toMatch(/kind: Ingress[\s\S]*namespace: shop/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders cart in staging as the release candidate, one replica, NODE_ENV restated and nothing else lost", () => {
|
|
64
|
+
const rendered = build(join(gitops, "envs", "staging", "shop", "cart"));
|
|
65
|
+
expect(rendered).toContain("image: ghcr.io/shortlink-org/cart:2.2.0-rc.1");
|
|
66
|
+
expect(rendered).toContain("replicas: 1");
|
|
67
|
+
expect(rendered).toMatch(/name: NODE_ENV\n\s+value: staging/);
|
|
68
|
+
// Merged by name: the other variables the base declares are still there.
|
|
69
|
+
expect(rendered).toMatch(/name: PRICING_ADDR\n\s+value: pricing\.shop\.svc:9090/);
|
|
70
|
+
expect(rendered).not.toContain("value: production");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders pricing in prod with the longer cache and the secret untouched", () => {
|
|
74
|
+
const rendered = build(join(gitops, "envs", "prod", "shop", "pricing"));
|
|
75
|
+
expect(rendered).toContain("image: ghcr.io/shortlink-org/pricing:1.4.2");
|
|
76
|
+
expect(rendered).toContain("PRICE_LIST_TTL: 1h");
|
|
77
|
+
expect(rendered).toContain("OTEL_EXPORTER_OTLP_ENDPOINT");
|
|
78
|
+
expect(rendered).toContain("kind: Secret");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is what the snapshot in examples/argocd was recorded from", () => {
|
|
82
|
+
const snapshot = JSON.parse(readFileSync(join(root, "examples", "argocd", "argocd.apps.json"), "utf8"));
|
|
83
|
+
const placed = snapshot.deployments.filter((d) => d.project === "shop");
|
|
84
|
+
// One Application per overlay, named as the ApplicationSet names it,
|
|
85
|
+
// pointing at the overlay, labelled onto the service.
|
|
86
|
+
expect(placed.map((d) => [d.name, d.environment, d.path, d.service]).sort()).toEqual(
|
|
87
|
+
overlays().map((o) => [`shop-${o.service}-${o.env}`, o.env, `examples/gitops/envs/${o.env}/shop/${o.service}`, `shop.${o.service}`]).sort(),
|
|
88
|
+
);
|
|
89
|
+
// The image the snapshot says runs is the image the overlay renders.
|
|
90
|
+
for (const deployment of placed) {
|
|
91
|
+
const rendered = build(join(root, deployment.path));
|
|
92
|
+
for (const image of deployment.images ?? []) expect(rendered).toContain(`image: ${image}`);
|
|
93
|
+
}
|
|
94
|
+
// The clusters the environments name are the ones the snapshot places in.
|
|
95
|
+
for (const { env } of overlays()) {
|
|
96
|
+
const cluster = readFileSync(join(gitops, "envs", env, "cluster.yaml"), "utf8").match(/^\s+name: (.+)$/m)?.[1];
|
|
97
|
+
for (const deployment of placed.filter((d) => d.environment === env)) expect(deployment.cluster).toBe(cluster);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("names every Application in the shop ApplicationSet with the labels the fetcher places by", () => {
|
|
102
|
+
const appset = readFileSync(join(gitops, "appsets", "shop.yaml"), "utf8");
|
|
103
|
+
expect(appset).toContain("app.kubernetes.io/part-of: shop");
|
|
104
|
+
expect(appset).toContain('app.kubernetes.io/name: "{{.path.basename}}"');
|
|
105
|
+
expect(appset).toContain('name: "shop-{{.path.basename}}-{{.env}}"');
|
|
106
|
+
expect(appset).toContain("path: examples/gitops/envs/*/cluster.yaml");
|
|
107
|
+
});
|
|
108
|
+
});
|