@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.
Files changed (291) hide show
  1. package/README.md +174 -6
  2. package/catalog/enum_test.go +46 -0
  3. package/catalog/evidence_test.go +35 -0
  4. package/catalog/model.go +1213 -0
  5. package/catalog/roundtrip_test.go +210 -0
  6. package/catalog/via_test.go +38 -0
  7. package/cli/init.test.mjs +6 -1
  8. package/cli/portolan.mjs +14 -1
  9. package/cli/portolan.test.mjs +49 -0
  10. package/go.mod +21 -0
  11. package/go.sum +34 -0
  12. package/internal/gocall/README.md +19 -0
  13. package/internal/gocall/analyze.go +189 -0
  14. package/internal/gocall/analyze_test.go +107 -0
  15. package/internal/gohttp/analyze.go +2562 -0
  16. package/internal/gohttp/destination.go +373 -0
  17. package/internal/gohttp/endpoints.go +1067 -0
  18. package/internal/gohttp/roots.go +320 -0
  19. package/internal/gohttp/typed.go +96 -0
  20. package/internal/goscan/constants.go +85 -0
  21. package/internal/goscan/goscan_test.go +227 -0
  22. package/internal/goscan/index.go +629 -0
  23. package/internal/goscan/index_test.go +66 -0
  24. package/internal/goscan/names.go +52 -0
  25. package/internal/goscan/parse_test.go +11 -0
  26. package/internal/goscan/source.go +37 -0
  27. package/internal/goscan/tree.go +284 -0
  28. package/internal/goscan/types.go +99 -0
  29. package/internal/wsdl/ids.go +127 -0
  30. package/internal/wsdl/ids_test.go +21 -0
  31. package/internal/wsdl/model.go +70 -0
  32. package/internal/wsdl/parse.go +949 -0
  33. package/internal/wsdl/parse_test.go +170 -0
  34. package/package.json +28 -15
  35. package/plugin/describe.go +156 -0
  36. package/plugin/describe_test.go +114 -0
  37. package/plugin/protocol.go +141 -0
  38. package/plugin/schematest/schematest.go +126 -0
  39. package/plugins/README.md +404 -50
  40. package/plugins/cmd/portolan-http-clients/main.go +19 -0
  41. package/plugins/extract-argocd/options.schema.json +44 -0
  42. package/plugins/extract-celery/extract.py +0 -2
  43. package/plugins/extract-celery/extract_test.py +1 -1
  44. package/plugins/extract-celery/main.py +1 -0
  45. package/plugins/extract-csharp-ddd/README.md +213 -0
  46. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  47. package/plugins/extract-django/README.md +52 -19
  48. package/plugins/extract-django/choices.py +47 -0
  49. package/plugins/extract-django/domain.py +30 -18
  50. package/plugins/extract-django/extract.py +21 -7
  51. package/plugins/extract-django/extract_test.py +68 -2
  52. package/plugins/extract-django/lifecycle.py +4 -28
  53. package/plugins/extract-django/main.py +1 -0
  54. package/plugins/extract-django/operations.py +1 -1
  55. package/plugins/extract-django/routing_test.py +109 -1
  56. package/plugins/extract-django/rules.py +154 -0
  57. package/plugins/extract-django/rules_test.py +158 -0
  58. package/plugins/extract-django/store.py +1 -1
  59. package/plugins/extract-django/transport.py +101 -55
  60. package/plugins/extract-django/verbs.py +241 -0
  61. package/plugins/extract-go/README.md +47 -0
  62. package/plugins/extract-go-sqs/options.schema.json +21 -0
  63. package/plugins/extract-http-clients/describe.go +20 -0
  64. package/plugins/extract-http-clients/describe_test.go +11 -0
  65. package/plugins/extract-http-clients/extract.go +740 -0
  66. package/plugins/extract-http-clients/extract_test.go +1561 -0
  67. package/plugins/extract-http-clients/main.go +41 -0
  68. package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
  69. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  70. package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
  71. package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
  72. package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
  73. package/plugins/extract-k8s/options.schema.json +32 -0
  74. package/plugins/extract-laravel/Cargo.lock +962 -0
  75. package/plugins/extract-laravel/Cargo.toml +20 -0
  76. package/plugins/extract-laravel/README.md +200 -0
  77. package/plugins/extract-laravel/options.schema.json +68 -0
  78. package/plugins/extract-laravel/rustfmt.toml +1 -0
  79. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  80. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  81. package/plugins/extract-php-ddd/README.md +141 -0
  82. package/plugins/extract-php-ddd/options.schema.json +50 -0
  83. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  84. package/plugins/extract-python-kafka/extract.py +0 -2
  85. package/plugins/extract-python-kafka/extract_test.py +1 -1
  86. package/plugins/extract-python-kafka/main.py +1 -0
  87. package/plugins/extract-terraform/options.schema.json +26 -0
  88. package/plugins/extract-ts/extract.test.ts +2 -2
  89. package/plugins/extract-ts/extract.ts +4 -5
  90. package/plugins/extract-ts/graphql.test.ts +1 -1
  91. package/plugins/extract-ts/main.ts +1 -0
  92. package/plugins/openapi/ids.go +261 -0
  93. package/plugins/openapi/ids_test.go +98 -0
  94. package/plugins/phpscan/Cargo.lock +953 -0
  95. package/plugins/phpscan/Cargo.toml +19 -0
  96. package/plugins/phpscan/rustfmt.toml +1 -0
  97. package/plugins/portolan-go.wasm +0 -0
  98. package/plugins/pyplugin/catalog.py +24 -1
  99. package/plugins/pyplugin/protocol.py +1 -5
  100. package/plugins/verify-otel/options.schema.json +12 -0
  101. package/portolan.json +132 -4
  102. package/rules/builtin.json +218 -0
  103. package/schema/portolan.schema.json +905 -4
  104. package/scripts/README.md +21 -13
  105. package/scripts/catalog-sources.mjs +6 -0
  106. package/scripts/delivery-presets.mjs +57 -14
  107. package/scripts/diff.mjs +5 -1
  108. package/scripts/django-aggregates.test.mjs +58 -0
  109. package/scripts/gen-likec4.mjs +150 -17
  110. package/scripts/gen-likec4.test.mjs +96 -0
  111. package/scripts/gen.mjs +148 -118
  112. package/scripts/gitops-example.test.mjs +108 -0
  113. package/scripts/go-discovery.test.mjs +30 -0
  114. package/scripts/history.mjs +186 -3
  115. package/scripts/history.test.mjs +1 -1
  116. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  117. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  118. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  119. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  120. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  121. package/scripts/host-plugins/fetch-git.mjs +78 -21
  122. package/scripts/host-plugins/fetch-git.test.mjs +62 -8
  123. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  124. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  125. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  126. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  127. package/scripts/json-format.mjs +192 -0
  128. package/scripts/json-format.test.mjs +97 -0
  129. package/scripts/local-api.mjs +287 -12
  130. package/scripts/local-api.test.mjs +150 -5
  131. package/scripts/local-discovery.mjs +94 -9
  132. package/scripts/manifest.mjs +14 -3
  133. package/scripts/manifest.test.mjs +24 -0
  134. package/scripts/output-diff.mjs +94 -0
  135. package/scripts/output-diff.test.mjs +36 -0
  136. package/scripts/package-smoke.mjs +62 -4
  137. package/scripts/plugin-host.mjs +45 -3
  138. package/scripts/plugin-host.test.mjs +9 -0
  139. package/scripts/plugin-wasm-worker.mjs +4 -1
  140. package/scripts/plugins-fresh.mjs +97 -0
  141. package/scripts/plugins-fresh.test.mjs +64 -0
  142. package/scripts/provenance.mjs +72 -0
  143. package/scripts/provenance.test.mjs +149 -0
  144. package/scripts/run-builtin.mjs +62 -7
  145. package/scripts/schema.mjs +167 -19
  146. package/scripts/trace-trials.mjs +176 -0
  147. package/scripts/trace-trials.test.mjs +142 -0
  148. package/scripts/warning-policy.mjs +167 -0
  149. package/scripts/warning-policy.test.mjs +93 -0
  150. package/src/app/Breadcrumbs.test.ts +3 -0
  151. package/src/app/Breadcrumbs.tsx +3 -0
  152. package/src/app/CatalogApp.tsx +2 -0
  153. package/src/app/Sidebar.tsx +3 -3
  154. package/src/app/SidebarFooter.tsx +20 -4
  155. package/src/catalog-docs.test.ts +64 -0
  156. package/src/catalog-docs.ts +35 -0
  157. package/src/catalog-error.test.ts +15 -0
  158. package/src/catalog-index.ts +25 -0
  159. package/src/catalog-model.ts +293 -5
  160. package/src/catalog-validation.ts +113 -2
  161. package/src/catalog.test.ts +40 -1
  162. package/src/chat/Starter.tsx +5 -11
  163. package/src/chat/tools.test.ts +27 -0
  164. package/src/chat/tools.ts +5 -9
  165. package/src/components/CatalogStamp.tsx +10 -8
  166. package/src/components/ChannelRows.test.tsx +76 -1
  167. package/src/components/ChannelRows.tsx +223 -60
  168. package/src/components/DeploymentRows.tsx +131 -0
  169. package/src/components/DocsLinks.test.tsx +27 -0
  170. package/src/components/DocsLinks.tsx +56 -0
  171. package/src/components/FieldTree.tsx +5 -2
  172. package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
  173. package/src/components/HTTPDestinationEvidence.tsx +31 -0
  174. package/src/components/Integrations.tsx +1 -1
  175. package/src/components/MachineDocs.tsx +6 -5
  176. package/src/components/MethodRows.tsx +9 -2
  177. package/src/components/PluginIcon.tsx +77 -0
  178. package/src/components/ProblemRow.tsx +173 -153
  179. package/src/components/RelationEvidence.test.tsx +14 -0
  180. package/src/components/RelationEvidence.tsx +53 -0
  181. package/src/components/RuleMarks.tsx +22 -0
  182. package/src/components/ShapeRows.tsx +24 -20
  183. package/src/data.ts +46 -7
  184. package/src/enrich.test.ts +459 -4
  185. package/src/enrich.ts +308 -7
  186. package/src/er/ErCanvas.tsx +218 -12
  187. package/src/er/GroupNode.tsx +57 -0
  188. package/src/er/StoreHeader.tsx +1 -0
  189. package/src/er/layout.test.ts +85 -2
  190. package/src/er/layout.ts +140 -5
  191. package/src/er/spec.test.ts +17 -0
  192. package/src/er/spec.ts +23 -10
  193. package/src/flow/Recordings.test.tsx +52 -0
  194. package/src/flow/Recordings.tsx +236 -0
  195. package/src/flow/StepDetail.tsx +59 -0
  196. package/src/flow/TraceTrial.tsx +419 -0
  197. package/src/flow/evidence.test.ts +16 -0
  198. package/src/flow/evidence.ts +34 -0
  199. package/src/flow/examples.test.ts +33 -0
  200. package/src/flow/examples.ts +37 -0
  201. package/src/flow/outline.test.ts +28 -0
  202. package/src/flow/outline.ts +7 -2
  203. package/src/flow/trace-trial-resume.test.ts +42 -0
  204. package/src/flow/trace-trial-resume.ts +74 -0
  205. package/src/graph/elk.ts +78 -0
  206. package/src/index.css +68 -0
  207. package/src/landing/DraggableReveal.tsx +3 -2
  208. package/src/landing/EvidencePipeline.tsx +105 -0
  209. package/src/landing/LandingPage.tsx +17 -70
  210. package/src/landing/ProductTour.tsx +6 -6
  211. package/src/lib/all-problems.ts +27 -17
  212. package/src/lib/catalog-diff.ts +1 -1
  213. package/src/lib/centrality.test.ts +251 -0
  214. package/src/lib/centrality.ts +232 -0
  215. package/src/lib/confluence.test.ts +37 -0
  216. package/src/lib/confluence.ts +41 -0
  217. package/src/lib/context-color.ts +1 -1
  218. package/src/lib/deployment-drift.ts +22 -0
  219. package/src/lib/derive.ts +43 -69
  220. package/src/lib/django-aggregates.d.mts +9 -0
  221. package/src/lib/django-aggregates.mjs +36 -0
  222. package/src/lib/django-aggregates.test.ts +29 -0
  223. package/src/lib/django-aggregates.ts +5 -0
  224. package/src/lib/environments.test.ts +83 -0
  225. package/src/lib/environments.ts +57 -0
  226. package/src/lib/integration-url.test.ts +30 -0
  227. package/src/lib/integration-url.ts +63 -0
  228. package/src/lib/kafka-ui.ts +3 -45
  229. package/src/lib/local-api.ts +116 -4
  230. package/src/lib/notion.ts +13 -0
  231. package/src/lib/plugin-index.json +2765 -0
  232. package/src/lib/plugins.test.ts +68 -0
  233. package/src/lib/plugins.ts +264 -0
  234. package/src/lib/problem-flows.test.ts +61 -0
  235. package/src/lib/problem-flows.ts +78 -0
  236. package/src/lib/problem-rules-cel.d.mts +43 -0
  237. package/src/lib/problem-rules-cel.mjs +407 -0
  238. package/src/lib/problem-rules.test.ts +287 -0
  239. package/src/lib/problem-rules.ts +271 -0
  240. package/src/lib/problem-subjects.ts +737 -0
  241. package/src/lib/rule-entries.ts +39 -0
  242. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  243. package/src/lib/rules-deploy.test.ts +133 -0
  244. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  245. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  246. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  247. package/src/lib/rules.test.ts +51 -0
  248. package/src/lib/rules.ts +86 -0
  249. package/src/lib/setup-info.test.ts +17 -0
  250. package/src/lib/setup-info.ts +58 -0
  251. package/src/lib/shape.test.ts +32 -0
  252. package/src/lib/shape.ts +30 -6
  253. package/src/lib/tech.ts +16 -0
  254. package/src/lib/trace-project.test.ts +34 -0
  255. package/src/lib/trace-project.ts +50 -0
  256. package/src/lib/use-problems.ts +23 -0
  257. package/src/lib/warnings.test.ts +63 -0
  258. package/src/lib/warnings.ts +260 -0
  259. package/src/likec4/ids.test.ts +6 -2
  260. package/src/likec4/ids.ts +43 -0
  261. package/src/main.tsx +23 -0
  262. package/src/map/ContextMapGraph.tsx +76 -32
  263. package/src/merge-deployments.test.ts +127 -0
  264. package/src/merge.test.ts +82 -0
  265. package/src/merge.ts +189 -18
  266. package/src/pages/AggregatePage.tsx +65 -14
  267. package/src/pages/ContextMap.tsx +45 -3
  268. package/src/pages/ContextPage.tsx +8 -5
  269. package/src/pages/EventPage.tsx +15 -5
  270. package/src/pages/FlowDetail.tsx +23 -2
  271. package/src/pages/GraphPage.tsx +40 -3
  272. package/src/pages/Overview.tsx +152 -12
  273. package/src/pages/PluginIndex.tsx +190 -0
  274. package/src/pages/Problems.tsx +396 -128
  275. package/src/pages/ServicePage.tsx +62 -5
  276. package/src/pages/Settings.tsx +217 -43
  277. package/src/pages/settings/AboutSettings.tsx +8 -1
  278. package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
  279. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  280. package/src/pages/settings/RecordingSettings.tsx +138 -0
  281. package/src/pages/settings/RulesSettings.tsx +825 -0
  282. package/src/routes.test.ts +9 -0
  283. package/src/routes.ts +22 -1
  284. package/src/selection/DetailPanel.tsx +15 -0
  285. package/src/virtual-provenance.d.ts +11 -0
  286. package/vite.config.ts +5 -0
  287. package/scripts/vendor-lock.mjs +0 -58
  288. package/scripts/vendor-lock.test.mjs +0 -69
  289. package/src/lib/data-problems.ts +0 -314
  290. package/src/lib/proto-problems.ts +0 -237
  291. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,263 @@
