@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.
Files changed (188) hide show
  1. package/README.md +151 -6
  2. package/catalog/model.go +147 -0
  3. package/catalog/roundtrip_test.go +7 -0
  4. package/cli/portolan.mjs +6 -1
  5. package/go.mod +7 -0
  6. package/go.sum +20 -6
  7. package/package.json +10 -9
  8. package/plugin/describe.go +38 -0
  9. package/plugins/README.md +296 -5
  10. package/plugins/extract-argocd/options.schema.json +44 -0
  11. package/plugins/extract-celery/main.py +1 -0
  12. package/plugins/extract-csharp-ddd/README.md +213 -0
  13. package/plugins/extract-csharp-ddd/options.schema.json +55 -0
  14. package/plugins/extract-django/README.md +13 -2
  15. package/plugins/extract-django/choices.py +47 -0
  16. package/plugins/extract-django/domain.py +2 -1
  17. package/plugins/extract-django/extract_test.py +13 -0
  18. package/plugins/extract-django/lifecycle.py +2 -28
  19. package/plugins/extract-django/main.py +1 -0
  20. package/plugins/extract-django/rules.py +154 -0
  21. package/plugins/extract-django/rules_test.py +158 -0
  22. package/plugins/extract-go-sqs/options.schema.json +21 -0
  23. package/plugins/extract-http-clients/describe.go +5 -4
  24. package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
  25. package/plugins/extract-k8s/options.schema.json +32 -0
  26. package/plugins/extract-laravel/Cargo.lock +962 -0
  27. package/plugins/extract-laravel/Cargo.toml +20 -0
  28. package/plugins/extract-laravel/README.md +200 -0
  29. package/plugins/extract-laravel/options.schema.json +68 -0
  30. package/plugins/extract-laravel/rustfmt.toml +1 -0
  31. package/plugins/extract-php-ddd/Cargo.lock +972 -0
  32. package/plugins/extract-php-ddd/Cargo.toml +22 -0
  33. package/plugins/extract-php-ddd/README.md +141 -0
  34. package/plugins/extract-php-ddd/options.schema.json +50 -0
  35. package/plugins/extract-php-ddd/rustfmt.toml +1 -0
  36. package/plugins/extract-python-kafka/main.py +1 -0
  37. package/plugins/extract-terraform/options.schema.json +26 -0
  38. package/plugins/extract-ts/main.ts +1 -0
  39. package/plugins/phpscan/Cargo.lock +953 -0
  40. package/plugins/phpscan/Cargo.toml +19 -0
  41. package/plugins/phpscan/rustfmt.toml +1 -0
  42. package/plugins/portolan-go.wasm +0 -0
  43. package/plugins/pyplugin/catalog.py +24 -1
  44. package/plugins/verify-otel/options.schema.json +12 -0
  45. package/portolan.json +130 -3
  46. package/rules/builtin.json +218 -0
  47. package/schema/portolan.schema.json +880 -13
  48. package/scripts/README.md +3 -1
  49. package/scripts/delivery-presets.mjs +36 -3
  50. package/scripts/gen-likec4.mjs +149 -16
  51. package/scripts/gen-likec4.test.mjs +96 -0
  52. package/scripts/gen.mjs +30 -3
  53. package/scripts/gitops-example.test.mjs +108 -0
  54. package/scripts/host-plugins/fetch-argocd.mjs +338 -0
  55. package/scripts/host-plugins/fetch-argocd.options.json +49 -0
  56. package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
  57. package/scripts/host-plugins/fetch-bsr.mjs +1 -0
  58. package/scripts/host-plugins/fetch-csr.mjs +1 -0
  59. package/scripts/host-plugins/fetch-git.mjs +1 -0
  60. package/scripts/host-plugins/fetch-k8s.mjs +263 -0
  61. package/scripts/host-plugins/fetch-k8s.options.json +50 -0
  62. package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
  63. package/scripts/host-plugins/k8s-topology.mjs +183 -0
  64. package/scripts/json-format.mjs +192 -0
  65. package/scripts/json-format.test.mjs +97 -0
  66. package/scripts/local-api.mjs +216 -8
  67. package/scripts/local-api.test.mjs +88 -2
  68. package/scripts/local-discovery.mjs +12 -0
  69. package/scripts/manifest.mjs +10 -1
  70. package/scripts/plugin-host.mjs +23 -1
  71. package/scripts/plugins-fresh.mjs +97 -0
  72. package/scripts/plugins-fresh.test.mjs +64 -0
  73. package/scripts/run-builtin.mjs +26 -5
  74. package/scripts/schema.mjs +138 -19
  75. package/scripts/trace-trials.mjs +176 -0
  76. package/scripts/trace-trials.test.mjs +142 -0
  77. package/scripts/warning-policy.mjs +12 -6
  78. package/scripts/warning-policy.test.mjs +40 -3
  79. package/src/app/Breadcrumbs.test.ts +3 -0
  80. package/src/app/Breadcrumbs.tsx +3 -0
  81. package/src/app/CatalogApp.tsx +2 -0
  82. package/src/app/SidebarFooter.tsx +20 -4
  83. package/src/catalog-index.ts +25 -0
  84. package/src/catalog-model.ts +246 -1
  85. package/src/catalog-validation.ts +104 -2
  86. package/src/catalog.test.ts +40 -1
  87. package/src/components/ChannelRows.test.tsx +76 -1
  88. package/src/components/ChannelRows.tsx +223 -60
  89. package/src/components/DeploymentRows.tsx +131 -0
  90. package/src/components/DocsLinks.test.tsx +27 -0
  91. package/src/components/DocsLinks.tsx +56 -0
  92. package/src/components/FieldTree.tsx +5 -2
  93. package/src/components/HTTPDestinationEvidence.tsx +1 -1
  94. package/src/components/PluginIcon.tsx +77 -0
  95. package/src/components/ProblemRow.tsx +173 -153
  96. package/src/components/RuleMarks.tsx +22 -0
  97. package/src/components/ShapeRows.tsx +24 -20
  98. package/src/data.ts +21 -0
  99. package/src/enrich.test.ts +127 -3
  100. package/src/enrich.ts +104 -6
  101. package/src/er/ErCanvas.tsx +218 -12
  102. package/src/er/GroupNode.tsx +57 -0
  103. package/src/er/StoreHeader.tsx +1 -0
  104. package/src/er/layout.test.ts +85 -2
  105. package/src/er/layout.ts +140 -5
  106. package/src/er/spec.test.ts +17 -0
  107. package/src/er/spec.ts +23 -10
  108. package/src/flow/Recordings.test.tsx +52 -0
  109. package/src/flow/Recordings.tsx +236 -0
  110. package/src/flow/StepDetail.tsx +53 -0
  111. package/src/flow/TraceTrial.tsx +419 -0
  112. package/src/flow/examples.test.ts +33 -0
  113. package/src/flow/examples.ts +37 -0
  114. package/src/flow/outline.test.ts +28 -0
  115. package/src/flow/outline.ts +7 -2
  116. package/src/flow/trace-trial-resume.test.ts +42 -0
  117. package/src/flow/trace-trial-resume.ts +74 -0
  118. package/src/graph/elk.ts +78 -0
  119. package/src/index.css +24 -0
  120. package/src/landing/LandingPage.tsx +15 -11
  121. package/src/landing/ProductTour.tsx +6 -6
  122. package/src/lib/all-problems.ts +27 -17
  123. package/src/lib/centrality.test.ts +251 -0
  124. package/src/lib/centrality.ts +232 -0
  125. package/src/lib/confluence.test.ts +37 -0
  126. package/src/lib/confluence.ts +41 -0
  127. package/src/lib/context-color.ts +1 -1
  128. package/src/lib/deployment-drift.ts +22 -0
  129. package/src/lib/derive.ts +43 -69
  130. package/src/lib/environments.test.ts +83 -0
  131. package/src/lib/environments.ts +57 -0
  132. package/src/lib/integration-url.test.ts +30 -0
  133. package/src/lib/integration-url.ts +63 -0
  134. package/src/lib/kafka-ui.ts +3 -45
  135. package/src/lib/local-api.ts +96 -2
  136. package/src/lib/notion.ts +13 -0
  137. package/src/lib/plugin-index.json +2765 -0
  138. package/src/lib/plugins.test.ts +68 -0
  139. package/src/lib/plugins.ts +264 -0
  140. package/src/lib/problem-flows.test.ts +61 -0
  141. package/src/lib/problem-flows.ts +78 -0
  142. package/src/lib/problem-rules-cel.d.mts +43 -0
  143. package/src/lib/problem-rules-cel.mjs +407 -0
  144. package/src/lib/problem-rules.test.ts +287 -0
  145. package/src/lib/problem-rules.ts +271 -0
  146. package/src/lib/problem-subjects.ts +737 -0
  147. package/src/lib/rule-entries.ts +39 -0
  148. package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
  149. package/src/lib/rules-deploy.test.ts +133 -0
  150. package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
  151. package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
  152. package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
  153. package/src/lib/rules.test.ts +51 -0
  154. package/src/lib/rules.ts +86 -0
  155. package/src/lib/shape.test.ts +32 -0
  156. package/src/lib/shape.ts +30 -6
  157. package/src/lib/tech.ts +16 -0
  158. package/src/lib/trace-project.test.ts +34 -0
  159. package/src/lib/trace-project.ts +50 -0
  160. package/src/lib/use-problems.ts +23 -0
  161. package/src/lib/warnings.test.ts +9 -0
  162. package/src/lib/warnings.ts +10 -10
  163. package/src/likec4/ids.test.ts +6 -2
  164. package/src/likec4/ids.ts +43 -0
  165. package/src/main.tsx +23 -0
  166. package/src/merge-deployments.test.ts +127 -0
  167. package/src/merge.test.ts +82 -0
  168. package/src/merge.ts +173 -9
  169. package/src/pages/AggregatePage.tsx +57 -7
  170. package/src/pages/ContextMap.tsx +45 -3
  171. package/src/pages/ContextPage.tsx +2 -0
  172. package/src/pages/EventPage.tsx +15 -5
  173. package/src/pages/FlowDetail.tsx +23 -2
  174. package/src/pages/GraphPage.tsx +40 -3
  175. package/src/pages/Overview.tsx +152 -12
  176. package/src/pages/PluginIndex.tsx +190 -0
  177. package/src/pages/Problems.tsx +396 -128
  178. package/src/pages/ServicePage.tsx +58 -2
  179. package/src/pages/Settings.tsx +28 -2
  180. package/src/pages/settings/AboutSettings.tsx +8 -1
  181. package/src/pages/settings/IntegrationsSettings.tsx +63 -17
  182. package/src/pages/settings/RecordingSettings.tsx +138 -0
  183. package/src/pages/settings/RulesSettings.tsx +825 -0
  184. package/src/routes.test.ts +9 -0
  185. package/src/routes.ts +22 -1
  186. package/src/lib/data-problems.ts +0 -314
  187. package/src/lib/proto-problems.ts +0 -237
  188. package/src/lib/wire-problems.ts +0 -342
