kubeagent 0.1.35 → 0.1.36
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 +2 -0
- package/dist/cli.js +108 -0
- package/dist/detector/cache.d.ts +6 -0
- package/dist/detector/cache.js +44 -0
- package/dist/detector/cache.test.d.ts +1 -0
- package/dist/detector/cache.test.js +54 -0
- package/dist/detector/catalog.d.ts +17 -0
- package/dist/detector/catalog.js +88 -0
- package/dist/detector/catalog.test.d.ts +1 -0
- package/dist/detector/catalog.test.js +33 -0
- package/dist/detector/eol.d.ts +13 -0
- package/dist/detector/eol.js +64 -0
- package/dist/detector/eol.test.d.ts +1 -0
- package/dist/detector/eol.test.js +78 -0
- package/dist/detector/image-parser.d.ts +11 -0
- package/dist/detector/image-parser.js +59 -0
- package/dist/detector/image-parser.test.d.ts +1 -0
- package/dist/detector/image-parser.test.js +87 -0
- package/dist/detector/index.d.ts +33 -0
- package/dist/detector/index.js +217 -0
- package/dist/detector/index.test.d.ts +1 -0
- package/dist/detector/index.test.js +210 -0
- package/dist/detector/osv.d.ts +12 -0
- package/dist/detector/osv.js +50 -0
- package/dist/detector/osv.test.d.ts +1 -0
- package/dist/detector/osv.test.js +86 -0
- package/dist/detector/report.d.ts +6 -0
- package/dist/detector/report.js +91 -0
- package/dist/detector/sources.d.ts +42 -0
- package/dist/detector/sources.js +218 -0
- package/dist/diagnoser/tools.d.ts +2 -2
- package/dist/kb/loader.js +6 -0
- package/dist/kb/writer.d.ts +1 -0
- package/dist/kb/writer.js +4 -0
- package/dist/onboard/index.js +23 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ kubeagent watch
|
|
|
29
29
|
| `watch` | Continuous monitoring with auto-remediation |
|
|
30
30
|
| `diagnose <resource>` | One-shot diagnosis of a pod/deployment/service |
|
|
31
31
|
| `scan <directory>` | Match local project directories to cluster deployments |
|
|
32
|
+
| `scan-apps` | Detect OSS apps in the cluster, list affected workloads + ingress domains, check for CVEs / EOL (`--report` for AI-prioritized remediation) |
|
|
32
33
|
| `notify` | Manage notification channels (`list`, `add`, `remove`, `test`) |
|
|
33
34
|
| `login` | Log in to KubeAgent (browser or `--device` for headless) |
|
|
34
35
|
| `account` | Show account info and token balance |
|
|
@@ -52,6 +53,7 @@ KubeAgent builds a per-cluster knowledge base at `~/.kubeagent/clusters/<context
|
|
|
52
53
|
clusters/
|
|
53
54
|
hetzner-prod/
|
|
54
55
|
cluster.md # Nodes, namespaces, deployments, services
|
|
56
|
+
applications.md # Detected OSS apps with CVE / EOL status
|
|
55
57
|
projects/
|
|
56
58
|
api.md # Tech stack, dependencies, notes
|
|
57
59
|
runbooks/ # Custom runbooks (manually added)
|
package/dist/cli.js
CHANGED
|
@@ -328,6 +328,114 @@ program
|
|
|
328
328
|
}
|
|
329
329
|
}
|
|
330
330
|
});
|
|
331
|
+
program
|
|
332
|
+
.command("scan-apps")
|
|
333
|
+
.description("Detect open-source apps in the cluster and check for CVEs / EOL versions")
|
|
334
|
+
.option("-c, --context <context>", "Kubernetes context")
|
|
335
|
+
.option("--no-cache", "Force refresh of CVE/EOL data")
|
|
336
|
+
.option("--json", "Print JSON result instead of writing the knowledge base")
|
|
337
|
+
.option("--report", "Also generate an AI-prioritized remediation report (uses tokens)")
|
|
338
|
+
.action(async (opts) => {
|
|
339
|
+
const { detectApplications, formatApplicationsMarkdown } = await import("./detector/index.js");
|
|
340
|
+
const { writeApplicationsKb, ensureKbDir } = await import("./kb/writer.js");
|
|
341
|
+
const contextName = opts.context ?? "default";
|
|
342
|
+
const spinner = ora("Scanning cluster for OSS applications...").start();
|
|
343
|
+
let result;
|
|
344
|
+
try {
|
|
345
|
+
result = await detectApplications({
|
|
346
|
+
context: opts.context,
|
|
347
|
+
noCache: opts.cache === false,
|
|
348
|
+
onProgress: (step) => {
|
|
349
|
+
spinner.text = `Scanning cluster for OSS applications — ${step}...`;
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
spinner.fail(`Scan failed: ${err.message}`);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
const eolCount = result.apps.filter((a) => a.eol?.eol).length;
|
|
358
|
+
const cveCount = result.apps.filter((a) => a.cves.length > 0).length;
|
|
359
|
+
spinner.succeed(`Inventoried ${result.apps.length} app${result.apps.length === 1 ? "" : "s"} ` +
|
|
360
|
+
`(${eolCount} EOL, ${cveCount} with CVEs, ${result.unknowns.length} unmatched)`);
|
|
361
|
+
if (opts.json) {
|
|
362
|
+
console.log(JSON.stringify(result, null, 2));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const kbDir = join(configDir(), "clusters", contextName);
|
|
366
|
+
ensureKbDir(kbDir);
|
|
367
|
+
writeApplicationsKb(kbDir, formatApplicationsMarkdown(result));
|
|
368
|
+
console.log(chalk.dim(`Inventory written to ${join(kbDir, "applications.md")}`));
|
|
369
|
+
if (result.apps.length === 0 && result.unknowns.length === 0) {
|
|
370
|
+
console.log(chalk.dim("No detectable OSS applications found."));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const renderWorkloads = (workloads) => {
|
|
374
|
+
for (const w of workloads) {
|
|
375
|
+
const hosts = w.hosts && w.hosts.length > 0 ? chalk.cyan(` ${w.hosts.join(", ")}`) : "";
|
|
376
|
+
console.log(chalk.dim(` ↳ ${w.kind}/${w.namespace}/${w.name}`) + hosts);
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
console.log();
|
|
380
|
+
for (const a of result.apps) {
|
|
381
|
+
if (a.eol?.eol) {
|
|
382
|
+
const since = a.eol.eolDate ? ` since ${a.eol.eolDate}` : "";
|
|
383
|
+
const latest = a.eol.latest ? chalk.dim(` (latest: ${a.eol.latest})`) : "";
|
|
384
|
+
console.log(chalk.red(`[critical] ${a.display} ${a.version} — EOL${since}`) +
|
|
385
|
+
latest +
|
|
386
|
+
chalk.dim(` ${a.workloads.length} workload(s)`));
|
|
387
|
+
renderWorkloads(a.workloads);
|
|
388
|
+
}
|
|
389
|
+
else if (a.cves.length > 0) {
|
|
390
|
+
const ids = a.cves.slice(0, 3).map((c) => c.id).join(", ");
|
|
391
|
+
const more = a.cves.length > 3 ? ` + ${a.cves.length - 3} more` : "";
|
|
392
|
+
console.log(chalk.yellow(`[warning] ${a.display} ${a.version} — ${a.cves.length} CVE(s): ${ids}${more}`) +
|
|
393
|
+
chalk.dim(` ${a.workloads.length} workload(s)`));
|
|
394
|
+
renderWorkloads(a.workloads);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
console.log(chalk.green(`[info] ${a.display} ${a.version} — no known CVEs`) +
|
|
398
|
+
chalk.dim(` ${a.workloads.length} workload(s)`));
|
|
399
|
+
renderWorkloads(a.workloads);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (opts.report) {
|
|
403
|
+
if (result.apps.length === 0) {
|
|
404
|
+
console.log(chalk.dim("\nNo apps to analyze — skipping report."));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const auth = loadAuth();
|
|
408
|
+
if (!auth?.apiKey) {
|
|
409
|
+
console.error(chalk.red("\n--report requires login. Run: kubeagent login"));
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
const { generateReport } = await import("./detector/report.js");
|
|
413
|
+
const { writeFileSync } = await import("node:fs");
|
|
414
|
+
const reportSpinner = ora("Generating remediation report...").start();
|
|
415
|
+
let report;
|
|
416
|
+
try {
|
|
417
|
+
report = await generateReport(result, auth, {
|
|
418
|
+
onProgress: (s) => { reportSpinner.text = `Generating remediation report — ${s}...`; },
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
const e = err;
|
|
423
|
+
if (e.status === 402 || e.code === "token_exhausted") {
|
|
424
|
+
reportSpinner.fail("Token balance exhausted. Run: kubeagent account");
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
reportSpinner.fail(`Report failed: ${e.message.replace("KubeAgent proxy: ", "")}`);
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
reportSpinner.succeed("Remediation report ready");
|
|
432
|
+
const reportPath = join(kbDir, "report.md");
|
|
433
|
+
writeFileSync(reportPath, report);
|
|
434
|
+
console.log(chalk.dim(`Report written to ${reportPath}`));
|
|
435
|
+
console.log();
|
|
436
|
+
console.log(report);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
331
439
|
// Notify commands
|
|
332
440
|
const notifyCmd = program.command("notify").description("Manage notification channels");
|
|
333
441
|
notifyCmd
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function readCache<T>(kind: string, key: string): T | null;
|
|
2
|
+
export declare function readStaleCache<T>(kind: string, key: string): {
|
|
3
|
+
data: T;
|
|
4
|
+
fetchedAt: number;
|
|
5
|
+
} | null;
|
|
6
|
+
export declare function writeCache<T>(kind: string, key: string, data: T): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { configDir } from "../config.js";
|
|
4
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
function cacheFile(kind, key) {
|
|
6
|
+
const safe = key.replace(/[^a-z0-9_.@+-]/gi, "_");
|
|
7
|
+
return join(configDir(), "cache", kind, `${safe}.json`);
|
|
8
|
+
}
|
|
9
|
+
export function readCache(kind, key) {
|
|
10
|
+
const path = cacheFile(kind, key);
|
|
11
|
+
if (!existsSync(path))
|
|
12
|
+
return null;
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(path, "utf-8");
|
|
15
|
+
const entry = JSON.parse(raw);
|
|
16
|
+
if (Date.now() - entry.fetchedAt > TTL_MS)
|
|
17
|
+
return null;
|
|
18
|
+
return entry.data;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function readStaleCache(kind, key) {
|
|
25
|
+
const path = cacheFile(kind, key);
|
|
26
|
+
if (!existsSync(path))
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
const raw = readFileSync(path, "utf-8");
|
|
30
|
+
const entry = JSON.parse(raw);
|
|
31
|
+
return { data: entry.data, fetchedAt: entry.fetchedAt };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function writeCache(kind, key, data) {
|
|
38
|
+
const path = cacheFile(kind, key);
|
|
39
|
+
const dir = dirname(path);
|
|
40
|
+
if (!existsSync(dir))
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
const entry = { fetchedAt: Date.now(), data };
|
|
43
|
+
writeFileSync(path, JSON.stringify(entry));
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
let tmpHome;
|
|
6
|
+
let originalHome;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
originalHome = process.env.HOME;
|
|
9
|
+
tmpHome = mkdtempSync(join(tmpdir(), "kubeagent-cache-test-"));
|
|
10
|
+
process.env.HOME = tmpHome;
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (originalHome !== undefined)
|
|
14
|
+
process.env.HOME = originalHome;
|
|
15
|
+
else
|
|
16
|
+
delete process.env.HOME;
|
|
17
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
describe("cache", () => {
|
|
20
|
+
it("round-trips data within the TTL", async () => {
|
|
21
|
+
const { readCache, writeCache } = await import("./cache.js");
|
|
22
|
+
writeCache("osv", "redis@7.0.5", [{ id: "CVE-1" }]);
|
|
23
|
+
const got = readCache("osv", "redis@7.0.5");
|
|
24
|
+
expect(got).toEqual([{ id: "CVE-1" }]);
|
|
25
|
+
});
|
|
26
|
+
it("returns null for a missing entry", async () => {
|
|
27
|
+
const { readCache } = await import("./cache.js");
|
|
28
|
+
expect(readCache("osv", "nope")).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
it("creates the cache directory on write", async () => {
|
|
31
|
+
const { writeCache } = await import("./cache.js");
|
|
32
|
+
writeCache("eol", "postgres", [{ cycle: "15" }]);
|
|
33
|
+
expect(existsSync(join(tmpHome, ".kubeagent", "cache", "eol", "postgres.json"))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it("sanitizes unsafe characters in cache keys", async () => {
|
|
36
|
+
const { writeCache } = await import("./cache.js");
|
|
37
|
+
writeCache("osv", "foo/../bar@1.0", { x: 1 });
|
|
38
|
+
expect(existsSync(join(tmpHome, ".kubeagent", "cache", "osv", "foo_.._bar@1.0.json"))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it("readStaleCache returns data and a fetchedAt timestamp", async () => {
|
|
41
|
+
const { writeCache, readStaleCache } = await import("./cache.js");
|
|
42
|
+
writeCache("osv", "k", "v");
|
|
43
|
+
const stale = readStaleCache("osv", "k");
|
|
44
|
+
expect(stale?.data).toBe("v");
|
|
45
|
+
expect(typeof stale?.fetchedAt).toBe("number");
|
|
46
|
+
});
|
|
47
|
+
it("treats a malformed cache file as a miss", async () => {
|
|
48
|
+
const { writeCache, readCache } = await import("./cache.js");
|
|
49
|
+
writeCache("osv", "k", "v");
|
|
50
|
+
const path = join(tmpHome, ".kubeagent", "cache", "osv", "k.json");
|
|
51
|
+
writeFileSync(path, "not json");
|
|
52
|
+
expect(readCache("osv", "k")).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface OsvLookup {
|
|
2
|
+
name: string;
|
|
3
|
+
ecosystem?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ProductEntry {
|
|
6
|
+
display: string;
|
|
7
|
+
eolProduct?: string;
|
|
8
|
+
osv?: OsvLookup;
|
|
9
|
+
}
|
|
10
|
+
export declare const PRODUCTS: Record<string, ProductEntry>;
|
|
11
|
+
export declare const ALIASES: Record<string, string>;
|
|
12
|
+
export interface CatalogMatch {
|
|
13
|
+
canonical: string;
|
|
14
|
+
entry: ProductEntry;
|
|
15
|
+
}
|
|
16
|
+
export declare function lookupCatalog(repository: string, name: string): CatalogMatch | null;
|
|
17
|
+
export declare function lookupChart(chartName: string): CatalogMatch | null;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const PRODUCTS = {
|
|
2
|
+
postgres: { display: "PostgreSQL", eolProduct: "postgres", osv: { name: "postgresql" } },
|
|
3
|
+
mysql: { display: "MySQL", eolProduct: "mysql", osv: { name: "mysql" } },
|
|
4
|
+
mariadb: { display: "MariaDB", eolProduct: "mariadb", osv: { name: "mariadb" } },
|
|
5
|
+
redis: { display: "Redis", eolProduct: "redis", osv: { name: "redis" } },
|
|
6
|
+
mongodb: { display: "MongoDB", eolProduct: "mongodb", osv: { name: "mongodb" } },
|
|
7
|
+
nginx: { display: "nginx", eolProduct: "nginx", osv: { name: "nginx" } },
|
|
8
|
+
traefik: { display: "Traefik", eolProduct: "traefik" },
|
|
9
|
+
haproxy: { display: "HAProxy", eolProduct: "haproxy" },
|
|
10
|
+
rabbitmq: { display: "RabbitMQ", eolProduct: "rabbitmq", osv: { name: "rabbitmq-server" } },
|
|
11
|
+
kafka: { display: "Apache Kafka", eolProduct: "apache-kafka" },
|
|
12
|
+
elasticsearch: { display: "Elasticsearch", eolProduct: "elasticsearch", osv: { name: "elasticsearch" } },
|
|
13
|
+
kibana: { display: "Kibana", eolProduct: "kibana" },
|
|
14
|
+
grafana: { display: "Grafana", eolProduct: "grafana", osv: { name: "grafana" } },
|
|
15
|
+
prometheus: { display: "Prometheus", eolProduct: "prometheus" },
|
|
16
|
+
etcd: { display: "etcd", eolProduct: "etcd", osv: { name: "etcd" } },
|
|
17
|
+
kubernetes: { display: "Kubernetes", eolProduct: "kubernetes", osv: { name: "kubernetes" } },
|
|
18
|
+
node: { display: "Node.js", eolProduct: "nodejs" },
|
|
19
|
+
python: { display: "Python", eolProduct: "python", osv: { name: "python" } },
|
|
20
|
+
php: { display: "PHP", eolProduct: "php", osv: { name: "php" } },
|
|
21
|
+
ruby: { display: "Ruby", eolProduct: "ruby", osv: { name: "ruby" } },
|
|
22
|
+
};
|
|
23
|
+
export const ALIASES = {
|
|
24
|
+
postgresql: "postgres",
|
|
25
|
+
"library/postgres": "postgres",
|
|
26
|
+
"library/postgresql": "postgres",
|
|
27
|
+
"bitnami/postgresql": "postgres",
|
|
28
|
+
"bitnami/postgresql-repmgr": "postgres",
|
|
29
|
+
"library/mysql": "mysql",
|
|
30
|
+
"bitnami/mysql": "mysql",
|
|
31
|
+
"library/mariadb": "mariadb",
|
|
32
|
+
"bitnami/mariadb": "mariadb",
|
|
33
|
+
"library/redis": "redis",
|
|
34
|
+
"bitnami/redis": "redis",
|
|
35
|
+
"redis-stack": "redis",
|
|
36
|
+
"redis-stack-server": "redis",
|
|
37
|
+
"library/mongo": "mongodb",
|
|
38
|
+
mongo: "mongodb",
|
|
39
|
+
"library/mongodb": "mongodb",
|
|
40
|
+
"bitnami/mongodb": "mongodb",
|
|
41
|
+
"library/nginx": "nginx",
|
|
42
|
+
"bitnami/nginx": "nginx",
|
|
43
|
+
"nginxinc/nginx-unprivileged": "nginx",
|
|
44
|
+
"library/traefik": "traefik",
|
|
45
|
+
"library/haproxy": "haproxy",
|
|
46
|
+
"haproxytech/haproxy-alpine": "haproxy",
|
|
47
|
+
"library/rabbitmq": "rabbitmq",
|
|
48
|
+
"bitnami/rabbitmq": "rabbitmq",
|
|
49
|
+
"confluentinc/cp-kafka": "kafka",
|
|
50
|
+
"bitnami/kafka": "kafka",
|
|
51
|
+
"docker.elastic.co/elasticsearch/elasticsearch": "elasticsearch",
|
|
52
|
+
"docker.elastic.co/kibana/kibana": "kibana",
|
|
53
|
+
"grafana/grafana": "grafana",
|
|
54
|
+
"grafana/grafana-oss": "grafana",
|
|
55
|
+
"prom/prometheus": "prometheus",
|
|
56
|
+
"quay.io/prometheus/prometheus": "prometheus",
|
|
57
|
+
"library/etcd": "etcd",
|
|
58
|
+
"etcd": "etcd",
|
|
59
|
+
"registry.k8s.io/etcd": "etcd",
|
|
60
|
+
"library/node": "node",
|
|
61
|
+
"library/python": "python",
|
|
62
|
+
"library/php": "php",
|
|
63
|
+
"library/ruby": "ruby",
|
|
64
|
+
};
|
|
65
|
+
export function lookupCatalog(repository, name) {
|
|
66
|
+
const keys = [repository.toLowerCase(), name.toLowerCase()];
|
|
67
|
+
for (const key of keys) {
|
|
68
|
+
const aliased = ALIASES[key];
|
|
69
|
+
if (aliased && PRODUCTS[aliased]) {
|
|
70
|
+
return { canonical: aliased, entry: PRODUCTS[aliased] };
|
|
71
|
+
}
|
|
72
|
+
if (PRODUCTS[key]) {
|
|
73
|
+
return { canonical: key, entry: PRODUCTS[key] };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
export function lookupChart(chartName) {
|
|
79
|
+
const key = chartName.toLowerCase();
|
|
80
|
+
const aliased = ALIASES[key];
|
|
81
|
+
if (aliased && PRODUCTS[aliased]) {
|
|
82
|
+
return { canonical: aliased, entry: PRODUCTS[aliased] };
|
|
83
|
+
}
|
|
84
|
+
if (PRODUCTS[key]) {
|
|
85
|
+
return { canonical: key, entry: PRODUCTS[key] };
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { lookupCatalog, lookupChart } from "./catalog.js";
|
|
3
|
+
describe("lookupCatalog", () => {
|
|
4
|
+
it("matches bare image names from the products map", () => {
|
|
5
|
+
const m = lookupCatalog("library/postgres", "postgres");
|
|
6
|
+
expect(m?.canonical).toBe("postgres");
|
|
7
|
+
expect(m?.entry.display).toBe("PostgreSQL");
|
|
8
|
+
});
|
|
9
|
+
it("resolves aliases for bitnami charts", () => {
|
|
10
|
+
const m = lookupCatalog("bitnami/postgresql", "postgresql");
|
|
11
|
+
expect(m?.canonical).toBe("postgres");
|
|
12
|
+
});
|
|
13
|
+
it("resolves quay.io prometheus", () => {
|
|
14
|
+
const m = lookupCatalog("prometheus/prometheus", "prometheus");
|
|
15
|
+
expect(m?.canonical).toBe("prometheus");
|
|
16
|
+
});
|
|
17
|
+
it("handles case-insensitive lookups", () => {
|
|
18
|
+
const m = lookupCatalog("Library/PostgreSQL", "PostgreSQL");
|
|
19
|
+
expect(m?.canonical).toBe("postgres");
|
|
20
|
+
});
|
|
21
|
+
it("returns null for unknown repositories", () => {
|
|
22
|
+
expect(lookupCatalog("acme/internal-app", "internal-app")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("lookupChart", () => {
|
|
26
|
+
it("matches chart names to canonical products", () => {
|
|
27
|
+
expect(lookupChart("postgresql")?.canonical).toBe("postgres");
|
|
28
|
+
expect(lookupChart("redis")?.canonical).toBe("redis");
|
|
29
|
+
});
|
|
30
|
+
it("returns null for unknown charts", () => {
|
|
31
|
+
expect(lookupChart("unknown-chart")).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface EolStatus {
|
|
2
|
+
product: string;
|
|
3
|
+
cycle: string;
|
|
4
|
+
eol: boolean;
|
|
5
|
+
eolDate?: string;
|
|
6
|
+
latest?: string;
|
|
7
|
+
releaseDate?: string;
|
|
8
|
+
lts: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function queryEol(product: string, version: string, opts?: {
|
|
11
|
+
noCache?: boolean;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}): Promise<EolStatus | null>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readCache, readStaleCache, writeCache } from "./cache.js";
|
|
2
|
+
import { major, majorMinor } from "./image-parser.js";
|
|
3
|
+
function pickCycle(cycles, version) {
|
|
4
|
+
const mm = majorMinor(version);
|
|
5
|
+
const m = major(version);
|
|
6
|
+
return (cycles.find((c) => c.cycle === version) ??
|
|
7
|
+
cycles.find((c) => c.cycle === mm) ??
|
|
8
|
+
cycles.find((c) => c.cycle === m) ??
|
|
9
|
+
null);
|
|
10
|
+
}
|
|
11
|
+
function interpretCycle(product, cycle) {
|
|
12
|
+
let eol = false;
|
|
13
|
+
let eolDate;
|
|
14
|
+
if (typeof cycle.eol === "string") {
|
|
15
|
+
eolDate = cycle.eol;
|
|
16
|
+
eol = new Date(cycle.eol).getTime() < Date.now();
|
|
17
|
+
}
|
|
18
|
+
else if (cycle.eol === true) {
|
|
19
|
+
eol = true;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
product,
|
|
23
|
+
cycle: cycle.cycle,
|
|
24
|
+
eol,
|
|
25
|
+
eolDate,
|
|
26
|
+
latest: cycle.latest,
|
|
27
|
+
releaseDate: cycle.releaseDate,
|
|
28
|
+
lts: cycle.lts === true || (typeof cycle.lts === "string" && cycle.lts.length > 0),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export async function queryEol(product, version, opts = {}) {
|
|
32
|
+
if (!version)
|
|
33
|
+
return null;
|
|
34
|
+
if (!opts.noCache) {
|
|
35
|
+
const cached = readCache("eol", product);
|
|
36
|
+
if (cached) {
|
|
37
|
+
const cycle = pickCycle(cached, version);
|
|
38
|
+
return cycle ? interpretCycle(product, cycle) : null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`https://endoflife.date/api/${product}.json`, {
|
|
43
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 8000),
|
|
44
|
+
});
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const stale = readStaleCache("eol", product);
|
|
47
|
+
if (!stale)
|
|
48
|
+
return null;
|
|
49
|
+
const cycle = pickCycle(stale.data, version);
|
|
50
|
+
return cycle ? interpretCycle(product, cycle) : null;
|
|
51
|
+
}
|
|
52
|
+
const data = (await res.json());
|
|
53
|
+
writeCache("eol", product, data);
|
|
54
|
+
const cycle = pickCycle(data, version);
|
|
55
|
+
return cycle ? interpretCycle(product, cycle) : null;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
const stale = readStaleCache("eol", product);
|
|
59
|
+
if (!stale)
|
|
60
|
+
return null;
|
|
61
|
+
const cycle = pickCycle(stale.data, version);
|
|
62
|
+
return cycle ? interpretCycle(product, cycle) : null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
vi.mock("./cache.js", () => ({
|
|
3
|
+
readCache: vi.fn(),
|
|
4
|
+
readStaleCache: vi.fn(),
|
|
5
|
+
writeCache: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { readCache, readStaleCache, writeCache } from "./cache.js";
|
|
8
|
+
import { queryEol } from "./eol.js";
|
|
9
|
+
const mockFetch = vi.fn();
|
|
10
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
11
|
+
const cacheRead = vi.mocked(readCache);
|
|
12
|
+
const cacheStale = vi.mocked(readStaleCache);
|
|
13
|
+
const cacheWrite = vi.mocked(writeCache);
|
|
14
|
+
const POSTGRES_FIXTURE = [
|
|
15
|
+
{ cycle: "16", releaseDate: "2023-09-14", eol: "2028-11-09", latest: "16.2", lts: false },
|
|
16
|
+
{ cycle: "15", releaseDate: "2022-10-13", eol: "2027-11-11", latest: "15.6", lts: false },
|
|
17
|
+
{ cycle: "11", releaseDate: "2018-10-18", eol: "2023-11-09", latest: "11.22", lts: false },
|
|
18
|
+
];
|
|
19
|
+
describe("queryEol", () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
mockFetch.mockReset();
|
|
22
|
+
cacheRead.mockReset();
|
|
23
|
+
cacheStale.mockReset();
|
|
24
|
+
cacheWrite.mockReset();
|
|
25
|
+
cacheRead.mockReturnValue(null);
|
|
26
|
+
cacheStale.mockReturnValue(null);
|
|
27
|
+
});
|
|
28
|
+
it("hits endoflife.date and picks the matching cycle by major version", async () => {
|
|
29
|
+
mockFetch.mockResolvedValue({ ok: true, json: async () => POSTGRES_FIXTURE });
|
|
30
|
+
const status = await queryEol("postgres", "15.2", { noCache: true });
|
|
31
|
+
expect(mockFetch.mock.calls[0][0]).toBe("https://endoflife.date/api/postgres.json");
|
|
32
|
+
expect(status).toMatchObject({
|
|
33
|
+
product: "postgres",
|
|
34
|
+
cycle: "15",
|
|
35
|
+
eol: false,
|
|
36
|
+
eolDate: "2027-11-11",
|
|
37
|
+
latest: "15.6",
|
|
38
|
+
});
|
|
39
|
+
expect(cacheWrite).toHaveBeenCalledOnce();
|
|
40
|
+
});
|
|
41
|
+
it("flags a cycle as EOL when its date is in the past", async () => {
|
|
42
|
+
mockFetch.mockResolvedValue({ ok: true, json: async () => POSTGRES_FIXTURE });
|
|
43
|
+
const status = await queryEol("postgres", "11.18", { noCache: true });
|
|
44
|
+
expect(status?.cycle).toBe("11");
|
|
45
|
+
expect(status?.eol).toBe(true);
|
|
46
|
+
expect(status?.eolDate).toBe("2023-11-09");
|
|
47
|
+
});
|
|
48
|
+
it("returns null when no cycle matches", async () => {
|
|
49
|
+
mockFetch.mockResolvedValue({ ok: true, json: async () => POSTGRES_FIXTURE });
|
|
50
|
+
const status = await queryEol("postgres", "99.0", { noCache: true });
|
|
51
|
+
expect(status).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
it("returns null on network error with no cache", async () => {
|
|
54
|
+
mockFetch.mockRejectedValue(new Error("network down"));
|
|
55
|
+
const status = await queryEol("postgres", "15.2", { noCache: true });
|
|
56
|
+
expect(status).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
it("serves a fresh cached entry without hitting the network", async () => {
|
|
59
|
+
cacheRead.mockReturnValue(POSTGRES_FIXTURE);
|
|
60
|
+
const status = await queryEol("postgres", "15.2");
|
|
61
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
62
|
+
expect(status?.cycle).toBe("15");
|
|
63
|
+
});
|
|
64
|
+
it("falls back to stale cache on network error", async () => {
|
|
65
|
+
cacheStale.mockReturnValue({ data: POSTGRES_FIXTURE, fetchedAt: 1 });
|
|
66
|
+
mockFetch.mockRejectedValue(new Error("offline"));
|
|
67
|
+
const status = await queryEol("postgres", "11.18", { noCache: true });
|
|
68
|
+
expect(status?.eol).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it("treats eol: true as EOL regardless of date", async () => {
|
|
71
|
+
mockFetch.mockResolvedValue({
|
|
72
|
+
ok: true,
|
|
73
|
+
json: async () => [{ cycle: "1.0", eol: true, latest: "1.0.5" }],
|
|
74
|
+
});
|
|
75
|
+
const status = await queryEol("oldthing", "1.0", { noCache: true });
|
|
76
|
+
expect(status?.eol).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ParsedImage {
|
|
2
|
+
registry: string;
|
|
3
|
+
repository: string;
|
|
4
|
+
name: string;
|
|
5
|
+
tag: string;
|
|
6
|
+
digest?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseImage(image: string): ParsedImage;
|
|
9
|
+
export declare function normalizeVersion(tag: string): string;
|
|
10
|
+
export declare function majorMinor(version: string): string;
|
|
11
|
+
export declare function major(version: string): string;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const DOCKER_HUB = "docker.io";
|
|
2
|
+
export function parseImage(image) {
|
|
3
|
+
const trimmed = image.trim();
|
|
4
|
+
let refPart = trimmed;
|
|
5
|
+
let digest;
|
|
6
|
+
const atIdx = trimmed.indexOf("@");
|
|
7
|
+
if (atIdx >= 0) {
|
|
8
|
+
digest = trimmed.slice(atIdx + 1);
|
|
9
|
+
refPart = trimmed.slice(0, atIdx);
|
|
10
|
+
}
|
|
11
|
+
const firstSlash = refPart.indexOf("/");
|
|
12
|
+
let registry = DOCKER_HUB;
|
|
13
|
+
let path = refPart;
|
|
14
|
+
if (firstSlash > 0) {
|
|
15
|
+
const maybeHost = refPart.slice(0, firstSlash);
|
|
16
|
+
if (maybeHost === "localhost" ||
|
|
17
|
+
maybeHost.includes(".") ||
|
|
18
|
+
maybeHost.includes(":")) {
|
|
19
|
+
registry = maybeHost;
|
|
20
|
+
path = refPart.slice(firstSlash + 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const lastSlash = path.lastIndexOf("/");
|
|
24
|
+
const tagIdx = path.indexOf(":", lastSlash + 1);
|
|
25
|
+
let tag;
|
|
26
|
+
let repository;
|
|
27
|
+
if (tagIdx >= 0) {
|
|
28
|
+
tag = path.slice(tagIdx + 1);
|
|
29
|
+
repository = path.slice(0, tagIdx);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
repository = path;
|
|
33
|
+
tag = digest ? "" : "latest";
|
|
34
|
+
}
|
|
35
|
+
if (registry === DOCKER_HUB && !repository.includes("/")) {
|
|
36
|
+
repository = `library/${repository}`;
|
|
37
|
+
}
|
|
38
|
+
const name = repository.split("/").pop() ?? repository;
|
|
39
|
+
return { registry, repository, name, tag, digest };
|
|
40
|
+
}
|
|
41
|
+
const SEMVER_PREFIX_RE = /^v(?=\d)/;
|
|
42
|
+
export function normalizeVersion(tag) {
|
|
43
|
+
if (!tag || tag === "latest" || tag === "stable")
|
|
44
|
+
return "";
|
|
45
|
+
const dashIdx = tag.indexOf("-");
|
|
46
|
+
const core = dashIdx >= 0 ? tag.slice(0, dashIdx) : tag;
|
|
47
|
+
return core.replace(SEMVER_PREFIX_RE, "");
|
|
48
|
+
}
|
|
49
|
+
export function majorMinor(version) {
|
|
50
|
+
const parts = version.split(".");
|
|
51
|
+
if (parts.length === 0)
|
|
52
|
+
return version;
|
|
53
|
+
if (parts.length === 1)
|
|
54
|
+
return parts[0];
|
|
55
|
+
return `${parts[0]}.${parts[1]}`;
|
|
56
|
+
}
|
|
57
|
+
export function major(version) {
|
|
58
|
+
return version.split(".")[0] ?? version;
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|