1
+ // fetch-k8s, run by the host: a live cluster on one side, a catalog fragment
2
+ // on the other. It asks `kubectl` for the workloads, Services, Ingresses,
3
+ // routes and ConfigMaps of the namespaces named, and answers with the names
4
+ // each service answers on and the in-cluster names it dials - the same two
5
+ // lists extract-k8s reads out of the manifests in a repository, from the
6
+ // cluster that actually runs them.
7
+ //
8
+ // It runs in the host (portolan.0008) because it needs a binary, a socket
9
+ // and a credential. The credential is kubectl's own, from the kubeconfig,
10
+ // and never in the manifest.
11
+ //
12
+ // Nothing the cluster says is written down but names. The objects live in
13
+ // this process for the length of one call; a Secret is never asked for; a
14
+ // value from env or a ConfigMap is reduced to a host or to nothing; and a
15
+ // warning names a namespace or a kind, never a value. What kubectl printed
16
+ // on stderr goes to the terminal and not into the build report.
17
+ //
18
+ // Offline, or when the cluster cannot be reached, the fragment committed in
19
+ // the repository is used unchanged - the same rule as fetch-git, for the
20
+ // same reason: a run that lost the cluster must not lose the catalog.
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ import optionsSchema from "./fetch-k8s.options.json" with { type: "json" };
27
+ import { topology } from "./k8s-topology.mjs";
28
+
29
+ export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
30
+ export const DEFAULT_OUT = "k8s-cluster.json";
31
+ export const DEFAULT_LABELS = Object.freeze({
32
+ context: "app.kubernetes.io/part-of",
33
+ service: "app.kubernetes.io/name",
34
+ });
35
+
36
+ // What is asked for, and the whole of it. Secrets are not a kind this list
37
+ // could grow to hold: the reader has no use for one.
38
+ const KINDS = ["deployments", "statefulsets", "daemonsets", "replicasets", "jobs", "cronjobs", "services", "ingresses", "configmaps"];
39
+ const ROUTE_KINDS = ["httproutes.gateway.networking.k8s.io", "grpcroutes.gateway.networking.k8s.io"];
40
+
41
+ export function describe() {
42
+ return {
43
+ name: "fetch-k8s",
44
+ summary: "Reads the workloads, Services, Ingresses and routes of a live cluster through kubectl into the names each service answers on and dials; values and secrets are never kept.",
45
+ category: "infrastructure",
46
+ phases: ["extract"],
47
+ options: optionsSchema,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @param {{options?: object}} request
53
+ * @param {{env?: NodeJS.ProcessEnv, exec?: (args: string[]) => string, stderr?: (text: string) => void}} [io]
54
+ */
55
+ export function run(request, { env = process.env, exec = kubectl, stderr = (text) => process.stderr.write(text) } = {}) {
56
+ const options = request.options ?? {};
57
+ if (!options.cache) {
58
+ throw new Error("no cache directory: set `cache` to the same path as the step's `out`, so an offline run can replay what the last online one wrote");
59
+ }
60
+ const out = String(options.out ?? "").trim() || DEFAULT_OUT;
61
+ const at = join(options.cache, out);
62
+ const builder = new Builder();
63
+
64
+ if (offline(env)) {
65
+ emitCached(builder, at, out, "offline");
66
+ return builder.response();
67
+ }
68
+
69
+ let objects;
70
+ try {
71
+ objects = read(exec, options, builder, stderr);
72
+ } catch (cause) {
73
+ try {
74
+ emitCached(builder, at, out, cause.message);
75
+ } catch (cacheCause) {
76
+ throw new Error(`the cluster could not be read (${cause.message}) and there is no usable fragment in the tree (${cacheCause.message})`);
77
+ }
78
+ return builder.response();
79
+ }
80
+
81
+ builder.file(out, fragment(objects, options, builder));
82
+ return builder.response();
83
+ }
84
+
85
+ /** Whether to open a socket. Explicit, and CI verifies what was committed. */
86
+ export function offline(env = process.env) {
87
+ if (String(env[OFFLINE_ENV] ?? "").trim()) return true;
88
+ return !["", "0", "false"].includes(String(env.CI ?? "").trim().toLowerCase());
89
+ }
90
+
91
+ /** The label keys the manifest chose, over the conventional ones. */
92
+ export function labelsOf(options) {
93
+ const declared = options.labels ?? {};
94
+ return {
95
+ context: String(declared.context ?? "").trim() || DEFAULT_LABELS.context,
96
+ service: String(declared.service ?? "").trim() || DEFAULT_LABELS.service,
97
+ };
98
+ }
99
+
100
+ function kubectl(args) {
101
+ return execFileSync("kubectl", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 });
102
+ }
103
+
104
+ /**
105
+ * One `kubectl get` per namespace, or one across all of them. The kinds a
106
+ * Gateway API cluster serves are asked for separately, because a cluster
107
+ * without the CRDs refuses the whole request otherwise.
108
+ */
109
+ function read(exec, options, builder, stderr) {
110
+ const namespaces = Array.isArray(options.namespaces) ? options.namespaces.map((ns) => String(ns).trim()).filter(Boolean).sort() : [];
111
+ const scopes = namespaces.length ? namespaces.map((ns) => ["-n", ns]) : [["-A"]];
112
+ const base = String(options.kubeContext ?? "").trim() ? ["--context", String(options.kubeContext).trim()] : [];
113
+ const objects = [];
114
+ for (const scope of scopes) {
115
+ objects.push(...items(call(exec, [...base, "get", KINDS.join(","), ...scope, "-o", "json"], stderr)));
116
+ }
117
+ let routesMissing = false;
118
+ for (const scope of scopes) {
119
+ for (const kind of ROUTE_KINDS) {
120
+ try {
121
+ objects.push(...items(call(exec, [...base, "get", kind, ...scope, "-o", "json"], stderr)));
122
+ } catch {
123
+ routesMissing = true;
124
+ }
125
+ }
126
+ }
127
+ if (routesMissing) builder.warn("", "the cluster serves no Gateway API routes, or refused to list them; hosts of HTTPRoute and GRPCRoute were not read");
128
+ return objects;
129
+ }
130
+
131
+ /**
132
+ * Runs one kubectl. What kubectl says on stderr reaches the terminal, not
133
+ * the build report: a connection error names the server, and that is a
134
+ * fact for the person at the keyboard and not for a committed file.
135
+ */
136
+ function call(exec, args, stderr) {
137
+ try {
138
+ return exec(args);
139
+ } catch (cause) {
140
+ const said = String(cause.stderr ?? "").trim();
141
+ if (said) stderr(`kubectl ${args.join(" ")}:\n${said}\n`);
142
+ const status = Number.isInteger(cause.status) ? `exit ${cause.status}` : cause.code === "ENOENT" ? "kubectl is not on PATH" : "kubectl failed";
143
+ throw new Error(`kubectl get ${args[args.indexOf("get") + 1] ?? ""}: ${status}`);
144
+ }
145
+ }
146
+
147
+ function items(raw) {
148
+ let parsed;
149
+ try {
150
+ parsed = JSON.parse(raw);
151
+ } catch (cause) {
152
+ throw new Error(`kubectl's answer is not JSON: ${cause.message}`);
153
+ }
154
+ const list = Array.isArray(parsed?.items) ? parsed.items : parsed?.kind && parsed?.metadata ? [parsed] : [];
155
+ return list.filter((item) => item && typeof item === "object" && item.kind !== "Secret");
156
+ }
157
+
158
+ /**
159
+ * The fragment: every workload the labels place into a context and a
160
+ * service, folded by service. A workload the labels do not place and the
161
+ * namespace map does not claim is counted in a warning, not guessed at.
162
+ */
163
+ export function fragment(objects, options, builder) {
164
+ const labels = labelsOf(options);
165
+ const namespaceContexts = options.namespaceContexts ?? {};
166
+ const services = new Map();
167
+ const unmapped = new Map();
168
+
169
+ for (const entry of topology(objects)) {
170
+ const meta = entry.object.metadata ?? {};
171
+ const ns = meta.namespace ?? "";
172
+ const all = { ...(podTemplateLabels(entry.object)), ...(meta.labels ?? {}) };
173
+ const context = String(all[labels.context] ?? "").trim() || String(namespaceContexts[ns] ?? "").trim();
174
+ if (!context) {
175
+ unmapped.set(ns, (unmapped.get(ns) ?? 0) + 1);
176
+ continue;
177
+ }
178
+ const slug = String(all[labels.service] ?? "").trim() || String(meta.name ?? "");
179
+ const id = `${context}.${slug}`;
180
+ const service = services.get(id) ?? { id, context, slug, kinds: new Set(), hosts: new Set(), dials: new Set() };
181
+ service.kinds.add(entry.kind);
182
+ for (const host of entry.hosts) service.hosts.add(host);
183
+ for (const dial of entry.dials) service.dials.add(dial);
184
+ services.set(id, service);
185
+ }
186
+
187
+ for (const ns of [...unmapped.keys()].sort()) {
188
+ builder.warn(ns || "cluster", `${unmapped.get(ns)} workload(s) carry no "${labels.context}" label and the namespace is not in namespaceContexts; they were left out`);
189
+ }
190
+
191
+ const contexts = new Map();
192
+ for (const id of [...services.keys()].sort()) {
193
+ const service = services.get(id);
194
+ const context = contexts.get(service.context) ?? { id: service.context, slug: service.context, name: "", summary: "", services: [] };
195
+ const record = {
196
+ id: service.id,
197
+ slug: service.slug,
198
+ name: "",
199
+ repo: "",
200
+ path: "",
201
+ readme: "",
202
+ provides: [],
203
+ consumes: [],
204
+ aggregates: [],
205
+ };
206
+ // A service that is only jobs is a job; one with a Deployment beside
207
+ // its CronJob is whatever its code says it is.
208
+ if (service.kinds.size === 1 && service.kinds.has("job")) record.kind = "job";
209
+ const dials = [...service.dials].filter((dial) => !service.hosts.has(dial)).sort();
210
+ if (service.hosts.size) record.hosts = [...service.hosts].sort();
211
+ if (dials.length) record.dials = dials;
212
+ context.services.push(record);
213
+ contexts.set(service.context, context);
214
+ }
215
+
216
+ const catalog = {
217
+ contexts: [...contexts.keys()].sort().map((id) => contexts.get(id)),
218
+ defs: {},
219
+ flows: [],
220
+ adrs: [],
221
+ };
222
+ return `${JSON.stringify(catalog, null, 2)}\n`;
223
+ }
224
+
225
+ function podTemplateLabels(object) {
226
+ const spec = object.spec ?? {};
227
+ const template = object.kind === "CronJob" ? spec.jobTemplate?.spec?.template : spec.template;
228
+ return template?.metadata?.labels ?? {};
229
+ }
230
+
231
+ function emitCached(builder, at, out, why) {
232
+ let raw;
233
+ try {
234
+ raw = readFileSync(at, "utf8");
235
+ } catch (cause) {
236
+ if (cause.code === "ENOENT") throw new Error(`no ${out} in ${at.split("\\").join("/").slice(0, -out.length - 1) || "."}`);
237
+ throw cause;
238
+ }
239
+ try {
240
+ JSON.parse(raw);
241
+ } catch (cause) {
242
+ throw new Error(`${out} is not JSON: ${cause.message}`);
243
+ }
244
+ builder.file(out, raw);
245
+ builder.warn("", `the cluster was not read (${why}); the fragment committed in this repository is used unchanged`);
246
+ }
247
+
248
+ class Builder {
249
+ files = [];
250
+ warnings = [];
251
+
252
+ file(name, contents) {
253
+ this.files.push({ name, contents });
254
+ }
255
+
256
+ warn(ref, message) {
257
+ this.warnings.push({ severity: "warning", message, ref });
258
+ }
259
+
260
+ response() {
261
+ return { files: this.files, warnings: this.warnings };
262
+ }
263
+ }
@@ -0,0 +1,50 @@
1
+ {
2
+ "type": "object",
3
+ "additionalProperties": false,
4
+ "required": ["cache"],
5
+ "properties": {
6
+ "kubeContext": {
7
+ "type": "string",
8
+ "description": "The kubeconfig context to read, passed to kubectl as --context. Left out, kubectl's current context.",
9
+ "examples": ["prod-eu"]
10
+ },
11
+ "namespaces": {
12
+ "type": "array",
13
+ "items": { "type": "string" },
14
+ "description": "The namespaces to read. Left out, every namespace the credential can list.",
15
+ "examples": [["shop", "auth", "payments"]]
16
+ },
17
+ "labels": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "description": "Which labels place a workload into the catalog. The defaults are the Kubernetes recommended labels; an estate that labels differently names its own keys here.",
21
+ "properties": {
22
+ "context": {
23
+ "type": "string",
24
+ "description": "The label whose value is the bounded context's slug.",
25
+ "default": "app.kubernetes.io/part-of"
26
+ },
27
+ "service": {
28
+ "type": "string",
29
+ "description": "The label whose value is the service's slug. A workload without it is named after itself.",
30
+ "default": "app.kubernetes.io/name"
31
+ }
32
+ }
33
+ },
34
+ "namespaceContexts": {
35
+ "type": "object",
36
+ "additionalProperties": { "type": "string" },
37
+ "description": "Namespace to bounded context, for workloads that carry no context label. A workload with the label keeps it; one with neither is counted in a warning and left out.",
38
+ "examples": [{ "shop": "shop", "shop-jobs": "shop" }]
39
+ },
40
+ "cache": {
41
+ "type": "string",
42
+ "description": "Where the last run's fragment was written, repo-relative: the same path as the step's out. An offline run, or one that cannot reach the cluster, replays it unchanged."
43
+ },
44
+ "out": {
45
+ "type": "string",
46
+ "description": "Name of the fragment file, inside the step's output directory.",
47
+ "default": "k8s-cluster.json"
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,259 @@
1
+ // The cluster reader, against a recorded kubectl. NEVER against a live one.
2
+ import { mkdtempSync, 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
+
7
+ import { DEFAULT_OUT, OFFLINE_ENV, fragment, labelsOf, run } from "./fetch-k8s.mjs";
8
+ import { hostOnly, topology } from "./k8s-topology.mjs";
9
+
10
+ const cleanups = [];
11
+ afterEach(() => {
12
+ for (const cleanup of cleanups.splice(0)) cleanup();
13
+ });
14
+
15
+ function cache() {
16
+ const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-k8s-"));
17
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
18
+ return dir;
19
+ }
20
+
21
+ const workload = (kind, name, namespace, labels, containers, extra = {}) => ({
22
+ kind,
23
+ apiVersion: kind === "CronJob" || kind === "Job" ? "batch/v1" : "apps/v1",
24
+ metadata: { name, namespace, labels },
25
+ spec: kind === "CronJob"
26
+ ? { schedule: "0 3 * * *", jobTemplate: { spec: { template: { metadata: { labels }, spec: { containers } } } } }
27
+ : { selector: { matchLabels: labels }, template: { metadata: { labels }, spec: { containers } }, ...extra },
28
+ });
29
+
30
+ const service = (name, namespace, selector) => ({ kind: "Service", apiVersion: "v1", metadata: { name, namespace }, spec: { selector, ports: [{ port: 8080 }] } });
31
+
32
+ // The recorded cluster. Every value that must never reach the fragment is
33
+ // planted here and grepped for below.
34
+ const OBJECTS = [
35
+ workload("Deployment", "pricing", "shop", { "app.kubernetes.io/name": "pricing", "app.kubernetes.io/part-of": "shop" }, [{
36
+ name: "pricing",
37
+ image: "ghcr.io/acme/pricing:1.4.2",
38
+ env: [
39
+ { name: "CART_URL", value: "http://cart.shop.svc:8080/api" },
40
+ { name: "LEDGER_DSN", value: "postgres://pricing:hunter2@ledger-db.payments.svc:5432/ledger?sslmode=disable" },
41
+ { name: "LOG_LEVEL", value: "info" },
42
+ { name: "DB_PASSWORD", value: "s3cr3t-password" },
43
+ { name: "AUTH_URL", value: "http://auth:9000" },
44
+ { name: "OMS_URL", valueFrom: { configMapKeyRef: { name: "pricing-config", key: "OMS_URL" } } },
45
+ { name: "API_TOKEN", valueFrom: { secretKeyRef: { name: "pricing-secrets", key: "token" } } },
46
+ ],
47
+ envFrom: [{ configMapRef: { name: "pricing-shared" } }, { secretRef: { name: "pricing-secrets" } }],
48
+ }]),
49
+ workload("CronJob", "nightly-reprice", "shop", { "app.kubernetes.io/name": "pricing", "app.kubernetes.io/part-of": "shop" }, [{
50
+ name: "reprice",
51
+ env: [{ name: "PRICING_URL", value: "http://pricing.shop.svc" }, { name: "OMS_URL", value: "grpc://oms.shop.svc:9090" }],
52
+ }]),
53
+ workload("Deployment", "cart", "shop", { app: "cart", team: "checkout" }, [{ name: "cart", env: [{ name: "PRICING_ADDR", value: "pricing.shop.svc:9090" }] }]),
54
+ workload("Deployment", "orphan", "sandbox", { app: "orphan" }, [{ name: "orphan" }]),
55
+ workload("Job", "migrate", "payments", { "app.kubernetes.io/name": "ledger", "app.kubernetes.io/part-of": "payments" }, [{ name: "migrate", env: [{ name: "DATABASE_URL", value: "postgres://u:p@ledger-db.payments.svc/x" }] }]),
56
+ service("pricing", "shop", { "app.kubernetes.io/name": "pricing" }),
57
+ service("auth", "shop", { app: "auth" }),
58
+ service("cart", "shop", { app: "cart" }),
59
+ service("ledger-db", "payments", { app: "ledger-db" }),
60
+ {
61
+ kind: "Ingress", apiVersion: "networking.k8s.io/v1", metadata: { name: "shop", namespace: "shop" },
62
+ spec: { rules: [
63
+ { host: "shop.example.com", http: { paths: [{ path: "/pricing", backend: { service: { name: "pricing" } } }, { path: "/cart", backend: { service: { name: "cart" } } }] } },
64
+ { host: "admin.example.com", http: { paths: [{ path: "/", backend: { service: { name: "auth" } } }] } },
65
+ ] },
66
+ },
67
+ { kind: "ConfigMap", apiVersion: "v1", metadata: { name: "pricing-config", namespace: "shop" }, data: { OMS_URL: "grpc://oms.shop.svc:9090", GREETING: "hello" } },
68
+ { kind: "ConfigMap", apiVersion: "v1", metadata: { name: "pricing-shared", namespace: "shop" }, data: { CATALOG_URL: "https://catalog.shop.svc.cluster.local/v1", REGION: "eu-west-1" } },
69
+ ];
70
+
71
+ const ROUTES = [{
72
+ kind: "HTTPRoute", apiVersion: "gateway.networking.k8s.io/v1", metadata: { name: "pricing", namespace: "shop" },
73
+ spec: { hostnames: ["api.example.com"], rules: [{ backendRefs: [{ name: "pricing" }] }] },
74
+ }];
75
+
76
+ const PLANTED = [
77
+ "hunter2", "s3cr3t", "pricing-secrets", "8080", "9090", "/api", "sslmode", "u:p@",
78
+ "CART_URL", "LEDGER_DSN", "OMS_URL", "DB_PASSWORD", "LOG_LEVEL", "info", "hello", "eu-west-1", "ghcr.io",
79
+ ];
80
+
81
+ /** A kubectl that answers from the recording, by namespace, and knows no Gateway API. */
82
+ function recorded({ routes = true, fail = false, calls = [] } = {}) {
83
+ return (args) => {
84
+ calls.push(args);
85
+ if (fail) {
86
+ const cause = new Error("kubectl failed");
87
+ cause.status = 1;
88
+ cause.stderr = "Unable to connect to the server: dial tcp 10.0.0.1:6443: i/o timeout";
89
+ throw cause;
90
+ }
91
+ const kinds = args[args.indexOf("get") + 1];
92
+ const scoped = args.includes("-n") ? args[args.indexOf("-n") + 1] : "";
93
+ const pool = kinds.startsWith("httproutes") || kinds.startsWith("grpcroutes")
94
+ ? (routes ? (kinds.startsWith("httproutes") ? ROUTES : []) : null)
95
+ : OBJECTS;
96
+ if (pool === null) {
97
+ const cause = new Error("kubectl failed");
98
+ cause.status = 1;
99
+ cause.stderr = `error: the server doesn't have a resource type "${kinds.split(".")[0]}"`;
100
+ throw cause;
101
+ }
102
+ return JSON.stringify({ kind: "List", items: pool.filter((item) => !scoped || item.metadata.namespace === scoped) });
103
+ };
104
+ }
105
+
106
+ const isolated = { [OFFLINE_ENV]: "", CI: "" };
107
+ const read = (options, io = {}) => {
108
+ const said = [];
109
+ const response = run({ options }, { env: { ...isolated, ...(io.env ?? {}) }, exec: io.exec ?? recorded(), stderr: (text) => said.push(text) });
110
+ const file = response.files.find((f) => f.name === (options.out ?? DEFAULT_OUT));
111
+ return { response, file, catalog: file ? JSON.parse(file.contents) : undefined, said, warnings: response.warnings.map((w) => `${w.ref ? `${w.ref}: ` : ""}${w.message}`) };
112
+ };
113
+ const serviceOf = (catalog, id) => catalog.contexts.flatMap((c) => c.services).find((s) => s.id === id);
114
+
115
+ describe("fetch-k8s", () => {
116
+ it("reads hosts and dials per service, by the recommended labels, and nothing else", () => {
117
+ const { catalog, response, warnings, said } = read({ cache: cache(), namespaceContexts: { shop: "shop" } });
118
+
119
+ expect(catalog.contexts.map((c) => c.id)).toEqual(["payments", "shop"]);
120
+ const pricing = serviceOf(catalog, "shop.pricing");
121
+ expect(pricing.hosts).toEqual(["api.example.com", "pricing", "pricing.shop", "pricing.shop.svc", "pricing.shop.svc.cluster.local", "shop.example.com"]);
122
+ expect(pricing.dials).toEqual(["auth", "cart.shop.svc", "catalog.shop.svc.cluster.local", "ledger-db.payments.svc", "oms.shop.svc"]);
123
+ // A Deployment beside the CronJob: the service is not a job.
124
+ expect(pricing.kind).toBeUndefined();
125
+
126
+ const cart = serviceOf(catalog, "shop.cart");
127
+ expect(cart.slug).toBe("cart");
128
+ expect(cart.dials).toEqual(["pricing.shop.svc"]);
129
+ expect(cart.hosts).toContain("shop.example.com");
130
+
131
+ const ledger = serviceOf(catalog, "payments.ledger");
132
+ expect(ledger.kind).toBe("job");
133
+ expect(ledger.dials).toEqual(["ledger-db.payments.svc"]);
134
+
135
+ expect(warnings).toEqual(['sandbox: 1 workload(s) carry no "app.kubernetes.io/part-of" label and the namespace is not in namespaceContexts; they were left out']);
136
+ expect(said).toEqual([]);
137
+
138
+ const everything = response.files.map((f) => f.contents).join("\n") + warnings.join("\n");
139
+ for (const planted of PLANTED) expect(everything, planted).not.toContain(planted);
140
+ expect(everything).not.toContain("Secret");
141
+ });
142
+
143
+ it("asks for no secrets, and asks once per namespace when namespaces are named", () => {
144
+ const calls = [];
145
+ read({ cache: cache(), namespaces: ["shop", "auth"], kubeContext: "prod" }, { exec: recorded({ calls }) });
146
+ for (const args of calls) {
147
+ expect(args.slice(0, 2)).toEqual(["--context", "prod"]);
148
+ expect(args.join(" ")).not.toMatch(/secret/i);
149
+ }
150
+ const scopes = calls.map((args) => args[args.indexOf("-n") + 1]);
151
+ expect(scopes.filter((ns) => ns === "auth").length).toBe(3);
152
+ expect(scopes.filter((ns) => ns === "shop").length).toBe(3);
153
+ expect(calls.some((args) => args.includes("-A"))).toBe(false);
154
+ });
155
+
156
+ it("lets the manifest name its own labels", () => {
157
+ const { catalog, warnings } = read({ cache: cache(), labels: { context: "team", service: "app" } });
158
+ expect(warnings).toEqual([
159
+ 'payments: 1 workload(s) carry no "team" label and the namespace is not in namespaceContexts; they were left out',
160
+ 'sandbox: 1 workload(s) carry no "team" label and the namespace is not in namespaceContexts; they were left out',
161
+ 'shop: 2 workload(s) carry no "team" label and the namespace is not in namespaceContexts; they were left out',
162
+ ]);
163
+ expect(catalog.contexts.map((c) => c.id)).toEqual(["checkout"]);
164
+ expect(serviceOf(catalog, "checkout.cart").dials).toEqual(["pricing.shop.svc"]);
165
+ expect(labelsOf({ labels: { context: " " } })).toEqual({ context: "app.kubernetes.io/part-of", service: "app.kubernetes.io/name" });
166
+ });
167
+
168
+ it("goes on without Gateway API routes, with one warning", () => {
169
+ const { catalog, warnings } = read({ cache: cache() }, { exec: recorded({ routes: false }) });
170
+ expect(serviceOf(catalog, "shop.pricing").hosts).not.toContain("api.example.com");
171
+ expect(warnings).toContain("the cluster serves no Gateway API routes, or refused to list them; hosts of HTTPRoute and GRPCRoute were not read");
172
+ });
173
+
174
+ it("replays the committed fragment offline, unchanged", () => {
175
+ const dir = cache();
176
+ const first = read({ cache: dir });
177
+ writeFileSync(join(dir, DEFAULT_OUT), first.file.contents);
178
+
179
+ const replayed = read({ cache: dir }, { env: { [OFFLINE_ENV]: "1" }, exec: () => { throw new Error("must not be called"); } });
180
+ expect(replayed.file.contents).toBe(first.file.contents);
181
+ expect(replayed.warnings).toEqual(["the cluster was not read (offline); the fragment committed in this repository is used unchanged"]);
182
+
183
+ const inCI = read({ cache: dir }, { env: { CI: "true" }, exec: () => { throw new Error("must not be called"); } });
184
+ expect(inCI.file.contents).toBe(first.file.contents);
185
+ });
186
+
187
+ it("falls back to the committed fragment when the cluster cannot be reached, and keeps the server out of the report", () => {
188
+ const dir = cache();
189
+ const first = read({ cache: dir });
190
+ writeFileSync(join(dir, DEFAULT_OUT), first.file.contents);
191
+
192
+ const { file, warnings, said } = read({ cache: dir }, { exec: recorded({ fail: true }) });
193
+ expect(file.contents).toBe(first.file.contents);
194
+ expect(warnings).toEqual(["the cluster was not read (kubectl get deployments,statefulsets,daemonsets,replicasets,jobs,cronjobs,services,ingresses,configmaps: exit 1); the fragment committed in this repository is used unchanged"]);
195
+ expect(warnings.join("\n")).not.toContain("10.0.0.1");
196
+ expect(said.join("\n")).toContain("10.0.0.1:6443");
197
+ });
198
+
199
+ it("is an error, never a short answer, with no cluster and no committed copy", () => {
200
+ expect(() => read({ cache: cache() }, { exec: recorded({ fail: true }) })).toThrow(/could not be read .* no usable fragment/);
201
+ expect(() => read({ cache: cache() }, { env: { [OFFLINE_ENV]: "1" } })).toThrow(/no usable fragment|no k8s-cluster.json/);
202
+ expect(() => run({ options: {} }, { env: isolated })).toThrow(/no cache directory/);
203
+ });
204
+
205
+ it("writes the fragment the same way whatever order the cluster listed things in", () => {
206
+ const shuffled = [...OBJECTS].reverse();
207
+ const exec = (args) => {
208
+ const kinds = args[args.indexOf("get") + 1];
209
+ if (kinds.startsWith("httproutes")) return JSON.stringify({ items: ROUTES });
210
+ if (kinds.startsWith("grpcroutes")) return JSON.stringify({ items: [] });
211
+ return JSON.stringify({ items: shuffled });
212
+ };
213
+ expect(read({ cache: cache() }, { exec }).file.contents).toBe(read({ cache: cache() }).file.contents);
214
+ });
215
+ });
216
+
217
+ describe("k8s-topology", () => {
218
+ // The same table as extract-k8s's TestHostOnly, so the two readers stay
219
+ // one rule.
220
+ it("hostOnly keeps a host and nothing else", () => {
221
+ const known = new Set(["auth", "auth.shop"]);
222
+ const cases = {
223
+ "http://cart.shop.svc:8080/api": "cart.shop.svc",
224
+ "postgres://u:p@db.payments.svc:5432/x?sslmode=off": "db.payments.svc",
225
+ "HTTPS://Cart.Shop.SVC.cluster.local": "cart.shop.svc.cluster.local",
226
+ "auth:9000": "auth",
227
+ "auth.shop": "auth.shop",
228
+ "oms.shop.svc.cluster.local": "oms.shop.svc.cluster.local",
229
+ cart: undefined,
230
+ "example.com": undefined,
231
+ "https://api.stripe.com/v1": undefined,
232
+ hunter2: undefined,
233
+ true: undefined,
234
+ info: undefined,
235
+ "host=db.shop.svc user=x password=y": undefined,
236
+ "": undefined,
237
+ "-bad.shop.svc": undefined,
238
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc": undefined,
239
+ };
240
+ for (const [value, want] of Object.entries(cases)) expect(hostOnly(value, known), value).toBe(want);
241
+ });
242
+
243
+ it("an empty selector selects nothing, and a Service in another namespace never does", () => {
244
+ const objects = [
245
+ workload("Deployment", "a", "one", { app: "a" }, [{ name: "a" }]),
246
+ service("headless", "one", {}),
247
+ service("a", "two", { app: "a" }),
248
+ ];
249
+ expect(topology(objects)[0].hosts).toEqual([]);
250
+ });
251
+
252
+ it("fragment leaves a service its kind when only jobs deploy it", () => {
253
+ const only = [workload("CronJob", "sweep", "ops", { "app.kubernetes.io/name": "sweeper", "app.kubernetes.io/part-of": "ops" }, [{ name: "s" }])];
254
+ const warnings = [];
255
+ const out = JSON.parse(fragment(only, {}, { warn: (ref, message) => warnings.push(message) }));
256
+ expect(out.contexts[0].services[0]).toEqual({ id: "ops.sweeper", slug: "sweeper", name: "", repo: "", path: "", readme: "", provides: [], consumes: [], aggregates: [], kind: "job" });
257
+ expect(warnings).toEqual([]);
258
+ });
259
+ });