@shortlink-org/portolan 0.2.4 → 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 +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +28 -15
- package/plugin/describe.go +156 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- 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 +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.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/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -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/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- 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 +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- 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 +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- 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/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- 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.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- 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 +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -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 +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- 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/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- 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 +116 -4
- 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/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -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 +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- 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 +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- 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/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
- 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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Host
|
|
2
2
|
|
|
3
3
|
The side of portolan that runs. `npm run gen` reads the manifest, runs every
|
|
4
|
-
plugin it names,
|
|
5
|
-
|
|
4
|
+
plugin it names, writes what comes back, merges the sources into one estate
|
|
5
|
+
and validates the union. Nothing here draws anything.
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -10,21 +10,25 @@ one estate and validates the union. Nothing here draws anything.
|
|
|
10
10
|
the schema `npm run schema` composes out of what every declared plugin says
|
|
11
11
|
it can be told.
|
|
12
12
|
- Runs three phases in order, extract, verify, generate, and hands each
|
|
13
|
-
plugin one JSON message: the input root
|
|
13
|
+
plugin one JSON message: the input root and output directory for an
|
|
14
14
|
extractor, the merged catalog for a verifier or a generator, the step's
|
|
15
15
|
options unread. The output path lets a fragment point at a companion file
|
|
16
16
|
returned in the same response without letting the plugin write it itself.
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
- Writes no provenance into a fragment. When a source last changed, and in
|
|
18
|
+
which commit, is read from the history of the checkout wherever the catalog
|
|
19
|
+
is read - here, by the LikeC4 generator, by the architecture diff and by
|
|
20
|
+
the site as Vite builds it - so a fragment is content and nothing else, and
|
|
21
|
+
regenerating from the same sources changes no byte (portolan.0010).
|
|
20
22
|
- Writes the files a plugin named, refuses a name that climbs out of the
|
|
21
23
|
step's output directory, keeps a listing per step of what it wrote, and
|
|
22
24
|
deletes what stopped being generated (portolan.0001).
|
|
23
25
|
- Merges every source the manifest's globs find and validates the union with
|
|
24
26
|
the code the site runs: `src/merge.ts`, `src/catalog.ts`, `src/enrich.ts`.
|
|
25
27
|
- In `--check` mode writes nothing and fails on the first file that differs
|
|
26
|
-
from disk
|
|
27
|
-
|
|
28
|
+
from disk, saying where the file first differs and what changed among the
|
|
29
|
+
step's inputs since its output was last committed - the history is the
|
|
30
|
+
record of the last generation, so nothing else has to be. Either way it
|
|
31
|
+
leaves `.portolan/build-report.json` for the Settings page.
|
|
28
32
|
|
|
29
33
|
## What it does not do
|
|
30
34
|
|
|
@@ -33,7 +37,9 @@ one estate and validates the union. Nothing here draws anything.
|
|
|
33
37
|
- Read a plugin's stdout for anything but the one message, or its stderr for
|
|
34
38
|
anything but notes; a warning is kept beside the step in the build report.
|
|
35
39
|
- Draw the C4 model: `gen-likec4.mjs` writes `likec4/` from the same merged
|
|
36
|
-
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`.
|
|
37
43
|
|
|
38
44
|
## Files
|
|
39
45
|
|
|
@@ -47,7 +53,9 @@ one estate and validates the union. Nothing here draws anything.
|
|
|
47
53
|
that report put on the pull request as one comment kept current;
|
|
48
54
|
`forge-release.mjs`, the same report as one section of a release's notes;
|
|
49
55
|
`forge.mjs`, what those two share - which forge the CI is, and how to talk
|
|
50
|
-
to it; `
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
to it; `history.mjs`, what the checkout's history says about every file,
|
|
57
|
+
for a plugin that asks and for the provenance of every source;
|
|
58
|
+
`provenance.mjs`, that provenance handed to the site as one virtual module;
|
|
59
|
+
`output-diff.mjs`, where a generated file first differs from what the
|
|
60
|
+
generator produces; `site-docs.mjs`, generated documentation put into the
|
|
61
|
+
built site; `local-api.mjs`, what the dev server answers the site with.
|
|
@@ -14,6 +14,7 @@ import { validateCatalog } from "../src/catalog.ts";
|
|
|
14
14
|
import { filterCatalogForProfile } from "../src/catalog-profile.ts";
|
|
15
15
|
import { enrichCatalog } from "../src/enrich.ts";
|
|
16
16
|
import { mergeCatalogs } from "../src/merge.ts";
|
|
17
|
+
import { stampsFor } from "./history.mjs";
|
|
17
18
|
import { readManifest } from "./manifest.mjs";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -48,10 +49,15 @@ export async function loadCatalog(manifestPath = "portolan.json", { exclude = []
|
|
|
48
49
|
);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
// A source is dated by the history, not by itself (portolan.0010): the
|
|
53
|
+
// commit that last changed the file, and its date, read here and never
|
|
54
|
+
// written into the file.
|
|
55
|
+
const stamps = stampsFor(process.cwd(), paths);
|
|
51
56
|
const merged = mergeCatalogs(
|
|
52
57
|
paths.map((path) => ({
|
|
53
58
|
path,
|
|
54
59
|
catalog: JSON.parse(readFileSync(path, "utf8")),
|
|
60
|
+
stamp: stamps.get(path),
|
|
55
61
|
})),
|
|
56
62
|
);
|
|
57
63
|
if (paths.length === 0) {
|
|
@@ -23,7 +23,10 @@ const GITLAB_START = "# >>> Portolan delivery preset >>>";
|
|
|
23
23
|
const GITLAB_END = "# <<< Portolan delivery preset <<<";
|
|
24
24
|
const PROVIDERS = new Set(["github", "gitlab"]);
|
|
25
25
|
const FEATURE_IDS = ["check", "diff", "sarif", "pages"];
|
|
26
|
-
const DEFAULT_FEATURES =
|
|
26
|
+
const DEFAULT_FEATURES = {
|
|
27
|
+
github: ["check", "pages"],
|
|
28
|
+
gitlab: ["pages"],
|
|
29
|
+
};
|
|
27
30
|
const FEATURE_DETAILS = {
|
|
28
31
|
check: {
|
|
29
32
|
label: "Architecture check",
|
|
@@ -76,11 +79,23 @@ function repositoryName(remote, workspace) {
|
|
|
76
79
|
return clean.split(/[/:]/).filter(Boolean).pop() || relative(dirname(workspace), workspace) || "repository";
|
|
77
80
|
}
|
|
78
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.
|
|
79
86
|
function defaultBranch(workspace) {
|
|
80
|
-
const
|
|
81
|
-
|
|
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);
|
|
82
97
|
for (const candidate of ["main", "master"]) {
|
|
83
|
-
if (
|
|
98
|
+
if (refs.has(`refs/heads/${candidate}`)) return candidate;
|
|
84
99
|
}
|
|
85
100
|
return git(workspace, ["branch", "--show-current"]) || "main";
|
|
86
101
|
}
|
|
@@ -225,7 +240,9 @@ ${upload}`;
|
|
|
225
240
|
|
|
226
241
|
function gitlabCheck() {
|
|
227
242
|
return `"portolan:check":
|
|
228
|
-
stage:
|
|
243
|
+
stage: test
|
|
244
|
+
tags:
|
|
245
|
+
- runner-type:docker
|
|
229
246
|
image:
|
|
230
247
|
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
231
248
|
entrypoint: [""]
|
|
@@ -242,7 +259,9 @@ function gitlabCheck() {
|
|
|
242
259
|
function gitlabDiff({ pages }) {
|
|
243
260
|
const site = pages ? ' --site "$CI_PAGES_URL"' : "";
|
|
244
261
|
return `"portolan:review":
|
|
245
|
-
stage:
|
|
262
|
+
stage: test
|
|
263
|
+
tags:
|
|
264
|
+
- runner-type:docker
|
|
246
265
|
image:
|
|
247
266
|
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
248
267
|
entrypoint: [""]
|
|
@@ -259,8 +278,10 @@ function gitlabDiff({ pages }) {
|
|
|
259
278
|
}
|
|
260
279
|
|
|
261
280
|
function gitlabPages() {
|
|
262
|
-
return `
|
|
263
|
-
stage:
|
|
281
|
+
return `pages:
|
|
282
|
+
stage: deploy
|
|
283
|
+
tags:
|
|
284
|
+
- runner-type:docker
|
|
264
285
|
image:
|
|
265
286
|
name: ghcr.io/shortlink-org/portolan:${VERSION}
|
|
266
287
|
entrypoint: [""]
|
|
@@ -268,9 +289,10 @@ function gitlabPages() {
|
|
|
268
289
|
GIT_DEPTH: "0"
|
|
269
290
|
script:
|
|
270
291
|
- BASE_PATH="$(node -p 'new URL(process.env.CI_PAGES_URL).pathname.replace(/\\/?$/, "/") || "/"')"
|
|
271
|
-
- portolan build --output
|
|
272
|
-
|
|
273
|
-
|
|
292
|
+
- portolan build --output public --base "$BASE_PATH"
|
|
293
|
+
artifacts:
|
|
294
|
+
paths:
|
|
295
|
+
- public
|
|
274
296
|
rules:
|
|
275
297
|
- if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
|
|
276
298
|
`;
|
|
@@ -299,15 +321,36 @@ function mergeGitlab(existing, features) {
|
|
|
299
321
|
.replace(/^\n+|\n+$/g, "");
|
|
300
322
|
return { content: content ? `${content}\n` : "" };
|
|
301
323
|
}
|
|
302
|
-
if (/^[
|
|
324
|
+
if (/^(?:["']?portolan:(?:check|review|pages)["']?|["']?pages["']?)\s*:/m.test(existing)) {
|
|
303
325
|
return { conflict: "This pipeline already declares a Portolan job outside the managed region." };
|
|
304
326
|
}
|
|
305
327
|
const block = gitlabBlock(features);
|
|
306
328
|
return { content: block ? `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${block}` : existing };
|
|
307
329
|
}
|
|
308
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
|
+
|
|
309
350
|
function textDiff(path, before, after) {
|
|
310
351
|
if (before === after) return "";
|
|
352
|
+
if (before === "") return wholeFileDiff(path, after, "+");
|
|
353
|
+
if (after === "") return wholeFileDiff(path, before, "-");
|
|
311
354
|
const holder = mkdtempSync(join(tmpdir(), "portolan-preset-diff-"));
|
|
312
355
|
const left = join(holder, "before");
|
|
313
356
|
const right = join(holder, "after");
|
|
@@ -421,14 +464,14 @@ function installedFeatures(workspace, provider) {
|
|
|
421
464
|
);
|
|
422
465
|
if (/^[ \t]*["']?portolan:check["']?\s*:/m.test(managed)) selected.add("check");
|
|
423
466
|
if (/^[ \t]*["']?portolan:review["']?\s*:/m.test(managed)) selected.add("diff");
|
|
424
|
-
if (/^[
|
|
467
|
+
if (/^(?:["']?portolan:pages["']?|["']?pages["']?)\s*:/m.test(managed)) selected.add("pages");
|
|
425
468
|
return selected;
|
|
426
469
|
}
|
|
427
470
|
|
|
428
471
|
function selectedFeatures(workspace, request, provider) {
|
|
429
472
|
const installed = request.features == null ? installedFeatures(workspace, provider) : null;
|
|
430
473
|
const raw = request.features == null
|
|
431
|
-
? installed.size > 0 ? [...installed] : DEFAULT_FEATURES
|
|
474
|
+
? installed.size > 0 ? [...installed] : DEFAULT_FEATURES[provider]
|
|
432
475
|
: Array.isArray(request.features)
|
|
433
476
|
? request.features
|
|
434
477
|
: String(request.features).split(",").filter(Boolean);
|
package/scripts/diff.mjs
CHANGED
|
@@ -157,8 +157,12 @@ function catalogAt(ref) {
|
|
|
157
157
|
throw new Error(ref + " holds no catalog sources matching " + JSON.stringify(manifest.sources));
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// The base is one commit, and a commit is its own provenance: every source
|
|
161
|
+
// there is dated by it (portolan.0010), whatever an older fragment says.
|
|
162
|
+
const [commit = "", generatedAt = ""] = git(["log", "-1", "--format=%h %cI", ref]).split(" ");
|
|
163
|
+
const stamp = { commit, generatedAt };
|
|
160
164
|
const merged = mergeCatalogs(
|
|
161
|
-
paths.map((path) => ({ path, catalog: JSON.parse(git(["show", ref + ":" + path])) })),
|
|
165
|
+
paths.map((path) => ({ path, catalog: JSON.parse(git(["show", ref + ":" + path])), stamp })),
|
|
162
166
|
);
|
|
163
167
|
|
|
164
168
|
// Enriched before it is compared, exactly as the app and the generators see
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { djangoAggregateProposals, saveDjangoAggregates } from "./local-api.mjs";
|
|
7
|
+
|
|
8
|
+
const roots = [];
|
|
9
|
+
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
|
10
|
+
function fixture() {
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), "portolan-aggregates-")); roots.push(root);
|
|
12
|
+
const manifest = { sources: ["data/*.json"], extract: ["one", "two"].map((name) => ({ plugin: "django-domain", in: name, out: `data/${name}`, options: { context: name, service: name, aggregates: { existing: "Existing" }, apps: ["billing.records"] } })) };
|
|
13
|
+
const text = JSON.stringify(manifest);
|
|
14
|
+
writeFileSync(join(root, "portolan.json"), text);
|
|
15
|
+
const message = `billing/records: no model called Records, and 2 models to choose from: name the root in the aggregates option; aggregate candidates: ${JSON.stringify({ app: "billing.records", models: [{ name: "Entry", path: "billing/records/models.py", line: 1 }, { name: "Audit", path: "billing/records/models.py", line: 10 }] })}`;
|
|
16
|
+
const report = { status: "ok", manifestSha256: createHash("sha256").update(text).digest("hex"), steps: manifest.extract.map((step) => ({ phase: "extract", plugin: step.plugin, input: step.in, output: step.out, warnings: [message] })) };
|
|
17
|
+
mkdirSync(join(root, ".portolan")); writeFileSync(join(root, ".portolan/build-report.json"), JSON.stringify(report));
|
|
18
|
+
return { root, manifest, report };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("saving Django aggregate roots", () => {
|
|
22
|
+
it("targets the precise step and dotted app, preserving unrelated options", () => {
|
|
23
|
+
const { root, manifest } = fixture();
|
|
24
|
+
const proposals = djangoAggregateProposals(root);
|
|
25
|
+
expect(proposals.stale).toBe(false);
|
|
26
|
+
expect(proposals.proposals).toHaveLength(2);
|
|
27
|
+
saveDjangoAggregates(root, { revision: proposals.revision, selections: [{ id: "1:billing.records", model: "Entry" }] });
|
|
28
|
+
const saved = JSON.parse(readFileSync(join(root, "portolan.json"), "utf8"));
|
|
29
|
+
expect(saved.extract[0]).toEqual(manifest.extract[0]);
|
|
30
|
+
expect(saved.extract[1].options).toEqual({ ...manifest.extract[1].options, aggregates: { existing: "Existing", "billing.records": "Entry" } });
|
|
31
|
+
expect(djangoAggregateProposals(root).stale).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("saves a batch atomically and rejects forged, duplicate, or stale choices", () => {
|
|
35
|
+
const { root } = fixture();
|
|
36
|
+
const { revision } = djangoAggregateProposals(root);
|
|
37
|
+
const original = readFileSync(join(root, "portolan.json"), "utf8");
|
|
38
|
+
for (const selections of [[], [{ id: "0:billing.records", model: "Proxy" }], [{ id: "no-such-step", model: "Entry" }], [{ id: "0:billing.records", model: "Entry" }, { id: "1:billing.records", model: "Invalid" }], [{ id: "0:billing.records", model: "Entry" }, { id: "0:billing.records", model: "Audit" }]]) {
|
|
39
|
+
expect(() => saveDjangoAggregates(root, { revision, selections })).toThrow();
|
|
40
|
+
expect(readFileSync(join(root, "portolan.json"), "utf8")).toBe(original);
|
|
41
|
+
}
|
|
42
|
+
const selections = [{ id: "0:billing.records", model: "Entry" }, { id: "1:billing.records", model: "Audit" }];
|
|
43
|
+
expect(() => saveDjangoAggregates(root, { revision: "old", selections })).toThrow(/changed/);
|
|
44
|
+
expect(saveDjangoAggregates(root, { revision, selections })).toEqual({ saved: 2 });
|
|
45
|
+
expect(() => saveDjangoAggregates(root, { revision, selections })).toThrow(/changed/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("requires fresh report evidence and refuses ambiguous step bindings", () => {
|
|
49
|
+
const { root, manifest, report } = fixture();
|
|
50
|
+
manifest.extract.push(manifest.extract[0]);
|
|
51
|
+
writeFileSync(join(root, "portolan.json"), JSON.stringify(manifest));
|
|
52
|
+
expect(djangoAggregateProposals(root).proposals.map((p) => p.id)).toEqual(["1:billing.records"]);
|
|
53
|
+
expect(djangoAggregateProposals(root).stale).toBe(true);
|
|
54
|
+
report.steps = [];
|
|
55
|
+
writeFileSync(join(root, ".portolan/build-report.json"), JSON.stringify(report));
|
|
56
|
+
expect(djangoAggregateProposals(root).proposals).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
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
|
|
|
@@ -278,7 +300,7 @@ for (const context of catalog.contexts) {
|
|
|
278
300
|
model.push(` style { color ${contextColorName(context.id)} }`);
|
|
279
301
|
for (const aggregate of service.aggregates) {
|
|
280
302
|
model.push(
|
|
281
|
-
` ${safeId(aggregate.slug)} = aggregate ${q(aggregate.name)} {`,
|
|
303
|
+
` ${safeId(aggregate.slug)} = aggregate ${q(aggregate.kind === "model-group" ? `${aggregate.name} (model group)` : aggregate.name)} {`,
|
|
282
304
|
);
|
|
283
305
|
for (const event of aggregate.events) {
|
|
284
306
|
const latest = event.versions[event.versions.length - 1];
|
|
@@ -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
|
});
|