@shortlink-org/portolan 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +28 -15
- package/plugin/describe.go +156 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +116 -4
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -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
|
};
|
|
@@ -34,6 +34,7 @@ export function describe() {
|
|
|
34
34
|
return {
|
|
35
35
|
name: "fetch-csr",
|
|
36
36
|
summary: "Fetches pinned subject versions from a Confluent Schema Registry into the tree, with a lock beside each, so a schema published to a registry can be read without talking to it.",
|
|
37
|
+
category: "sources",
|
|
37
38
|
phases: ["extract"],
|
|
38
39
|
options: optionsSchema,
|
|
39
40
|
};
|
|
@@ -44,10 +44,23 @@ export const LOCK_NAME = "git.lock.json";
|
|
|
44
44
|
export const PIN_NAME = "git.repo.json";
|
|
45
45
|
export const OFFLINE_ENV = "PORTOLAN_OFFLINE";
|
|
46
46
|
|
|
47
|
+
const SNIFF_BYTES = 8 * 1024;
|
|
48
|
+
const BINARY_SUFFIXES = [
|
|
49
|
+
".tar.gz", ".tar.bz2", ".tar.xz",
|
|
50
|
+
".7z", ".a", ".apk", ".avif", ".bin", ".bmp", ".bz2", ".class",
|
|
51
|
+
".deb", ".dll", ".dmg", ".doc", ".docx", ".eot", ".exe", ".gif",
|
|
52
|
+
".gz", ".ico", ".iso", ".jar", ".jpeg", ".jpg", ".lib", ".mmdb",
|
|
53
|
+
".mov", ".mp3", ".mp4", ".o", ".otf", ".pdf", ".png", ".pyc",
|
|
54
|
+
".rar", ".so", ".tar", ".tgz", ".tif", ".tiff", ".ttf", ".war",
|
|
55
|
+
".wasm", ".webm", ".webp", ".woff", ".woff2", ".xls", ".xlsx",
|
|
56
|
+
".xz", ".zip",
|
|
57
|
+
];
|
|
58
|
+
|
|
47
59
|
export function describe() {
|
|
48
60
|
return {
|
|
49
61
|
name: "fetch-git",
|
|
50
62
|
summary: "Fetches pinned directories of another git repository into the tree, with a lock, so extractors can read a service that lives elsewhere.",
|
|
63
|
+
category: "sources",
|
|
51
64
|
phases: ["extract"],
|
|
52
65
|
options: optionsSchema,
|
|
53
66
|
};
|
|
@@ -105,7 +118,7 @@ export function run(request, { env = process.env } = {}) {
|
|
|
105
118
|
}
|
|
106
119
|
continue;
|
|
107
120
|
}
|
|
108
|
-
emitFetched(out, dir, want, fetched.commit, fetched.files, generatedAt);
|
|
121
|
+
emitFetched(out, dir, want, fetched.commit, fetched.files, fetched.skipped, generatedAt);
|
|
109
122
|
}
|
|
110
123
|
|
|
111
124
|
return out.response();
|
|
@@ -172,9 +185,11 @@ export function pin(repo, commit, generatedAt = "") {
|
|
|
172
185
|
/** The lock, written the way every generated file here is written. */
|
|
173
186
|
export function encodeLock(entry) {
|
|
174
187
|
const files = [...entry.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
188
|
+
const skipped = [...(entry.skipped ?? [])].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
175
189
|
const record = { repo: entry.repo, commit: entry.commit };
|
|
176
190
|
if (entry.paths?.length) record.paths = [...entry.paths].sort();
|
|
177
191
|
record.files = files.map((file) => ({ path: file.path, sha256: file.sha256, size: file.size }));
|
|
192
|
+
if (skipped.length) record.skipped = skipped.map((file) => ({ path: file.path, size: file.size, reason: file.reason }));
|
|
178
193
|
return `${JSON.stringify({ repos: [record] }, null, 2)}\n`;
|
|
179
194
|
}
|
|
180
195
|
|
|
@@ -191,13 +206,15 @@ function live(url, want, out, env) {
|
|
|
191
206
|
commit = resolve(url, want.ref, env);
|
|
192
207
|
out.warn(want.repo, `is not pinned; "${want.ref || "HEAD"}" resolved to ${commit}. Pin it in portolan.json or every run is a lottery.`);
|
|
193
208
|
}
|
|
194
|
-
const
|
|
195
|
-
if (files.size === 0
|
|
196
|
-
|
|
209
|
+
const fetched = download(url, commit, want.paths ?? [], env);
|
|
210
|
+
if (fetched.files.size === 0 && fetched.skipped.length === 0) {
|
|
211
|
+
throw new Error(`${want.repo} at ${commit} holds no files under ${(want.paths ?? []).join(", ")}`);
|
|
212
|
+
}
|
|
213
|
+
return { commit, ...fetched };
|
|
197
214
|
}
|
|
198
215
|
|
|
199
|
-
function emitFetched(out, dir, want, commit, files, generatedAt) {
|
|
200
|
-
const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [] };
|
|
216
|
+
function emitFetched(out, dir, want, commit, files, skipped, generatedAt) {
|
|
217
|
+
const entry = { repo: want.repo, commit, paths: want.paths ?? [], files: [], skipped };
|
|
201
218
|
for (const path of [...files.keys()].sort()) {
|
|
202
219
|
const contents = files.get(path);
|
|
203
220
|
out.file(posix.join(dir, path), contents);
|
|
@@ -205,6 +222,7 @@ function emitFetched(out, dir, want, commit, files, generatedAt) {
|
|
|
205
222
|
}
|
|
206
223
|
out.file(posix.join(dir, LOCK_NAME), encodeLock(entry));
|
|
207
224
|
out.file(posix.join(dir, PIN_NAME), pin(want.repo, commit, generatedAt));
|
|
225
|
+
warnSkipped(out, want.repo, skipped);
|
|
208
226
|
}
|
|
209
227
|
|
|
210
228
|
/**
|
|
@@ -225,6 +243,13 @@ function emitCached(out, dir, at, want, why, generatedAt = "") {
|
|
|
225
243
|
// what is actually on disk rather than what was asked for.
|
|
226
244
|
out.file(posix.join(dir, PIN_NAME), pin(want.repo, held.lock.commit, generatedAt));
|
|
227
245
|
out.warn(want.repo, `not fetched (${why}); the copy committed in this repository is used unchanged`);
|
|
246
|
+
warnSkipped(out, want.repo, held.lock.skipped);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function warnSkipped(out, repo, skipped = []) {
|
|
250
|
+
if (skipped.length === 0) return;
|
|
251
|
+
const bytes = skipped.reduce((total, file) => total + file.size, 0);
|
|
252
|
+
out.warn(repo, `skipped ${skipped.length} binary ${skipped.length === 1 ? "file" : "files"} (${bytes} bytes); metadata is recorded in ${LOCK_NAME}`);
|
|
228
253
|
}
|
|
229
254
|
|
|
230
255
|
/**
|
|
@@ -252,7 +277,14 @@ function replay(dir) {
|
|
|
252
277
|
if (repos.length !== 1) throw new Error(`${lockPath} names ${repos.length} repositories; expected exactly one`);
|
|
253
278
|
const entry = repos[0];
|
|
254
279
|
const files = new Map();
|
|
280
|
+
const kept = [];
|
|
281
|
+
const skipped = [...(Array.isArray(entry.skipped) ? entry.skipped : [])];
|
|
255
282
|
for (const want of entry.files ?? []) {
|
|
283
|
+
const known = knownBinaryReason(want.path);
|
|
284
|
+
if (known) {
|
|
285
|
+
skipped.push({ path: want.path, size: want.size, reason: known });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
256
288
|
let contents;
|
|
257
289
|
try {
|
|
258
290
|
contents = readFileSync(join(dir, ...String(want.path).split("/")));
|
|
@@ -261,9 +293,15 @@ function replay(dir) {
|
|
|
261
293
|
throw cause;
|
|
262
294
|
}
|
|
263
295
|
if (digestOf(contents) !== want.sha256) throw new Error(`${want.path} does not match its digest; the vendored copy was edited by hand`);
|
|
296
|
+
const sniffed = sniffedBinaryReason(contents);
|
|
297
|
+
if (sniffed) {
|
|
298
|
+
skipped.push({ path: want.path, size: contents.length, reason: sniffed });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
264
301
|
files.set(want.path, contents);
|
|
302
|
+
kept.push(want);
|
|
265
303
|
}
|
|
266
|
-
return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files:
|
|
304
|
+
return { lock: { repo: entry.repo, commit: entry.commit, paths: entry.paths ?? [], files: kept, skipped }, files };
|
|
267
305
|
}
|
|
268
306
|
|
|
269
307
|
// --- git --------------------------------------------------------------------
|
|
@@ -305,7 +343,7 @@ function resolve(url, ref, env) {
|
|
|
305
343
|
* its branches where it does not, which is the case for a plain file://
|
|
306
344
|
* remote; either way what is read is verified to be that commit.
|
|
307
345
|
*
|
|
308
|
-
* @returns {Map<string, Buffer
|
|
346
|
+
* @returns {{files: Map<string, Buffer>, skipped: {path: string, size: number, reason: string}[]}}
|
|
309
347
|
*/
|
|
310
348
|
function download(url, commit, paths, env) {
|
|
311
349
|
const tmp = mkdtempSync(join(tmpdir(), "portolan-fetch-git-"));
|
|
@@ -326,19 +364,24 @@ function download(url, commit, paths, env) {
|
|
|
326
364
|
throw new Error(`${commit} is not a commit ${url} has, or not one reachable from a branch`);
|
|
327
365
|
}
|
|
328
366
|
|
|
329
|
-
const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(path)", commit, "--", ...paths], env);
|
|
367
|
+
const listing = git(tmp, ["ls-tree", "-r", "-z", "--format=%(objectname) %(objecttype) %(objectsize) %(path)", commit, "--", ...paths], env);
|
|
330
368
|
const blobs = [];
|
|
369
|
+
const skipped = [];
|
|
331
370
|
for (const line of listing.split("\0")) {
|
|
332
371
|
if (!line) continue;
|
|
333
|
-
const [sha, type, ...rest] = line.split(" ");
|
|
372
|
+
const [sha, type, rawSize, ...rest] = line.split(" ");
|
|
334
373
|
if (type !== "blob") continue;
|
|
335
|
-
|
|
374
|
+
const path = posix.normalize(rest.join(" "));
|
|
375
|
+
const size = Number(rawSize);
|
|
376
|
+
const reason = knownBinaryReason(path);
|
|
377
|
+
if (reason) skipped.push({ path, size, reason });
|
|
378
|
+
else blobs.push({ sha, path, size });
|
|
336
379
|
}
|
|
337
380
|
const files = new Map();
|
|
338
|
-
if (blobs.length === 0) return files;
|
|
381
|
+
if (blobs.length === 0) return { files, skipped };
|
|
339
382
|
|
|
340
|
-
// One
|
|
341
|
-
//
|
|
383
|
+
// One `cat-file --batch` process answers every remaining sha with a header
|
|
384
|
+
// line and the bytes. Known binaries never enter this batch.
|
|
342
385
|
const batch = execFileSync("git", ["cat-file", "--batch"], {
|
|
343
386
|
cwd: tmp,
|
|
344
387
|
env: { ...env, GIT_TERMINAL_PROMPT: "0" },
|
|
@@ -352,15 +395,30 @@ function download(url, commit, paths, env) {
|
|
|
352
395
|
if (header[1] === "missing") throw new Error(`${blob.path} is missing from ${commit}`);
|
|
353
396
|
const size = Number(header[2]);
|
|
354
397
|
const start = newline + 1;
|
|
355
|
-
|
|
398
|
+
const contents = batch.subarray(start, start + size);
|
|
399
|
+
const reason = sniffedBinaryReason(contents);
|
|
400
|
+
if (reason) skipped.push({ path: blob.path, size, reason });
|
|
401
|
+
else files.set(blob.path, Buffer.from(contents));
|
|
356
402
|
offset = start + size + 1;
|
|
357
403
|
}
|
|
358
|
-
return files;
|
|
404
|
+
return { files, skipped };
|
|
359
405
|
} finally {
|
|
360
406
|
rmSync(tmp, { recursive: true, force: true });
|
|
361
407
|
}
|
|
362
408
|
}
|
|
363
409
|
|
|
410
|
+
function knownBinaryReason(path) {
|
|
411
|
+
const lower = String(path).toLowerCase();
|
|
412
|
+
const suffix = BINARY_SUFFIXES.find((candidate) => lower.endsWith(candidate));
|
|
413
|
+
return suffix ? `known binary extension ${suffix}` : "";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function sniffedBinaryReason(contents) {
|
|
417
|
+
if (contents.subarray(0, SNIFF_BYTES).includes(0)) return `NUL byte in first ${SNIFF_BYTES} bytes`;
|
|
418
|
+
const text = contents.toString("utf8");
|
|
419
|
+
return Buffer.from(text, "utf8").equals(contents) ? "" : "invalid UTF-8";
|
|
420
|
+
}
|
|
421
|
+
|
|
364
422
|
/** Accumulates a response the way plugin.Builder does. */
|
|
365
423
|
class Builder {
|
|
366
424
|
files = [];
|
|
@@ -372,11 +430,10 @@ class Builder {
|
|
|
372
430
|
return;
|
|
373
431
|
}
|
|
374
432
|
const text = contents.toString("utf8");
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
);
|
|
433
|
+
if (!Buffer.from(text, "utf8").equals(contents) || contents.subarray(0, SNIFF_BYTES).includes(0)) {
|
|
434
|
+
throw new Error(`${name} is binary and cannot be emitted by fetch-git`);
|
|
435
|
+
}
|
|
436
|
+
this.files.push({ name, contents: text });
|
|
380
437
|
}
|
|
381
438
|
|
|
382
439
|
warn(ref, message) {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { basename, dirname, join } from "node:path";
|
|
5
6
|
import { afterEach, describe, expect, it } from "vitest";
|
|
6
7
|
|
|
7
8
|
import { runPlugin } from "../plugin-host.mjs";
|
|
8
|
-
import { OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
|
|
9
|
+
import { LOCK_NAME, OFFLINE_ENV, encodeLock, pin, run, splitRepo, webRepo } from "./fetch-git.mjs";
|
|
9
10
|
|
|
10
11
|
const created = [];
|
|
11
12
|
afterEach(() => {
|
|
@@ -105,18 +106,71 @@ describe("fetch-git", () => {
|
|
|
105
106
|
expect(inCi.warnings).toHaveLength(1);
|
|
106
107
|
});
|
|
107
108
|
|
|
108
|
-
it("
|
|
109
|
+
it("skips binary blobs online and from the offline cache", () => {
|
|
109
110
|
const remote = repository();
|
|
110
111
|
const cacheDir = cache();
|
|
111
112
|
const first = fetch(options(remote, cacheDir, ["docs/example.png"]), online);
|
|
112
113
|
const name = `${remote.copyDir}/docs/example.png`;
|
|
113
|
-
|
|
114
|
-
expect(
|
|
114
|
+
expect(first.files.find((file) => file.name === name)).toBeUndefined();
|
|
115
|
+
expect(first.files.every((file) => file.encoding === undefined)).toBe(true);
|
|
116
|
+
const lockName = `${remote.copyDir}/${LOCK_NAME}`;
|
|
117
|
+
expect(JSON.parse(contentsOf(first, lockName)).repos[0].skipped).toEqual([
|
|
118
|
+
{ path: "docs/example.png", size: 10, reason: "known binary extension .png" },
|
|
119
|
+
]);
|
|
120
|
+
expect(first.warnings[0].message).toContain("skipped 1 binary file (10 bytes)");
|
|
115
121
|
write(cacheDir, first);
|
|
116
|
-
expect(readFileSync(join(cacheDir, name))).
|
|
122
|
+
expect(() => readFileSync(join(cacheDir, name))).toThrow();
|
|
117
123
|
|
|
118
124
|
const replayed = fetch(options(remote, cacheDir, ["docs/example.png"]), offline);
|
|
119
|
-
expect(replayed.files.find((file) => file.name === name)).
|
|
125
|
+
expect(replayed.files.find((file) => file.name === name)).toBeUndefined();
|
|
126
|
+
expect(JSON.parse(contentsOf(replayed, lockName)).repos[0].skipped).toEqual([
|
|
127
|
+
{ path: "docs/example.png", size: 10, reason: "known binary extension .png" },
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("does not read a large known binary into the plugin response and sniffs unknown binary files", () => {
|
|
132
|
+
const remote = repository();
|
|
133
|
+
const mmdb = "tests/integration/resources/GeoLite2-Country.mmdb";
|
|
134
|
+
const unknown = "tests/integration/resources/blob.data";
|
|
135
|
+
mkdirSync(dirname(join(remote.dir, mmdb)), { recursive: true });
|
|
136
|
+
writeFileSync(join(remote.dir, mmdb), Buffer.alloc(10 * 1024 * 1024, 0xab));
|
|
137
|
+
writeFileSync(join(remote.dir, unknown), Buffer.from([1, 0, 2]));
|
|
138
|
+
remote.git(["add", "."]);
|
|
139
|
+
remote.git(["commit", "--quiet", "-m", "binary fixtures"]);
|
|
140
|
+
remote.commit = remote.git(["rev-parse", "HEAD"]);
|
|
141
|
+
|
|
142
|
+
const response = fetch(options(remote, cache(), [mmdb, unknown]), online);
|
|
143
|
+
expect(response.files.every((file) => file.encoding === undefined)).toBe(true);
|
|
144
|
+
expect(JSON.stringify(response).length).toBeLessThan(20_000);
|
|
145
|
+
const lock = JSON.parse(contentsOf(response, `${remote.copyDir}/${LOCK_NAME}`)).repos[0];
|
|
146
|
+
expect(lock.files).toEqual([]);
|
|
147
|
+
expect(lock.skipped).toEqual([
|
|
148
|
+
{ path: mmdb, size: 10 * 1024 * 1024, reason: "known binary extension .mmdb" },
|
|
149
|
+
{ path: unknown, size: 3, reason: "NUL byte in first 8192 bytes" },
|
|
150
|
+
]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("drops binary files from a cache written by an older Portolan", () => {
|
|
154
|
+
const remote = repository();
|
|
155
|
+
const cacheDir = cache();
|
|
156
|
+
const at = join(cacheDir, remote.copyDir);
|
|
157
|
+
const path = "docs/example.png";
|
|
158
|
+
const contents = readFileSync(join(remote.dir, path));
|
|
159
|
+
mkdirSync(dirname(join(at, path)), { recursive: true });
|
|
160
|
+
writeFileSync(join(at, path), contents);
|
|
161
|
+
writeFileSync(join(at, LOCK_NAME), encodeLock({
|
|
162
|
+
repo: remote.url,
|
|
163
|
+
commit: remote.commit,
|
|
164
|
+
paths: [path],
|
|
165
|
+
files: [{ path, size: contents.length, sha256: createHash("sha256").update(contents).digest("hex") }],
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
const replayed = fetch(options(remote, cacheDir, [path]), offline);
|
|
169
|
+
expect(replayed.files.some((file) => file.name.endsWith(path))).toBe(false);
|
|
170
|
+
expect(JSON.parse(contentsOf(replayed, `${remote.copyDir}/${LOCK_NAME}`)).repos[0]).toMatchObject({
|
|
171
|
+
files: [],
|
|
172
|
+
skipped: [{ path, size: contents.length, reason: "known binary extension .png" }],
|
|
173
|
+
});
|
|
120
174
|
});
|
|
121
175
|
|
|
122
176
|
it("names the edited file when a vendored copy no longer matches its lock", () => {
|
|
@@ -223,8 +277,8 @@ describe("names", () => {
|
|
|
223
277
|
repos: [{ repo: "github.com/acme/shop", commit: "c1d2e3f" }],
|
|
224
278
|
});
|
|
225
279
|
|
|
226
|
-
const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }] });
|
|
227
|
-
expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }] }] });
|
|
280
|
+
const lock = encodeLock({ repo: "github.com/acme/shop", commit: "abc", paths: ["services/oms", "proto"], files: [{ path: "b", sha256: "2", size: 1 }, { path: "a", sha256: "1", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] });
|
|
281
|
+
expect(JSON.parse(lock)).toEqual({ repos: [{ repo: "github.com/acme/shop", commit: "abc", paths: ["proto", "services/oms"], files: [{ path: "a", sha256: "1", size: 1 }, { path: "b", sha256: "2", size: 1 }], skipped: [{ path: "z.png", size: 2, reason: "known binary extension .png" }] }] });
|
|
228
282
|
expect(JSON.parse(encodeLock({ repo: "r", commit: "c", paths: [], files: [] })).repos[0]).not.toHaveProperty("paths");
|
|
229
283
|
});
|
|
230
284
|
});
|