@@ -0,0 +1,338 @@
1
+ // fetch-argocd, run by the host: an Argo CD API on one side, a snapshot of
2
+ // where the estate's services run committed to this repository on the other.
3
+ //
4
+ // portolan.0011 reads the names a service answers on and dials, from the
5
+ // manifests and from the cluster, and nothing else; neither says which
6
+ // revision of which repository stands in which cluster and namespace, under
7
+ // which name. Only the thing that deploys knows that (portolan.0012). It is
8
+ // not in any tree, so it cannot be an extractor's, and it is read over a
9
+ // socket with a credential, so it is the host's (portolan.0008).
10
+ //
11
+ // The contract is the plugin's all the same: the snapshot is named, never
12
+ // written, so the host writes it, it gets a manifest entry, is compared by
13
+ // `gen:check` like any other generated file, and is removed when the step
14
+ // stops naming it. Two files land in the step's out. `argocd.apps.json` is
15
+ // a catalog fragment - the deployments, one per Application, nothing else.
16
+ // `argocd.lock.json` is for the next run of this step: the server, the
17
+ // applications and the fragment's digest, which is what lets an offline run
18
+ // replay the committed snapshot and refuse one edited by hand.
19
+ //
20
+ // There is no pin. An Application has no immutable commit the way a module
21
+ // or a repository has; the snapshot IS the fact, and it moves when a deploy
22
+ // moves it. So an online run after a deploy changes the committed snapshot,
23
+ // and that diff is the review worth having: what went where, in one pull
24
+ // request. What is kept is what a deploy changes - the revision, the images,
25
+ // the destination. What is not kept is what changes without one: health,
26
+ // sync state, the time of the last operation. A page that said "healthy"
27
+ // yesterday and was not regenerated since is a page that lies, and a build
28
+ // that reads a clock is not reproducible (portolan.0010).
29
+ //
30
+ // In CI, or with PORTOLAN_OFFLINE=1, the step replays the committed
31
+ // snapshot, checks it against its lock and emits an identical file list, so
32
+ // a fork's pull request needs no token and Argo CD being down cannot turn
33
+ // the tree red.
34
+ //
35
+ // No token in the manifest, ever: portolan.json is committed. The credential
36
+ // comes from ARGOCD_AUTH_TOKEN, the variable the argocd CLI reads, and it
37
+ // changes whether the fetch succeeds, never what the fetch says.
38
+
39
+ import { createHash } from "node:crypto";
40
+ import { readFileSync } from "node:fs";
41
+ import { join } from "node:path";
42
+
43
+ import optionsSchema from "./fetch-argocd.options.json" with { type: "json" };
44
+ import { webRepo } from "./fetch-git.mjs";
45
+
46
+ export const LOCK_NAME = "argocd.lock.json";
47
+ export const FRAGMENT_NAME = "argocd.apps.json";
48
+ export const TOKEN_ENV = "ARGOCD_AUTH_TOKEN";
49
+ export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
50
+
51
+ /** The one call: every Application the token can list, narrowed by query. */
52
+ export const LIST_PATH = "/api/v1/applications";
53
+
54
+ /** The label that names an environment when the manifest does not say. */
55
+ export const DEFAULT_ENVIRONMENT_LABEL = "env";
56
+
57
+ /**
58
+ * The labels that name the service an Application deploys, when the
59
+ * manifest does not say: the Kubernetes recommended ones, the same
60
+ * fetch-k8s reads off a workload. An ApplicationSet that stamps them on
61
+ * every Application it makes is what lets a GitOps repository - where the
62
+ * path points at an overlay, not at the service - still place each
63
+ * Application on its service.
64
+ */
65
+ export const DEFAULT_LABELS = { context: "app.kubernetes.io/part-of", service: "app.kubernetes.io/name" };
66
+
67
+ /** How Argo CD names the cluster it runs in, and how the catalog does. */
68
+ const IN_CLUSTER_SERVER = "https://kubernetes.default.svc";
69
+ const IN_CLUSTER_NAME = "in-cluster";
70
+
71
+ export function describe() {
72
+ return {
73
+ name: "fetch-argocd",
74
+ summary: "Reads the applications an Argo CD server manages into a snapshot of where each service runs - cluster, namespace, revision, images - with a lock beside it.",
75
+ category: "sources",
76
+ phases: ["extract"],
77
+ options: optionsSchema,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * The step. Reads nothing out of the tree being described; its `cache` is
83
+ * repo-relative and everything else is in the options.
84
+ *
85
+ * The rules are fetch-bsr's: a failed fetch falls back to the committed
86
+ * snapshot with a warning, and a failed fetch with no snapshot is an ERROR,
87
+ * never a short file list, because the host deletes files a step stops
88
+ * naming.
89
+ *
90
+ * @param {{options?: object}} request
91
+ * @param {{env?: NodeJS.ProcessEnv, fetch?: typeof fetch}} [io]
92
+ */
93
+ export async function run(request, { env = process.env, fetch: fetchFn = globalThis.fetch } = {}) {
94
+ const options = request.options ?? {};
95
+ const server = String(options.server ?? "").trim();
96
+ if (!server) throw new Error("no server: name the Argo CD API server in the manifest");
97
+ if (!options.cache) {
98
+ 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");
99
+ }
100
+
101
+ const out = new Builder();
102
+ if (offline(env)) {
103
+ emitCached(out, options.cache, "offline");
104
+ return out.response();
105
+ }
106
+
107
+ let deployments;
108
+ try {
109
+ deployments = await live(server, options, env, fetchFn);
110
+ } catch (cause) {
111
+ try {
112
+ emitCached(out, options.cache, cause.message);
113
+ } catch (cacheCause) {
114
+ throw new Error(`${server} could not be read (${cause.message}) and there is no usable snapshot in the tree (${cacheCause.message})`);
115
+ }
116
+ return out.response();
117
+ }
118
+ emitFetched(out, server, deployments);
119
+ return out.response();
120
+ }
121
+
122
+ export function offline(env = process.env) {
123
+ if (String(env[OFFLINE_ENV] ?? "").trim()) return true;
124
+ return !["", "0", "false"].includes(String(env.CI ?? "").trim().toLowerCase());
125
+ }
126
+
127
+ /** A credential, or "". The argocd CLI reads the same variable. */
128
+ export function token(env = process.env) {
129
+ return String(env[TOKEN_ENV] ?? "").trim();
130
+ }
131
+
132
+ /** The server as a base URL: https unless the manifest says otherwise. */
133
+ export function baseUrl(server) {
134
+ const value = String(server ?? "").trim().replace(/\/+$/, "");
135
+ return /^https?:\/\//.test(value) ? value : `https://${value}`;
136
+ }
137
+
138
+ /**
139
+ * One Application, reduced to what a deploy changes.
140
+ *
141
+ * Everything the wire knows is looked at here and nowhere else. What comes
142
+ * out is names and revisions: no parameter, no value, no status that moves
143
+ * without a commit. A Helm parameter or a plugin env is a value and stays
144
+ * behind for the reason portolan.0011 gives.
145
+ *
146
+ * @param {object} app an item of the list answer
147
+ * @param {string} base the server's base URL, for the link
148
+ * @param {string} environmentLabel
149
+ * @param {{context?: string, service?: string}} serviceLabels
150
+ */
151
+ export function deploymentOf(app, base, environmentLabel = DEFAULT_ENVIRONMENT_LABEL, serviceLabels = DEFAULT_LABELS) {
152
+ const metadata = app?.metadata ?? {};
153
+ const spec = app?.spec ?? {};
154
+ const status = app?.status ?? {};
155
+ const name = String(metadata.name ?? "").trim();
156
+ if (!name) throw new Error("an application has no name");
157
+ const appNamespace = String(metadata.namespace ?? "argocd").trim() || "argocd";
158
+
159
+ // A multi-source application deploys the source that carries a path (its
160
+ // manifests) and reads values out of the others; the path is the one
161
+ // whose revision a reader wants to follow.
162
+ const sources = Array.isArray(spec.sources) && spec.sources.length ? spec.sources : spec.source ? [spec.source] : [];
163
+ const sourceIndex = Math.max(0, sources.findIndex((source) => String(source?.path ?? "").trim()));
164
+ const source = sources[sourceIndex] ?? {};
165
+ const revisions = Array.isArray(status.sync?.revisions) ? status.sync.revisions : [];
166
+ const revision = String(revisions[sourceIndex] ?? status.sync?.revision ?? "").trim();
167
+
168
+ const destination = spec.destination ?? {};
169
+ const server = String(destination.server ?? "").trim();
170
+ const cluster = String(destination.name ?? "").trim() || (server === IN_CLUSTER_SERVER ? IN_CLUSTER_NAME : server);
171
+ const labels = metadata.labels ?? {};
172
+ const environment = String(labels[environmentLabel] ?? "").trim() || cluster;
173
+
174
+ const record = {
175
+ id: `${appNamespace}/${name}`,
176
+ name,
177
+ project: String(spec.project ?? "default").trim() || "default",
178
+ environment,
179
+ cluster,
180
+ namespace: String(destination.namespace ?? "").trim(),
181
+ repo: webRepo(source.repoURL ?? ""),
182
+ path: String(source.path ?? "").trim().replace(/^\.?\/+|\/+$/g, ""),
183
+ targetRevision: String(source.targetRevision ?? "").trim(),
184
+ revision,
185
+ tool: toolOf(status.sourceType ?? status.sourceTypes?.[sourceIndex], ...sources),
186
+ url: `${base}/applications/${encodeURIComponent(appNamespace)}/${encodeURIComponent(name)}`,
187
+ // Who said so: the deployer. A row extract-argocd reads out of the
188
+ // GitOps tree says "manifest", and the merge lays the two over each
189
+ // other (portolan.0013).
190
+ basis: "api",
191
+ };
192
+ // The service, when the labels say: `<context>.<service>`, the id a
193
+ // service carries in the catalog. Both labels or nothing - a name without
194
+ // a context is a word, and the catalog does not place words.
195
+ const contextSlug = String(labels[serviceLabels.context ?? DEFAULT_LABELS.context] ?? "").trim();
196
+ const serviceSlug = String(labels[serviceLabels.service ?? DEFAULT_LABELS.service] ?? "").trim();
197
+ if (contextSlug && serviceSlug) record.service = `${contextSlug}.${serviceSlug}`;
198
+ const chart = String(source.chart ?? "").trim();
199
+ if (chart) record.chart = chart;
200
+ const images = [...new Set((status.summary?.images ?? []).map((image) => String(image).trim()).filter(Boolean))].sort();
201
+ if (images.length) record.images = images;
202
+ return record;
203
+ }
204
+
205
+ /**
206
+ * `Helm`, `Kustomize`, `Directory`, `Plugin` as Argo CD says it, lowercased;
207
+ * inferred from the sources when the status has not said yet. A multi-source
208
+ * application says it on the chart, not on the values it reads beside it.
209
+ */
210
+ export function toolOf(sourceType, ...sources) {
211
+ const said = String(sourceType ?? "").trim().toLowerCase();
212
+ if (said) return said;
213
+ for (const source of sources) {
214
+ if (source?.chart || source?.helm) return "helm";
215
+ if (source?.kustomize) return "kustomize";
216
+ if (source?.plugin) return "plugin";
217
+ if (source?.directory) return "directory";
218
+ }
219
+ return "";
220
+ }
221
+
222
+ /** The fragment: a catalog holding deployments and nothing else. */
223
+ export function fragment(deployments) {
224
+ const sorted = [...deployments].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
225
+ return `${JSON.stringify({ contexts: [], defs: {}, flows: [], adrs: [], deployments: sorted }, null, 2)}\n`;
226
+ }
227
+
228
+ /** The lock, written the way every generated file here is written. */
229
+ export function encodeLock(entry) {
230
+ return `${JSON.stringify({ server: entry.server, applications: [...entry.applications].sort(), sha256: entry.sha256 }, null, 2)}\n`;
231
+ }
232
+
233
+ function digestOf(contents) {
234
+ return createHash("sha256").update(contents).digest("hex");
235
+ }
236
+
237
+ async function live(server, options, env, fetchFn) {
238
+ const base = baseUrl(server);
239
+ const query = new URLSearchParams();
240
+ for (const project of Array.isArray(options.projects) ? options.projects : []) query.append("projects", String(project));
241
+ if (options.selector) query.set("selector", String(options.selector));
242
+ const url = `${base}${LIST_PATH}${query.size ? `?${query}` : ""}`;
243
+
244
+ const headers = { Accept: "application/json" };
245
+ const credential = token(env);
246
+ if (credential) headers.Authorization = `Bearer ${credential}`;
247
+ let response;
248
+ try {
249
+ // A timeout rather than none: a step that hangs holds up the whole run.
250
+ response = await fetchFn(url, { method: "GET", headers, signal: AbortSignal.timeout(60_000) });
251
+ } catch (cause) {
252
+ throw new Error(`${LIST_PATH}: ${cause.cause?.message ?? cause.message}`);
253
+ }
254
+ const raw = await response.text();
255
+ if (!response.ok) throw new Error(`${LIST_PATH}: ${apiError(response.status, raw)}`);
256
+ let answer;
257
+ try {
258
+ answer = JSON.parse(raw);
259
+ } catch {
260
+ throw new Error(`${LIST_PATH}: the answer is not JSON`);
261
+ }
262
+ const items = Array.isArray(answer?.items) ? answer.items : [];
263
+ if (items.length === 0) throw new Error(`${server} lists no applications; check the projects and selector in the manifest, and what the token may see`);
264
+ const label = String(options.environmentLabel ?? "").trim() || DEFAULT_ENVIRONMENT_LABEL;
265
+ const serviceLabels = { ...DEFAULT_LABELS, ...(options.labels ?? {}) };
266
+ return items.map((item) => deploymentOf(item, base, label, serviceLabels));
267
+ }
268
+
269
+ /** The error body Argo CD sends, or the status when the body is not one. */
270
+ function apiError(status, raw) {
271
+ try {
272
+ const wire = JSON.parse(raw);
273
+ if (wire?.message) return wire.message;
274
+ if (wire?.error) return wire.error;
275
+ } catch {
276
+ // Not an error body.
277
+ }
278
+ return `http ${status}`;
279
+ }
280
+
281
+ function emitFetched(out, server, deployments) {
282
+ const contents = fragment(deployments);
283
+ out.file(FRAGMENT_NAME, contents);
284
+ out.file(LOCK_NAME, encodeLock({ server, applications: deployments.map((d) => d.id), sha256: digestOf(contents) }));
285
+ }
286
+
287
+ function emitCached(out, cache, why) {
288
+ const held = replay(cache);
289
+ out.file(FRAGMENT_NAME, held.contents);
290
+ out.file(LOCK_NAME, encodeLock(held.lock));
291
+ out.warn(held.lock.server, `not fetched (${why}); the snapshot committed in this repository is used unchanged`);
292
+ }
293
+
294
+ function replay(dir) {
295
+ const lockPath = join(dir, LOCK_NAME);
296
+ let raw;
297
+ try {
298
+ raw = readFileSync(lockPath, "utf8");
299
+ } catch (cause) {
300
+ if (cause.code === "ENOENT") throw new Error(`no ${LOCK_NAME} in ${dir.split("\\").join("/")}`);
301
+ throw cause;
302
+ }
303
+ let lock;
304
+ try {
305
+ lock = JSON.parse(raw);
306
+ } catch (cause) {
307
+ throw new Error(`${lockPath}: ${cause.message}`);
308
+ }
309
+ let contents;
310
+ try {
311
+ contents = readFileSync(join(dir, FRAGMENT_NAME), "utf8");
312
+ } catch (cause) {
313
+ if (cause.code === "ENOENT") throw new Error(`${FRAGMENT_NAME} is in the lock but not on disk`);
314
+ throw cause;
315
+ }
316
+ if (digestOf(contents) !== lock.sha256) throw new Error(`${FRAGMENT_NAME} does not match its digest; the snapshot was edited by hand`);
317
+ return {
318
+ lock: { server: String(lock.server ?? ""), applications: Array.isArray(lock.applications) ? lock.applications : [], sha256: String(lock.sha256 ?? "") },
319
+ contents,
320
+ };
321
+ }
322
+
323
+ class Builder {
324
+ files = [];
325
+ warnings = [];
326
+
327
+ file(name, contents) {
328
+ this.files.push({ name, contents });
329
+ }
330
+
331
+ warn(ref, message) {
332
+ this.warnings.push({ severity: "warning", message, ref });
333
+ }
334
+
335
+ response() {
336
+ return { files: this.files, warnings: this.warnings };
337
+ }
338
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "type": "object",
3
+ "additionalProperties": false,
4
+ "required": ["server", "cache"],
5
+ "properties": {
6
+ "server": {
7
+ "type": "string",
8
+ "description": "The Argo CD API server: a host, or a URL when it is not https. The token comes from ARGOCD_AUTH_TOKEN, never from here.",
9
+ "examples": ["argocd.example.com"]
10
+ },
11
+ "projects": {
12
+ "type": "array",
13
+ "items": { "type": "string", "minLength": 1 },
14
+ "description": "Argo CD projects to read applications from. Left out, every application the token can list.",
15
+ "examples": [["shop", "platform"]]
16
+ },
17
+ "selector": {
18
+ "type": "string",
19
+ "description": "A label selector narrowing the applications, as `argocd app list -l` takes one.",
20
+ "examples": ["team=shop"]
21
+ },
22
+ "environmentLabel": {
23
+ "type": "string",
24
+ "default": "env",
25
+ "description": "The application label that names the environment. An application without it is placed by the cluster it deploys to."
26
+ },
27
+ "labels": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "description": "Which application labels name the service it deploys. The defaults are the Kubernetes recommended labels, the same ones fetch-k8s reads off a workload; an estate that labels differently names its own keys here. An application without them is placed by the repository and directory it deploys from.",
31
+ "properties": {
32
+ "context": {
33
+ "type": "string",
34
+ "description": "The label whose value is the bounded context's slug.",
35
+ "default": "app.kubernetes.io/part-of"
36
+ },
37
+ "service": {
38
+ "type": "string",
39
+ "description": "The label whose value is the service's slug.",
40
+ "default": "app.kubernetes.io/name"
41
+ }
42
+ }
43
+ },
44
+ "cache": {
45
+ "type": "string",
46
+ "description": "Where the last run's snapshot was written, repo-relative: the same path as the step's out. An offline run replays what is there and checks it against the lock beside it."
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,274 @@
1
+ // The fetcher, against a recorded Argo CD. NEVER against a live one: the
2
+ // whole point of this plugin's design is that the build does not depend on
3
+ // a control plane being up, or on anyone holding a token.
4
+ import { createHash } from "node:crypto";
5
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { createServer } from "node:http";
7
+ import { tmpdir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+ import { afterEach, describe, expect, it } from "vitest";
10
+
11
+ import { runPlugin } from "../plugin-host.mjs";
12
+ import { FRAGMENT_NAME, LIST_PATH, LOCK_NAME, OFFLINE_ENV, TOKEN_ENV, baseUrl, deploymentOf, run, toolOf } from "./fetch-argocd.mjs";
13
+
14
+ const REVISION = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678";
15
+
16
+ /** Two applications the way the API lists them, with everything a page must not keep. */
17
+ const CART = {
18
+ metadata: { name: "shop-cart", namespace: "argocd", labels: { env: "prod", team: "shop", "app.kubernetes.io/part-of": "shop", "app.kubernetes.io/name": "cart" } },
19
+ spec: {
20
+ project: "shop",
21
+ source: { repoURL: "https://github.com/shortlink-org/portolan.git", path: "./examples/shop/cart/deploy/k8s/", targetRevision: "main", kustomize: { namePrefix: "prod-" } },
22
+ destination: { server: "https://kubernetes.default.svc", namespace: "shop" },
23
+ },
24
+ status: {
25
+ sync: { status: "Synced", revision: REVISION },
26
+ health: { status: "Healthy" },
27
+ summary: { images: ["ghcr.io/shortlink-org/cart:1.4.2", "ghcr.io/shortlink-org/cart:1.4.2", "redis:7"] },
28
+ sourceType: "Kustomize",
29
+ operationState: { finishedAt: "2026-09-11T10:00:00Z" },
30
+ },
31
+ };
32
+ const PRICING = {
33
+ metadata: { name: "shop-pricing", namespace: "argocd" },
34
+ spec: {
35
+ project: "shop",
36
+ sources: [
37
+ { repoURL: "https://charts.example.com", chart: "service", targetRevision: "2.1.0", helm: { valueFiles: ["$values/pricing.yaml"], parameters: [{ name: "db.password", value: "hunter2" }] } },
38
+ { repoURL: "git@github.com:shortlink-org/portolan.git", path: "examples/shop/pricing/deploy", targetRevision: "HEAD", ref: "values" },
39
+ ],
40
+ destination: { name: "eu-west-1", namespace: "shop" },
41
+ },
42
+ status: { sync: { revisions: ["2.1.0", REVISION] }, summary: { images: ["ghcr.io/shortlink-org/pricing:0.9.0"] } },
43
+ };
44
+
45
+ const cleanups = [];
46
+ afterEach(async () => {
47
+ for (const cleanup of cleanups.splice(0)) await cleanup();
48
+ });
49
+
50
+ /** Serves the one call the plugin makes, from recorded shapes. */
51
+ async function argocd(handle = null) {
52
+ const seen = [];
53
+ const urls = [];
54
+ const server = createServer((request, response) => {
55
+ seen.push(request.headers.authorization ?? "");
56
+ urls.push(request.url);
57
+ if (handle) return handle(request, response);
58
+ response.setHeader("Content-Type", "application/json");
59
+ if (request.url.startsWith(LIST_PATH)) {
60
+ response.end(JSON.stringify({ metadata: { resourceVersion: "12345" }, items: [PRICING, CART] }));
61
+ } else {
62
+ response.statusCode = 404;
63
+ response.end(JSON.stringify({ error: "not found", code: 5, message: "no such route" }));
64
+ }
65
+ });
66
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
67
+ cleanups.push(() => new Promise((resolve) => server.close(resolve)));
68
+ return { url: `http://127.0.0.1:${server.address().port}`, seen, urls };
69
+ }
70
+
71
+ function cache() {
72
+ const dir = mkdtempSync(join(tmpdir(), "portolan-fetch-argocd-"));
73
+ cleanups.push(() => rmSync(dir, { recursive: true, force: true }));
74
+ return dir;
75
+ }
76
+
77
+ // A test must never inherit a real credential or a real offline setting.
78
+ const isolated = { [TOKEN_ENV]: "", [OFFLINE_ENV]: "", CI: "" };
79
+ const options = (server, cacheDir, extra = {}) => ({ server, cache: cacheDir, ...extra });
80
+ const fetch = (opts, env = {}) => run({ options: opts }, { env: { ...isolated, ...env } });
81
+ const names = (response) => response.files.map((file) => file.name).sort();
82
+ const contentsOf = (response, name) => response.files.find((file) => file.name === name)?.contents ?? "";
83
+ const write = (dir, response) => {
84
+ for (const file of response.files) {
85
+ mkdirSync(dirname(join(dir, file.name)), { recursive: true });
86
+ writeFileSync(join(dir, file.name), file.contents);
87
+ }
88
+ };
89
+
90
+ describe("fetch-argocd", () => {
91
+ it("writes the snapshot and a lock beside it, sorted, keeping what a deploy changes and nothing else", async () => {
92
+ const server = await argocd();
93
+ const response = await fetch(options(server.url, cache(), { projects: ["shop"] }));
94
+ expect(names(response)).toEqual([FRAGMENT_NAME, LOCK_NAME]);
95
+ expect(server.urls).toEqual([`${LIST_PATH}?projects=shop`]);
96
+
97
+ const catalog = JSON.parse(contentsOf(response, FRAGMENT_NAME));
98
+ expect(catalog).toMatchObject({ contexts: [], defs: {}, flows: [], adrs: [] });
99
+ expect(catalog.deployments.map((d) => d.id)).toEqual(["argocd/shop-cart", "argocd/shop-pricing"]);
100
+ expect(catalog.deployments[0]).toEqual({
101
+ id: "argocd/shop-cart",
102
+ name: "shop-cart",
103
+ project: "shop",
104
+ environment: "prod",
105
+ cluster: "in-cluster",
106
+ namespace: "shop",
107
+ repo: "github.com/shortlink-org/portolan",
108
+ path: "examples/shop/cart/deploy/k8s",
109
+ targetRevision: "main",
110
+ revision: REVISION,
111
+ tool: "kustomize",
112
+ url: `${server.url}/applications/argocd/shop-cart`,
113
+ basis: "api",
114
+ service: "shop.cart",
115
+ images: ["ghcr.io/shortlink-org/cart:1.4.2", "redis:7"],
116
+ });
117
+ // No labels, no service: the catalog places it by repository and path.
118
+ expect(catalog.deployments[1].service).toBeUndefined();
119
+ // A multi-source application follows the source that carries the
120
+ // manifests, and its cluster names the environment when no label does.
121
+ expect(catalog.deployments[1]).toMatchObject({
122
+ environment: "eu-west-1",
123
+ cluster: "eu-west-1",
124
+ repo: "github.com/shortlink-org/portolan",
125
+ path: "examples/shop/pricing/deploy",
126
+ targetRevision: "HEAD",
127
+ revision: REVISION,
128
+ tool: "helm",
129
+ });
130
+ expect(catalog.deployments[1].chart).toBeUndefined();
131
+ // Nothing that moves without a commit, and no value, reaches the tree.
132
+ const text = contentsOf(response, FRAGMENT_NAME);
133
+ for (const leaked of ["Synced", "Healthy", "finishedAt", "hunter2", "namePrefix", "resourceVersion"]) expect(text).not.toContain(leaked);
134
+
135
+ const lock = JSON.parse(contentsOf(response, LOCK_NAME));
136
+ expect(lock).toEqual({
137
+ server: server.url,
138
+ applications: ["argocd/shop-cart", "argocd/shop-pricing"],
139
+ sha256: createHash("sha256").update(text).digest("hex"),
140
+ });
141
+ expect(response.warnings).toEqual([]);
142
+ });
143
+
144
+ it("asks with the projects, the selector and the token, and lets none of them reach the output", async () => {
145
+ const server = await argocd();
146
+ const anonymous = await fetch(options(server.url, cache()));
147
+ const authorised = await fetch(options(server.url, cache(), { projects: ["shop", "platform"], selector: "team=shop" }), { [TOKEN_ENV]: "a-real-looking-secret" });
148
+ expect(server.urls).toEqual([LIST_PATH, `${LIST_PATH}?projects=shop&projects=platform&selector=team%3Dshop`]);
149
+ expect(server.seen).toEqual(["", "Bearer a-real-looking-secret"]);
150
+ for (const file of anonymous.files) {
151
+ expect(contentsOf(authorised, file.name)).toBe(file.contents);
152
+ expect(contentsOf(authorised, file.name)).not.toContain("a-real-looking-secret");
153
+ }
154
+ });
155
+
156
+ it("replays the committed snapshot offline, byte for byte, and in CI", async () => {
157
+ const server = await argocd();
158
+ const cacheDir = cache();
159
+ const online = await fetch(options(server.url, cacheDir));
160
+ write(cacheDir, online);
161
+
162
+ const replayed = await fetch(options(server.url, cacheDir), { [OFFLINE_ENV]: "1" });
163
+ expect(names(replayed)).toEqual(names(online));
164
+ for (const file of online.files) expect(contentsOf(replayed, file.name)).toBe(file.contents);
165
+ expect(replayed.warnings).toHaveLength(1);
166
+ expect(replayed.warnings[0].message).toContain("not fetched (offline)");
167
+
168
+ const inCi = await fetch(options(server.url, cacheDir), { CI: "true" });
169
+ expect(names(inCi)).toEqual(names(online));
170
+ expect(server.urls).toHaveLength(1);
171
+ });
172
+
173
+ it("names a snapshot edited by hand", async () => {
174
+ const server = await argocd();
175
+ const cacheDir = cache();
176
+ write(cacheDir, await fetch(options(server.url, cacheDir)));
177
+ writeFileSync(join(cacheDir, FRAGMENT_NAME), '{ "deployments": [] }\n');
178
+ await expect(fetch(options(server.url, cacheDir), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/edited by hand/);
179
+ });
180
+
181
+ it("fails rather than emitting nothing when there is no snapshot to fall back to", async () => {
182
+ await expect(fetch(options("http://127.0.0.1:1", cache()), { [OFFLINE_ENV]: "1" })).rejects.toThrow(/no argocd\.lock\.json/);
183
+
184
+ const broken = await argocd((request, response) => {
185
+ response.statusCode = 500;
186
+ response.end(JSON.stringify({ error: "the control plane is having a day", code: 13 }));
187
+ });
188
+ await expect(fetch(options(broken.url, cache()))).rejects.toThrow(/the control plane is having a day.*no usable snapshot/);
189
+
190
+ const empty = await argocd((request, response) => {
191
+ response.end(JSON.stringify({ items: null }));
192
+ });
193
+ await expect(fetch(options(empty.url, cache()))).rejects.toThrow(/lists no applications/);
194
+ });
195
+
196
+ it("falls back to the committed snapshot when the server fails", async () => {
197
+ const server = await argocd();
198
+ const cacheDir = cache();
199
+ const online = await fetch(options(server.url, cacheDir));
200
+ write(cacheDir, online);
201
+
202
+ const broken = await argocd((request, response) => {
203
+ response.statusCode = 502;
204
+ response.end();
205
+ });
206
+ const fallback = await fetch(options(broken.url, cacheDir));
207
+ expect(names(fallback)).toEqual(names(online));
208
+ expect(fallback.warnings[0].message).toMatch(/not fetched \(.*http 502\)/);
209
+ });
210
+
211
+ it("refuses a missing server and a missing cache", async () => {
212
+ await expect(fetch(options("", cache()))).rejects.toThrow(/server/);
213
+ await expect(fetch(options("argocd.example.com", ""))).rejects.toThrow(/cache/);
214
+ });
215
+
216
+ it("runs through the host like any plugin", async () => {
217
+ const server = await argocd();
218
+ const cacheDir = cache();
219
+ write(cacheDir, await fetch(options(server.url, cacheDir)));
220
+ const saved = { CI: process.env.CI, [OFFLINE_ENV]: process.env[OFFLINE_ENV] };
221
+ process.env.CI = "";
222
+ process.env[OFFLINE_ENV] = "1";
223
+ try {
224
+ const result = await runPlugin({ name: "argocd", host: "fetch-argocd" }, { portolanVersion: "0.1.0", options: options(server.url, cacheDir) });
225
+ expect(result.files).toHaveLength(2);
226
+ expect(result.warnings).toEqual([`${server.url}: not fetched (offline); the snapshot committed in this repository is used unchanged`]);
227
+ const described = await runPlugin({ name: "argocd", host: "fetch-argocd" }, { portolanVersion: "0.1.0", kind: "describe" });
228
+ expect(described.describe).toMatchObject({ name: "fetch-argocd", phases: ["extract"] });
229
+ } finally {
230
+ for (const [key, value] of Object.entries(saved)) {
231
+ if (value === undefined) delete process.env[key];
232
+ else process.env[key] = value;
233
+ }
234
+ }
235
+ });
236
+ });
237
+
238
+ describe("one application", () => {
239
+ it("spells the server as a URL, https unless told otherwise", () => {
240
+ expect(baseUrl("argocd.example.com")).toBe("https://argocd.example.com");
241
+ expect(baseUrl("https://argocd.example.com/")).toBe("https://argocd.example.com");
242
+ expect(baseUrl("http://127.0.0.1:8080")).toBe("http://127.0.0.1:8080");
243
+ });
244
+
245
+ it("reads the environment off the label the manifest names, else off the cluster", () => {
246
+ const base = "https://argocd.example.com";
247
+ expect(deploymentOf(CART, base, "env").environment).toBe("prod");
248
+ expect(deploymentOf(CART, base, "tier").environment).toBe("in-cluster");
249
+ expect(deploymentOf({ ...CART, spec: { ...CART.spec, destination: { server: "https://1.2.3.4", namespace: "x" } } }, base, "tier").environment).toBe("https://1.2.3.4");
250
+ });
251
+
252
+ it("infers the tool from the source until the status has said", () => {
253
+ expect(toolOf("Kustomize", {})).toBe("kustomize");
254
+ expect(toolOf("", { chart: "x" })).toBe("helm");
255
+ expect(toolOf("", { helm: {} })).toBe("helm");
256
+ expect(toolOf("", { kustomize: {} })).toBe("kustomize");
257
+ expect(toolOf("", { plugin: {} })).toBe("plugin");
258
+ expect(toolOf("", { directory: {} })).toBe("directory");
259
+ expect(toolOf("", {})).toBe("");
260
+ });
261
+
262
+ it("places by the labels the manifest names, and by nothing when only one of the two is there", () => {
263
+ const base = "https://argocd.example.com";
264
+ const labelled = (labels) => ({ ...CART, metadata: { ...CART.metadata, labels } });
265
+ expect(deploymentOf(labelled({ team: "shop", component: "cart" }), base, "env", { context: "team", service: "component" }).service).toBe("shop.cart");
266
+ expect(deploymentOf(labelled({ "app.kubernetes.io/name": "cart" }), base).service).toBeUndefined();
267
+ expect(deploymentOf(labelled({}), base).service).toBeUndefined();
268
+ });
269
+
270
+ it("refuses an application without a name, and defaults the namespace and project", () => {
271
+ expect(() => deploymentOf({ metadata: {} }, "https://a")).toThrow(/no name/);
272
+ expect(deploymentOf({ metadata: { name: "x" } }, "https://a")).toMatchObject({ id: "argocd/x", project: "default", tool: "", url: "https://a/applications/argocd/x" });
273
+ });
274
+ });
@@ -46,6 +46,7 @@ export function describe() {
46
46
  return {
47
47
  name: "fetch-bsr",
48
48
  summary: "Fetches pinned modules from a Buf Schema Registry into the tree, with a lock beside each, so the proto extractor can read a published contract.",
49
+ category: "sources",
49
50
  phases: ["extract"],
50
51
  options: optionsSchema,
51
52
  };