From beabd9ecc9b60af08a038db59d6856f488eddb9f Mon Sep 17 00:00:00 2001 From: Masao Kitamura Date: Thu, 1 Oct 2026 17:06:54 -0700 Subject: [PATCH] Add packages/analyzers with the runner and the first rule An Analyzer is metadata for the rule catalog plus a pure function of the scan input. runAnalyzers validates every finding, isolates a rule that fails and returns the findings ranked by saving. The first rule, aws.ebs.unattached, prices a volume from what the export actually charged for it. core gains the resource inventory types the rule reads. Claude-Session: https://claude.ai/code/session_01WTtRZcdke1yYdYUAanaf7V --- CONTRIBUTING.md | 8 ++ README.md | 7 +- .../analyzers/node_modules/@costexplorer/core | 1 + packages/analyzers/package.json | 15 +++ packages/analyzers/src/analyzer.ts | 28 ++++++ .../analyzers/src/aws/ebs-unattached.test.ts | 83 ++++++++++++++++ packages/analyzers/src/aws/ebs-unattached.ts | 57 +++++++++++ packages/analyzers/src/fixtures.ts | 49 ++++++++++ packages/analyzers/src/index.test.ts | 32 +++++++ packages/analyzers/src/index.ts | 9 ++ packages/analyzers/src/run-rate.test.ts | 41 ++++++++ packages/analyzers/src/run-rate.ts | 30 ++++++ packages/analyzers/src/run.test.ts | 95 +++++++++++++++++++ packages/analyzers/src/run.ts | 45 +++++++++ packages/analyzers/tsconfig.json | 4 + packages/core/src/index.ts | 1 + packages/core/src/resource.test.ts | 28 ++++++ packages/core/src/resource.ts | 29 ++++++ pnpm-lock.yaml | 6 ++ 19 files changed, 565 insertions(+), 3 deletions(-) create mode 120000 packages/analyzers/node_modules/@costexplorer/core create mode 100644 packages/analyzers/package.json create mode 100644 packages/analyzers/src/analyzer.ts create mode 100644 packages/analyzers/src/aws/ebs-unattached.test.ts create mode 100644 packages/analyzers/src/aws/ebs-unattached.ts create mode 100644 packages/analyzers/src/fixtures.ts create mode 100644 packages/analyzers/src/index.test.ts create mode 100644 packages/analyzers/src/index.ts create mode 100644 packages/analyzers/src/run-rate.test.ts create mode 100644 packages/analyzers/src/run-rate.ts create mode 100644 packages/analyzers/src/run.test.ts create mode 100644 packages/analyzers/src/run.ts create mode 100644 packages/analyzers/tsconfig.json create mode 100644 packages/core/src/resource.test.ts create mode 100644 packages/core/src/resource.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55611d1..46809a7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,14 @@ The install sets up two git hooks. The pre-commit hook formats the files you sta 4. Run `pnpm verify` before you push. It runs what CI runs. 5. Open one pull request per issue. Say why the change exists, what is in scope and how you verified it, and paste the `pnpm verify` output. +## Add an analyzer + +1. Write the rule as `packages/analyzers/src//.ts`. It exports one `Analyzer`, and `analyze` is a pure function of the scan input. `aws/ebs-unattached.ts` is the model. +2. Put its test beside it. Build the input with the helpers in `packages/analyzers/src/fixtures.ts`, and assert the whole finding at least once, including the saving and the commands. +3. Report the lower figure when unsure, round the saving down with `floorToCents`, and put the numbers the estimate rests on in `evidence`. +4. If the rule reads a resource type that does not exist yet, add it to `packages/core/src/resource.ts`. +5. Add the rule to the `analyzers` list in `packages/analyzers/src/index.ts`. + ## Formatting and lint `pnpm format` rewrites files and `pnpm lint:fix` applies lint fixes. Hooks and CI only run the read-only `pnpm format:check` and `pnpm lint`, so a violation is reported and never silently repaired. diff --git a/README.md b/README.md index a2efe25..6ea2e5f 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ The open-source engine behind Cost Explorer. It reads a cloud cost export and re The repository is a pnpm workspace. The engine is built as packages under `packages/`, and a Next.js 16 app at the root will host the docs and the rule catalog. The app still serves the create-next-app starting page. -| Package | Holds | -| --------------- | --------------------------------------------------------------------------------------------------- | -| `packages/core` | The cost record schema, aligned to the FinOps FOCUS columns, money math and the `Finding` contract. | +| Package | Holds | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `packages/core` | The cost record schema, aligned to the FinOps FOCUS columns, the resource inventory types, money math and the `Finding` contract. | +| `packages/analyzers` | The waste rules, one file per rule under `src//`, and `runAnalyzers`, which runs them over one scan. | The engine packages are framework-free TypeScript. They import neither React nor Next.js, so a command-line tool and a worker can run them. diff --git a/packages/analyzers/node_modules/@costexplorer/core b/packages/analyzers/node_modules/@costexplorer/core new file mode 120000 index 0000000..5e990a8 --- /dev/null +++ b/packages/analyzers/node_modules/@costexplorer/core @@ -0,0 +1 @@ +../../../core \ No newline at end of file diff --git a/packages/analyzers/package.json b/packages/analyzers/package.json new file mode 100644 index 0000000..67ec098 --- /dev/null +++ b/packages/analyzers/package.json @@ -0,0 +1,15 @@ +{ + "name": "@costexplorer/analyzers", + "version": "0.0.0", + "private": true, + "description": "The waste rules. One file per rule, each with fixtures and tests.", + "license": "Apache-2.0", + "type": "module", + "exports": "./src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@costexplorer/core": "workspace:*" + } +} diff --git a/packages/analyzers/src/analyzer.ts b/packages/analyzers/src/analyzer.ts new file mode 100644 index 0000000..2ee5169 --- /dev/null +++ b/packages/analyzers/src/analyzer.ts @@ -0,0 +1,28 @@ +import type { CostRecord, Finding, Resource } from "@costexplorer/core"; + +/** Everything a scan knows. Adapters build it, and every analyzer reads the same one. */ +export type ScanInput = { + /** The moment the inventory was taken, ISO 8601. Rules measure age against this, not the clock. */ + asOf: string; + costRecords: readonly CostRecord[]; + resources: readonly Resource[]; +}; + +export type AnalyzerCategory = "idle" | "stale" | "rightsizing" | "pricing" | "network" | "anomaly"; + +/** + * One waste rule. The metadata feeds the rule catalog, and `analyze` is a pure function of the + * scan input, so a rule can be tested with a fixture and run anywhere. + */ +export type Analyzer = { + /** Provider, service and rule, such as `aws.ebs.unattached`. Every finding carries it. */ + id: string; + title: string; + category: AnalyzerCategory; + tier: "free" | "pro"; + /** What the rule looks for, in one sentence. */ + signal: string; + /** What the finding tells the reader to do, in one sentence. */ + fix: string; + analyze(input: ScanInput): Finding[]; +}; diff --git a/packages/analyzers/src/aws/ebs-unattached.test.ts b/packages/analyzers/src/aws/ebs-unattached.test.ts new file mode 100644 index 0000000..62997f7 --- /dev/null +++ b/packages/analyzers/src/aws/ebs-unattached.test.ts @@ -0,0 +1,83 @@ +import { findingSchema } from "@costexplorer/core"; +import { describe, expect, it } from "vitest"; +import { daysBefore, ebsVolume, scan, usage } from "../fixtures.ts"; +import { ebsUnattached } from "./ebs-unattached.ts"; + +const find = (...args: Parameters) => ebsUnattached.analyze(scan(...args)); + +describe("aws.ebs.unattached", () => { + it("reports a volume detached 21 days ago with what it costs a month", () => { + const volume = ebsVolume("vol-idle", { unattachedSince: daysBefore(21) }); + const findings = find([volume], [usage("vol-idle", 0.24)]); + + expect(findings).toEqual([ + { + analyzerId: "aws.ebs.unattached", + resourceId: "vol-idle", + account: "111111111111", + region: "us-east-1", + monthlySavingUsd: 7.3, + confidence: "high", + evidence: { + sizeGb: 100, + volumeType: "gp3", + daysUnattached: 21, + unattachedSinceKnown: true, + chargedUsd: 0.24, + chargedHours: 24, + note: "A snapshot kept after the delete is billed separately and is not subtracted here.", + }, + remediation: { + cli: [ + 'SNAPSHOT=$(aws ec2 create-snapshot --region us-east-1 --volume-id vol-idle --description "Final snapshot of vol-idle" --query SnapshotId --output text)', + 'aws ec2 wait snapshot-completed --region us-east-1 --snapshot-ids "$SNAPSHOT"', + "aws ec2 delete-volume --region us-east-1 --volume-id vol-idle", + ].join("\n"), + }, + }, + ]); + expect(findingSchema.safeParse(findings[0]).success).toBe(true); + }); + + it("rounds the saving down to the cent", () => { + // 0.25 a day is 7.6041... a month. + const findings = find([ebsVolume("vol-idle")], [usage("vol-idle", 0.25)]); + expect(findings[0]?.monthlySavingUsd).toBe(7.6); + }); + + it("is only medium confidence when the detach time is unknown", () => { + const findings = find([ebsVolume("vol-old")], [usage("vol-old", 0.24)]); + expect(findings[0]).toMatchObject({ + confidence: "medium", + evidence: { daysUnattached: 200, unattachedSinceKnown: false }, + }); + }); + + it("reports no saving and low confidence when the export has no charge for the volume", () => { + const findings = find([ebsVolume("vol-unbilled")], [usage("vol-other", 5)]); + expect(findings[0]).toMatchObject({ + monthlySavingUsd: 0, + confidence: "low", + evidence: { chargedUsd: null, chargedHours: null }, + }); + }); + + it("starts at 14 full days", () => { + const at = (days: number) => ebsVolume(`vol-${days}`, { unattachedSince: daysBefore(days) }); + const findings = find([at(13.9), at(14), at(15)]); + expect(findings.map((finding) => finding.resourceId)).toEqual(["vol-14", "vol-15"]); + }); + + it("leaves attached and transitional volumes alone", () => { + const volumes = ["in-use", "creating", "deleting", "deleted", "error"].map((state) => + ebsVolume(`vol-${state}`, { state }), + ); + expect(find(volumes)).toEqual([]); + }); + + it("uses the volume's own region in the commands", () => { + const findings = find([ebsVolume("vol-eu", { region: "eu-west-1" })]); + expect(findings[0]?.region).toBe("eu-west-1"); + expect(findings[0]?.remediation.cli).toContain("delete-volume --region eu-west-1"); + }); +}); diff --git a/packages/analyzers/src/aws/ebs-unattached.ts b/packages/analyzers/src/aws/ebs-unattached.ts new file mode 100644 index 0000000..5a9e0f8 --- /dev/null +++ b/packages/analyzers/src/aws/ebs-unattached.ts @@ -0,0 +1,57 @@ +import { floorToCents, type Finding } from "@costexplorer/core"; +import type { Analyzer } from "../analyzer.ts"; +import { runRate } from "../run-rate.ts"; + +const ID = "aws.ebs.unattached"; +const MS_PER_DAY = 86_400_000; +const MIN_DAYS_UNATTACHED = 14; + +export const ebsUnattached: Analyzer = { + id: ID, + title: "Unattached EBS volumes", + category: "idle", + tier: "free", + signal: `An EBS volume has been attached to no instance for ${MIN_DAYS_UNATTACHED} days or more.`, + fix: "Snapshot the volume if the data matters, then delete it.", + + analyze({ asOf, costRecords, resources }) { + const findings: Finding[] = []; + for (const volume of resources) { + if (volume.type !== "aws.ebs.volume" || volume.state !== "available") continue; + + // The inventory API does not say when a volume was detached. Without that, the creation + // time is the only bound, and the volume may have been in use until yesterday. + const since = volume.unattachedSince ?? volume.createdAt; + const daysUnattached = Math.floor((Date.parse(asOf) - Date.parse(since)) / MS_PER_DAY); + if (daysUnattached < MIN_DAYS_UNATTACHED) continue; + + const rate = runRate(costRecords, volume.id); + const at = `--region ${volume.region}`; + findings.push({ + analyzerId: ID, + resourceId: volume.id, + account: volume.account, + region: volume.region, + monthlySavingUsd: rate ? floorToCents(rate.monthlyUsd) : 0, + confidence: !rate ? "low" : volume.unattachedSince ? "high" : "medium", + evidence: { + sizeGb: volume.sizeGb, + volumeType: volume.volumeType, + daysUnattached, + unattachedSinceKnown: volume.unattachedSince !== undefined, + chargedUsd: rate?.chargedUsd ?? null, + chargedHours: rate?.hours ?? null, + note: "A snapshot kept after the delete is billed separately and is not subtracted here.", + }, + remediation: { + cli: [ + `SNAPSHOT=$(aws ec2 create-snapshot ${at} --volume-id ${volume.id} --description "Final snapshot of ${volume.id}" --query SnapshotId --output text)`, + `aws ec2 wait snapshot-completed ${at} --snapshot-ids "$SNAPSHOT"`, + `aws ec2 delete-volume ${at} --volume-id ${volume.id}`, + ].join("\n"), + }, + }); + } + return findings; + }, +}; diff --git a/packages/analyzers/src/fixtures.ts b/packages/analyzers/src/fixtures.ts new file mode 100644 index 0000000..deab82c --- /dev/null +++ b/packages/analyzers/src/fixtures.ts @@ -0,0 +1,49 @@ +import { + costRecordSchema, + resourceSchema, + type CostRecord, + type Resource, +} from "@costexplorer/core"; +import type { ScanInput } from "./analyzer.ts"; + +/** The scan moment every fixture is measured against. */ +export const AS_OF = "2026-09-30T00:00:00Z"; + +export function daysBefore(days: number, asOf = AS_OF): string { + return new Date(Date.parse(asOf) - days * 86_400_000).toISOString(); +} + +/** One day of usage for a resource, ending at the scan moment unless a period is given. */ +export function usage(resourceId: string, effectiveCost: number, overrides = {}): CostRecord { + return costRecordSchema.parse({ + providerName: "AWS", + billingAccountId: "111111111111", + chargePeriodStart: daysBefore(1), + chargePeriodEnd: AS_OF, + chargeCategory: "Usage", + billingCurrency: "USD", + billedCost: effectiveCost, + effectiveCost, + serviceName: "Amazon Elastic Block Store", + resourceId, + ...overrides, + }); +} + +export function ebsVolume(id: string, overrides = {}): Resource { + return resourceSchema.parse({ + type: "aws.ebs.volume", + id, + account: "111111111111", + region: "us-east-1", + sizeGb: 100, + volumeType: "gp3", + state: "available", + createdAt: daysBefore(200), + ...overrides, + }); +} + +export function scan(resources: Resource[], costRecords: CostRecord[] = []): ScanInput { + return { asOf: AS_OF, resources, costRecords }; +} diff --git a/packages/analyzers/src/index.test.ts b/packages/analyzers/src/index.test.ts new file mode 100644 index 0000000..419b404 --- /dev/null +++ b/packages/analyzers/src/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { ebsVolume, scan, usage } from "./fixtures.ts"; +import { analyzers, runAnalyzers } from "./index.ts"; + +describe("the public rule set", () => { + it("gives every rule a provider.service.rule id and catalog text", () => { + for (const analyzer of analyzers) { + expect(analyzer.id).toMatch(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*){2}$/); + expect(analyzer.title).not.toBe(""); + expect(analyzer.signal).toMatch(/\.$/); + expect(analyzer.fix).toMatch(/\.$/); + } + }); + + it("runs clean on an empty scan", () => { + expect(runAnalyzers(analyzers, scan([]))).toEqual({ findings: [], errors: [] }); + }); + + it("finds the planted waste end to end", () => { + const result = runAnalyzers( + analyzers, + scan( + [ebsVolume("vol-idle"), ebsVolume("vol-busy", { state: "in-use" })], + [usage("vol-idle", 0.24), usage("vol-busy", 0.24)], + ), + ); + expect(result.errors).toEqual([]); + expect(result.findings.map((f) => [f.analyzerId, f.resourceId, f.monthlySavingUsd])).toEqual([ + ["aws.ebs.unattached", "vol-idle", 7.3], + ]); + }); +}); diff --git a/packages/analyzers/src/index.ts b/packages/analyzers/src/index.ts new file mode 100644 index 0000000..1edead4 --- /dev/null +++ b/packages/analyzers/src/index.ts @@ -0,0 +1,9 @@ +import type { Analyzer } from "./analyzer.ts"; +import { ebsUnattached } from "./aws/ebs-unattached.ts"; + +export type { Analyzer, AnalyzerCategory, ScanInput } from "./analyzer.ts"; +export { runAnalyzers, type AnalyzerError, type ScanResult } from "./run.ts"; +export { runRate, type RunRate } from "./run-rate.ts"; + +/** Every public rule, in catalog order. */ +export const analyzers: readonly Analyzer[] = [ebsUnattached]; diff --git a/packages/analyzers/src/run-rate.test.ts b/packages/analyzers/src/run-rate.test.ts new file mode 100644 index 0000000..503a41c --- /dev/null +++ b/packages/analyzers/src/run-rate.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { daysBefore, usage } from "./fixtures.ts"; +import { runRate } from "./run-rate.ts"; + +describe("runRate", () => { + it("scales what a resource was charged to a 730-hour month", () => { + expect(runRate([usage("vol-1", 0.24)], "vol-1")).toEqual({ + monthlyUsd: 7.3, + chargedUsd: 0.24, + hours: 24, + }); + }); + + it("counts an hour once when several line items cover it", () => { + const records = [usage("vol-1", 0.24), usage("vol-1", 0.12, { skuId: "iops" })]; + expect(runRate(records, "vol-1")).toMatchObject({ chargedUsd: 0.36, hours: 24 }); + }); + + it("spans from the first charge to the last", () => { + const records = [ + usage("vol-1", 0.24, { chargePeriodStart: daysBefore(3), chargePeriodEnd: daysBefore(2) }), + usage("vol-1", 0.24), + ]; + expect(runRate(records, "vol-1")).toMatchObject({ chargedUsd: 0.48, hours: 72 }); + }); + + it("ignores other resources and charges that are not usage", () => { + const records = [ + usage("vol-1", 0.24), + usage("vol-2", 99), + usage("vol-1", -0.2, { chargeCategory: "Credit" }), + usage("vol-1", 0.02, { chargeCategory: "Tax" }), + ]; + expect(runRate(records, "vol-1")?.chargedUsd).toBe(0.24); + }); + + it("returns null when the export has no usage charge for the resource", () => { + expect(runRate([usage("vol-2", 1)], "vol-1")).toBeNull(); + expect(runRate([usage("vol-1", -1, { chargeCategory: "Credit" })], "vol-1")).toBeNull(); + }); +}); diff --git a/packages/analyzers/src/run-rate.ts b/packages/analyzers/src/run-rate.ts new file mode 100644 index 0000000..81d4e6c --- /dev/null +++ b/packages/analyzers/src/run-rate.ts @@ -0,0 +1,30 @@ +import { HOURS_PER_MONTH, sumUsd, type CostRecord } from "@costexplorer/core"; + +const MS_PER_HOUR = 3_600_000; + +export type RunRate = { + /** What the resource costs per 730-hour month at the rate it was charged in the export. */ + monthlyUsd: number; + /** The usage charges the rate was computed from. */ + chargedUsd: number; + /** The hours between the first and the last charge. */ + hours: number; +}; + +/** + * The monthly cost of one resource, from what it was actually charged. Uses the amortized cost + * of usage lines only, so a tax, a credit or a one-off purchase does not move the rate. + * Returns `null` when the export has no usage charge for the resource. + */ +export function runRate(records: readonly CostRecord[], resourceId: string): RunRate | null { + const usage = records.filter( + (record) => record.resourceId === resourceId && record.chargeCategory === "Usage", + ); + if (usage.length === 0) return null; + + const start = Math.min(...usage.map((record) => Date.parse(record.chargePeriodStart))); + const end = Math.max(...usage.map((record) => Date.parse(record.chargePeriodEnd))); + const hours = (end - start) / MS_PER_HOUR; + const chargedUsd = sumUsd(usage.map((record) => record.effectiveCost)); + return { monthlyUsd: (chargedUsd / hours) * HOURS_PER_MONTH, chargedUsd, hours }; +} diff --git a/packages/analyzers/src/run.test.ts b/packages/analyzers/src/run.test.ts new file mode 100644 index 0000000..c46ab3b --- /dev/null +++ b/packages/analyzers/src/run.test.ts @@ -0,0 +1,95 @@ +import type { Finding } from "@costexplorer/core"; +import { describe, expect, it } from "vitest"; +import type { Analyzer } from "./analyzer.ts"; +import { scan } from "./fixtures.ts"; +import { runAnalyzers } from "./run.ts"; + +const finding = (analyzerId: string, monthlySavingUsd: number): Finding => ({ + analyzerId, + resourceId: "res-1", + account: "111111111111", + region: "us-east-1", + monthlySavingUsd, + confidence: "high", + evidence: {}, + remediation: {}, +}); + +const rule = (id: string, analyze: Analyzer["analyze"]): Analyzer => ({ + id, + title: id, + category: "idle", + tier: "free", + signal: "A test rule.", + fix: "Nothing.", + analyze, +}); + +describe("runAnalyzers", () => { + it("merges the findings of every rule, largest saving first", () => { + const result = runAnalyzers( + [ + rule("aws.test.small", () => [finding("aws.test.small", 3)]), + rule("aws.test.large", () => [finding("aws.test.large", 400)]), + ], + scan([]), + ); + expect(result.errors).toEqual([]); + expect(result.findings.map((f) => f.analyzerId)).toEqual(["aws.test.large", "aws.test.small"]); + }); + + it("passes the same input to every rule", () => { + const input = scan([]); + const seen: unknown[] = []; + const spy = (id: string) => rule(id, (given) => (seen.push(given), [])); + runAnalyzers([spy("aws.test.a"), spy("aws.test.b")], input); + expect(seen).toEqual([input, input]); + }); + + it("reports a rule that throws and keeps the findings of the others", () => { + const result = runAnalyzers( + [ + rule("aws.test.broken", () => { + throw new Error("no inventory"); + }), + rule("aws.test.throws-text", () => { + throw "plain text"; + }), + rule("aws.test.fine", () => [finding("aws.test.fine", 5)]), + ], + scan([]), + ); + expect(result.errors).toEqual([ + { analyzerId: "aws.test.broken", message: "no inventory" }, + { analyzerId: "aws.test.throws-text", message: "plain text" }, + ]); + expect(result.findings.map((f) => f.analyzerId)).toEqual(["aws.test.fine"]); + }); + + it("drops every finding of a rule that reports a malformed one", () => { + const result = runAnalyzers( + [rule("aws.test.bad", () => [finding("aws.test.bad", 5), finding("aws.test.bad", -1)])], + scan([]), + ); + expect(result.findings).toEqual([]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.message).toContain("monthlySavingUsd"); + }); + + it("rejects a finding reported under another rule's id", () => { + const result = runAnalyzers( + [rule("aws.test.mine", () => [finding("aws.test.theirs", 5)])], + scan([]), + ); + expect(result.errors).toEqual([ + { analyzerId: "aws.test.mine", message: "reported a finding as `aws.test.theirs`" }, + ]); + }); + + it("refuses two rules with the same id", () => { + const twice = [rule("aws.test.dup", () => []), rule("aws.test.dup", () => [])]; + expect(() => runAnalyzers(twice, scan([]))).toThrow( + "Two analyzers share the id `aws.test.dup`", + ); + }); +}); diff --git a/packages/analyzers/src/run.ts b/packages/analyzers/src/run.ts new file mode 100644 index 0000000..ff7d6d2 --- /dev/null +++ b/packages/analyzers/src/run.ts @@ -0,0 +1,45 @@ +import { findingSchema, rankFindings, type Finding } from "@costexplorer/core"; +import type { Analyzer, ScanInput } from "./analyzer.ts"; + +export type AnalyzerError = { analyzerId: string; message: string }; + +export type ScanResult = { + /** Every valid finding, largest saving first. */ + findings: Finding[]; + /** One entry per rule that threw or reported a malformed finding. Its findings are dropped. */ + errors: AnalyzerError[]; +}; + +function findingsOf(analyzer: Analyzer, input: ScanInput): Finding[] { + return analyzer.analyze(input).map((reported) => { + const finding = findingSchema.parse(reported); + if (finding.analyzerId !== analyzer.id) { + throw new Error(`reported a finding as \`${finding.analyzerId}\``); + } + return finding; + }); +} + +/** + * Runs every rule over the same input. A rule that fails is reported and skipped, so one bad + * rule does not cost the reader the findings of the others. + */ +export function runAnalyzers(analyzers: readonly Analyzer[], input: ScanInput): ScanResult { + const ids = analyzers.map((analyzer) => analyzer.id); + const duplicate = ids.find((id, index) => ids.indexOf(id) !== index); + if (duplicate) throw new Error(`Two analyzers share the id \`${duplicate}\``); + + const findings: Finding[] = []; + const errors: AnalyzerError[] = []; + for (const analyzer of analyzers) { + try { + findings.push(...findingsOf(analyzer, input)); + } catch (error) { + errors.push({ + analyzerId: analyzer.id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + return { findings: rankFindings(findings), errors }; +} diff --git a/packages/analyzers/tsconfig.json b/packages/analyzers/tsconfig.json new file mode 100644 index 0000000..1d4d90f --- /dev/null +++ b/packages/analyzers/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.packages.json", + "include": ["src"] +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d52b9ff..b7c8abf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,4 +14,5 @@ export { type Confidence, type Finding, } from "./finding.ts"; +export { ebsVolumeSchema, resourceSchema, type EbsVolume, type Resource } from "./resource.ts"; export { floorToCents, HOURS_PER_MONTH, monthlyFromHourly, sumUsd } from "./money.ts"; diff --git a/packages/core/src/resource.test.ts b/packages/core/src/resource.test.ts new file mode 100644 index 0000000..7f50567 --- /dev/null +++ b/packages/core/src/resource.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { resourceSchema } from "./resource.ts"; + +const volume = { + type: "aws.ebs.volume", + id: "vol-0abc", + account: "111111111111", + region: "us-east-1", + sizeGb: 100, + volumeType: "gp3", + state: "available", + createdAt: "2026-06-01T00:00:00Z", +}; + +describe("resourceSchema", () => { + it("accepts an EBS volume and defaults tags to none", () => { + expect(resourceSchema.parse(volume)).toEqual({ ...volume, tags: {} }); + }); + + it("rejects a resource type no analyzer reads", () => { + expect(resourceSchema.safeParse({ ...volume, type: "aws.ec2.instance" }).success).toBe(false); + }); + + it("rejects a volume with no size or an unknown state", () => { + expect(resourceSchema.safeParse({ ...volume, sizeGb: 0 }).success).toBe(false); + expect(resourceSchema.safeParse({ ...volume, state: "detached" }).success).toBe(false); + }); +}); diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts new file mode 100644 index 0000000..be704a4 --- /dev/null +++ b/packages/core/src/resource.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +const name = z.string().min(1); +const instant = z.iso.datetime({ offset: true }); + +/** + * A resource as the provider's inventory API describes it, at the moment of the scan. Cost + * records say what was charged, and resources say what exists and what state it is in. An + * analyzer usually needs both. + */ +export const ebsVolumeSchema = z.object({ + type: z.literal("aws.ebs.volume"), + /** The id as it appears in `CostRecord.resourceId`, such as `vol-0abc`. */ + id: name, + account: name, + region: name, + sizeGb: z.number().positive(), + volumeType: name, + state: z.enum(["creating", "available", "in-use", "deleting", "deleted", "error"]), + createdAt: instant, + /** When the last attachment ended. The inventory API does not report it, so it is often absent. */ + unattachedSince: instant.optional(), + tags: z.record(z.string(), z.string()).default({}), +}); +export type EbsVolume = z.infer; + +/** Every resource type an analyzer reads. Add a member here with the first rule that needs it. */ +export const resourceSchema = z.discriminatedUnion("type", [ebsVolumeSchema]); +export type Resource = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b019b3..d32e012 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -222,6 +222,12 @@ importers: specifier: ^5.0.3 version: 5.0.3(@types/node@24.19.0)(@vitest/coverage-v8@5.0.3)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)) + packages/analyzers: + dependencies: + '@costexplorer/core': + specifier: workspace:* + version: link:../core + packages/core: dependencies: zod: