@shortlink-org/portolan 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -6
- package/catalog/model.go +147 -0
- package/catalog/roundtrip_test.go +7 -0
- package/cli/portolan.mjs +6 -1
- package/go.mod +7 -0
- package/go.sum +20 -6
- package/package.json +10 -9
- package/plugin/describe.go +38 -0
- package/plugins/README.md +296 -5
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +13 -2
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +2 -1
- package/plugins/extract-django/extract_test.py +13 -0
- package/plugins/extract-django/lifecycle.py +2 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +5 -4
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +130 -3
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +880 -13
- package/scripts/README.md +3 -1
- package/scripts/delivery-presets.mjs +36 -3
- package/scripts/gen-likec4.mjs +149 -16
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +30 -3
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +1 -0
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +216 -8
- package/scripts/local-api.test.mjs +88 -2
- package/scripts/local-discovery.mjs +12 -0
- package/scripts/manifest.mjs +10 -1
- package/scripts/plugin-host.mjs +23 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/run-builtin.mjs +26 -5
- package/scripts/schema.mjs +138 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +12 -6
- package/scripts/warning-policy.test.mjs +40 -3
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +246 -1
- package/src/catalog-validation.ts +104 -2
- package/src/catalog.test.ts +40 -1
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.tsx +1 -1
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +21 -0
- package/src/enrich.test.ts +127 -3
- package/src/enrich.ts +104 -6
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +53 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +24 -0
- package/src/landing/LandingPage.tsx +15 -11
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +96 -2
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +9 -0
- package/src/lib/warnings.ts +10 -10
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +173 -9
- package/src/pages/AggregatePage.tsx +57 -7
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +2 -0
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +58 -2
- package/src/pages/Settings.tsx +28 -2
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -0,0 +1,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
|
};
|