@pagopa/dx-savemoney 0.6.4 → 0.6.5
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 +38 -0
- package/dist/azure/analyzer.d.ts.map +1 -1
- package/dist/azure/analyzer.js +42 -16
- package/dist/azure/analyzer.js.map +1 -1
- package/dist/azure/analyzers/advisor.js +11 -0
- package/dist/azure/analyzers/advisor.js.map +1 -1
- package/dist/azure/azqr.d.ts +3 -3
- package/dist/azure/azqr.d.ts.map +1 -1
- package/dist/azure/azqr.js +26 -4
- package/dist/azure/azqr.js.map +1 -1
- package/dist/azure/finding-category.d.ts +45 -0
- package/dist/azure/finding-category.d.ts.map +1 -0
- package/dist/azure/finding-category.js +65 -0
- package/dist/azure/finding-category.js.map +1 -0
- package/dist/azure/finding-code.d.ts +46 -0
- package/dist/azure/finding-code.d.ts.map +1 -0
- package/dist/azure/finding-code.js +127 -0
- package/dist/azure/finding-code.js.map +1 -0
- package/dist/azure/finding-dedup.d.ts +44 -0
- package/dist/azure/finding-dedup.d.ts.map +1 -0
- package/dist/azure/finding-dedup.js +132 -0
- package/dist/azure/finding-dedup.js.map +1 -0
- package/dist/finding.d.ts +19 -7
- package/dist/finding.d.ts.map +1 -1
- package/dist/finding.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/src/azure/__tests__/azqr.test.ts +45 -1
- package/src/azure/__tests__/finding-category.test.ts +68 -0
- package/src/azure/__tests__/finding-code.test.ts +128 -0
- package/src/azure/__tests__/finding-dedup.test.ts +269 -0
- package/src/azure/analyzer.ts +54 -18
- package/src/azure/analyzers/__tests__/advisor.test.ts +60 -0
- package/src/azure/analyzers/advisor.ts +14 -0
- package/src/azure/azqr.ts +28 -4
- package/src/azure/finding-category.ts +75 -0
- package/src/azure/finding-code.ts +148 -0
- package/src/azure/finding-dedup.ts +157 -0
- package/src/finding.ts +19 -7
- package/src/types.ts +16 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable per-sentence identity for custom (live-scan) analyzer findings.
|
|
3
|
+
*
|
|
4
|
+
* `Finding.recommendationId` powers cross-source dedup, but custom analyzer
|
|
5
|
+
* sentences were deliberately excluded from it: every sentence produced by
|
|
6
|
+
* `findingsFromAnalysisResult` shared the generic `code: "custom.unknown"`
|
|
7
|
+
* and had no stable identifier, so two different sentences could never be
|
|
8
|
+
* told apart safely.
|
|
9
|
+
*
|
|
10
|
+
* This module closes that gap for the sentences whose wording is known ahead
|
|
11
|
+
* of time: each per-resource analyzer in `azure/resources/` builds its
|
|
12
|
+
* `reason` string from a fixed set of templates (e.g. "Disk is unattached.",
|
|
13
|
+
* "VM is deallocated."). This module recognises those templates by a
|
|
14
|
+
* case-insensitive prefix match on the (already sentence-split) `reason` and
|
|
15
|
+
* assigns both `code` and `recommendationId` to a stable `custom.<id>` value,
|
|
16
|
+
* following the same convention as `advisor.<recommendationTypeId>` /
|
|
17
|
+
* `azqr.<recommendationId>`. That is what lets `foldDuplicateFinding`
|
|
18
|
+
* (`finding-dedup.ts`) — whose matching is already identity-based and
|
|
19
|
+
* source-agnostic — collapse two custom findings that describe the same
|
|
20
|
+
* problem on the same resource.
|
|
21
|
+
*
|
|
22
|
+
* A handful of sentences are worded identically by more than one resource
|
|
23
|
+
* type (e.g. App Service Plan and Container App both emit "Very low CPU usage
|
|
24
|
+
* (…)."). Those intentionally share one generic code: dedup only ever
|
|
25
|
+
* compares findings within the same `resourceId`, and a resource can't be two
|
|
26
|
+
* types at once, so the shared code cannot cause a false merge across
|
|
27
|
+
* unrelated resources.
|
|
28
|
+
*
|
|
29
|
+
* Sentences that don't match any known template are left untouched — they
|
|
30
|
+
* keep `code: "custom.unknown"` and no `recommendationId`, exactly like
|
|
31
|
+
* before, so genuinely unrecognised text is never merged on a guess.
|
|
32
|
+
*/
|
|
33
|
+
import { DETAIL_LOOKUP_FAILURE_PREFIX, MISSING_TAGS_REASON, NO_ANALYZER_REASON, UNPREFERRED_LOCATION_REASON, } from "./finding-category.js";
|
|
34
|
+
/** Shared prefix for every stable custom finding id, mirroring `advisor.<id>` / `azqr.<id>`. */
|
|
35
|
+
const CODE_PREFIX = "custom.";
|
|
36
|
+
/**
|
|
37
|
+
* Ordered `(normalized reason prefix, code suffix)` pairs. Evaluated
|
|
38
|
+
* top-to-bottom; the first match wins. Prefixes are matched against the
|
|
39
|
+
* trimmed, lower-cased `reason`, so the dynamic suffixes analyzers append
|
|
40
|
+
* (percentages, byte counts, durations, …) never break the match.
|
|
41
|
+
*/
|
|
42
|
+
const REASON_CODES = [
|
|
43
|
+
// Generic — emitted by (almost) every per-resource analyzer.
|
|
44
|
+
["resource id is missing.", "missing-resource-id"],
|
|
45
|
+
[DETAIL_LOOKUP_FAILURE_PREFIX, "lookup-failed"],
|
|
46
|
+
[MISSING_TAGS_REASON.toLowerCase(), "missing-tags"],
|
|
47
|
+
[NO_ANALYZER_REASON.toLowerCase(), "no-analyzer"],
|
|
48
|
+
[UNPREFERRED_LOCATION_REASON.toLowerCase(), "unpreferred-location"],
|
|
49
|
+
// Managed Disk (azure/resources/disk.ts)
|
|
50
|
+
["disk is unattached.", "disk.unattached"],
|
|
51
|
+
// Network Interface (azure/resources/nic.ts)
|
|
52
|
+
["nic not attached to any vm or private endpoint.", "nic.unattached"],
|
|
53
|
+
["no public ip assigned.", "nic.no-public-ip"],
|
|
54
|
+
// Private Endpoint (azure/resources/private-endpoint.ts)
|
|
55
|
+
[
|
|
56
|
+
"private endpoint has no private link service connections configured.",
|
|
57
|
+
"private-endpoint.no-connections",
|
|
58
|
+
],
|
|
59
|
+
[
|
|
60
|
+
"private endpoint has rejected or disconnected connections.",
|
|
61
|
+
"private-endpoint.rejected-connection",
|
|
62
|
+
],
|
|
63
|
+
[
|
|
64
|
+
"private endpoint has no network interfaces attached.",
|
|
65
|
+
"private-endpoint.no-nics",
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
"private endpoint is not associated with a subnet.",
|
|
69
|
+
"private-endpoint.no-subnet",
|
|
70
|
+
],
|
|
71
|
+
// Container App (azure/resources/container-app.ts)
|
|
72
|
+
["container app is not running.", "container-app.not-running"],
|
|
73
|
+
["container app has 0 replicas configured.", "container-app.zero-replicas"],
|
|
74
|
+
// App Service Plan (azure/resources/app-service.ts)
|
|
75
|
+
["app service plan has no apps deployed.", "app-service.no-apps"],
|
|
76
|
+
[
|
|
77
|
+
"premium tier with low resource utilization.",
|
|
78
|
+
"app-service.premium-underutilized",
|
|
79
|
+
],
|
|
80
|
+
// Public IP (azure/resources/public-ip.ts)
|
|
81
|
+
["public ip not associated with any resource.", "public-ip.unassociated"],
|
|
82
|
+
["static ip not in use.", "public-ip.static-unused"],
|
|
83
|
+
// Storage Account (azure/resources/storage.ts)
|
|
84
|
+
["very low transaction count (", "storage.low-transactions"],
|
|
85
|
+
// Virtual Machine (azure/resources/vm.ts) — worded distinctly ("Low ...",
|
|
86
|
+
// no "Very") from the shared "Very low ..." family below.
|
|
87
|
+
["vm is deallocated.", "vm.deallocated"],
|
|
88
|
+
["vm is stopped.", "vm.stopped"],
|
|
89
|
+
["low cpu usage (avg", "vm.low-cpu-usage"],
|
|
90
|
+
["low network traffic (", "vm.low-network-traffic"],
|
|
91
|
+
// Static Web App (azure/resources/static-web-app.ts)
|
|
92
|
+
["no traffic data available in", "static-site.no-traffic-data"],
|
|
93
|
+
["very low site traffic (", "static-site.low-traffic"],
|
|
94
|
+
["very low data transfer (", "static-site.low-data-transfer"],
|
|
95
|
+
// Shared across resource types whose analyzers phrase the same signal with
|
|
96
|
+
// identical text — see module doc. App Service Plan + Container App for
|
|
97
|
+
// CPU/memory; Container App + Public IP for network traffic.
|
|
98
|
+
["very low cpu usage (", "low-cpu-usage"],
|
|
99
|
+
["very low memory usage (", "low-memory-usage"],
|
|
100
|
+
["very low network traffic (", "low-network-traffic"],
|
|
101
|
+
];
|
|
102
|
+
/**
|
|
103
|
+
* Assigns a stable `code`/`recommendationId` to every custom finding whose
|
|
104
|
+
* `reason` matches a known sentence template, so equivalent sentences about
|
|
105
|
+
* the same resource collapse via `foldDuplicateFinding` instead of appearing
|
|
106
|
+
* as separate rows.
|
|
107
|
+
*
|
|
108
|
+
* Leaves untouched: non-`"custom"` findings, and findings that already carry
|
|
109
|
+
* a `recommendationId` (nothing to add) or whose `reason` matches no known
|
|
110
|
+
* template (stays `"custom.unknown"`, ungrouped — the same fallback behaviour
|
|
111
|
+
* as before this module existed).
|
|
112
|
+
*/
|
|
113
|
+
export function assignCustomFindingCodes(findings) {
|
|
114
|
+
return findings.map((finding) => {
|
|
115
|
+
if (finding.source !== "custom" || finding.recommendationId) {
|
|
116
|
+
return finding;
|
|
117
|
+
}
|
|
118
|
+
const code = stableCodeFor(finding.reason);
|
|
119
|
+
return code ? { ...finding, code, recommendationId: code } : finding;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
function stableCodeFor(reason) {
|
|
123
|
+
const normalized = reason.trim().toLowerCase();
|
|
124
|
+
const match = REASON_CODES.find(([prefix]) => normalized.startsWith(prefix));
|
|
125
|
+
return match ? `${CODE_PREFIX}${match[1]}` : undefined;
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=finding-code.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"finding-code.js","sourceRoot":"","sources":["../../src/azure/finding-code.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-source deduplication of findings.
|
|
3
|
+
*
|
|
4
|
+
* SaveMoney observes the same subscription through three lenses: the custom
|
|
5
|
+
* live-scan analyzers, Azure Advisor, and an optional AZQR report. They
|
|
6
|
+
* overlap — an unattached disk is waste no matter who reports it — so the
|
|
7
|
+
* same resource could otherwise appear several times in the output, once per
|
|
8
|
+
* source. This also folds duplicate sentences emitted by the custom analyzers
|
|
9
|
+
* themselves (e.g. the same resource triggering the same "Very low CPU usage"
|
|
10
|
+
* template twice), since the matching below is source-agnostic.
|
|
11
|
+
*
|
|
12
|
+
* Findings are unified on the `resourceId + recommendationId` key. Sources
|
|
13
|
+
* normalise `recommendationId` onto a shared vocabulary whenever one exists:
|
|
14
|
+
* orphaned resources use {@link orphanRecommendationId} regardless of who
|
|
15
|
+
* detected them, which is what lets an AZQR `AOR` row collapse onto the
|
|
16
|
+
* Advisor or custom finding for the same resource. Custom analyzer sentences
|
|
17
|
+
* with a known template get a stable `custom.<id>` identity from
|
|
18
|
+
* `finding-code.ts`; sentences with no known template keep
|
|
19
|
+
* `code: "custom.unknown"` and no `recommendationId`, so they are never
|
|
20
|
+
* collapsed on guesswork. The surviving finding is the one carrying the
|
|
21
|
+
* monetary estimate (only Advisor and the pricing-enriched custom analyzers
|
|
22
|
+
* have one — AZQR reports no amounts at all), annotated with the
|
|
23
|
+
* corroborating source so the extra provenance is not lost.
|
|
24
|
+
*/
|
|
25
|
+
import type { Finding } from "../finding.js";
|
|
26
|
+
import type { AzureDetailedResourceReport } from "./types.js";
|
|
27
|
+
/**
|
|
28
|
+
* Folds `incoming` into an existing finding of `report` when both describe the
|
|
29
|
+
* same problem, so the resource is reported once instead of once per source.
|
|
30
|
+
*
|
|
31
|
+
* The surviving finding keeps the highest severity and the only available
|
|
32
|
+
* monetary estimate, and records the corroborating source both in its own
|
|
33
|
+
* reason and in the legacy per-resource `analysis` summary, so the `lint`,
|
|
34
|
+
* `json` and `table` formats all keep the provenance.
|
|
35
|
+
*
|
|
36
|
+
* @returns `true` when the finding was folded and must not be appended.
|
|
37
|
+
*/
|
|
38
|
+
export declare function foldDuplicateFinding(incoming: Finding, report: AzureDetailedResourceReport): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Builds the canonical identity of an orphaned resource of the given type,
|
|
41
|
+
* e.g. `orphan.microsoft.compute/disks`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function orphanRecommendationId(resourceType: string): string;
|
|
44
|
+
//# sourceMappingURL=finding-dedup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"finding-dedup.d.ts","sourceRoot":"","sources":["../../src/azure/finding-dedup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,OAAO,EAAiB,MAAM,eAAe,CAAC;AAC5D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AAa9D;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,2BAA2B,GAClC,OAAO,CA0BT;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEnE"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-source deduplication of findings.
|
|
3
|
+
*
|
|
4
|
+
* SaveMoney observes the same subscription through three lenses: the custom
|
|
5
|
+
* live-scan analyzers, Azure Advisor, and an optional AZQR report. They
|
|
6
|
+
* overlap — an unattached disk is waste no matter who reports it — so the
|
|
7
|
+
* same resource could otherwise appear several times in the output, once per
|
|
8
|
+
* source. This also folds duplicate sentences emitted by the custom analyzers
|
|
9
|
+
* themselves (e.g. the same resource triggering the same "Very low CPU usage"
|
|
10
|
+
* template twice), since the matching below is source-agnostic.
|
|
11
|
+
*
|
|
12
|
+
* Findings are unified on the `resourceId + recommendationId` key. Sources
|
|
13
|
+
* normalise `recommendationId` onto a shared vocabulary whenever one exists:
|
|
14
|
+
* orphaned resources use {@link orphanRecommendationId} regardless of who
|
|
15
|
+
* detected them, which is what lets an AZQR `AOR` row collapse onto the
|
|
16
|
+
* Advisor or custom finding for the same resource. Custom analyzer sentences
|
|
17
|
+
* with a known template get a stable `custom.<id>` identity from
|
|
18
|
+
* `finding-code.ts`; sentences with no known template keep
|
|
19
|
+
* `code: "custom.unknown"` and no `recommendationId`, so they are never
|
|
20
|
+
* collapsed on guesswork. The surviving finding is the one carrying the
|
|
21
|
+
* monetary estimate (only Advisor and the pricing-enriched custom analyzers
|
|
22
|
+
* have one — AZQR reports no amounts at all), annotated with the
|
|
23
|
+
* corroborating source so the extra provenance is not lost.
|
|
24
|
+
*/
|
|
25
|
+
import { maxCostRisk } from "../types.js";
|
|
26
|
+
/** Sources that observe the live subscription, as opposed to a static report. */
|
|
27
|
+
const LIVE_SOURCES = new Set(["advisor", "custom"]);
|
|
28
|
+
/**
|
|
29
|
+
* Canonical identity prefix for "this resource is provisioned but unused".
|
|
30
|
+
* Shared by every source so orphan findings deduplicate across them.
|
|
31
|
+
*/
|
|
32
|
+
const ORPHAN_RECOMMENDATION_PREFIX = "orphan.";
|
|
33
|
+
/**
|
|
34
|
+
* Folds `incoming` into an existing finding of `report` when both describe the
|
|
35
|
+
* same problem, so the resource is reported once instead of once per source.
|
|
36
|
+
*
|
|
37
|
+
* The surviving finding keeps the highest severity and the only available
|
|
38
|
+
* monetary estimate, and records the corroborating source both in its own
|
|
39
|
+
* reason and in the legacy per-resource `analysis` summary, so the `lint`,
|
|
40
|
+
* `json` and `table` formats all keep the provenance.
|
|
41
|
+
*
|
|
42
|
+
* @returns `true` when the finding was folded and must not be appended.
|
|
43
|
+
*/
|
|
44
|
+
export function foldDuplicateFinding(incoming, report) {
|
|
45
|
+
const findings = report.findings;
|
|
46
|
+
if (!findings) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const surviving = findDuplicateFinding(incoming, findings);
|
|
50
|
+
if (!surviving) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const note = corroborationNote(surviving, incoming);
|
|
54
|
+
findings[findings.indexOf(surviving)] = {
|
|
55
|
+
...surviving,
|
|
56
|
+
estimatedMonthlySavings: surviving.estimatedMonthlySavings ?? incoming.estimatedMonthlySavings,
|
|
57
|
+
reason: note ? `${surviving.reason.trimEnd()} ${note}` : surviving.reason,
|
|
58
|
+
recommendationId: surviving.recommendationId ?? incoming.recommendationId,
|
|
59
|
+
severity: maxCostRisk(surviving.severity, incoming.severity),
|
|
60
|
+
};
|
|
61
|
+
report.analysis = {
|
|
62
|
+
...report.analysis,
|
|
63
|
+
costRisk: maxCostRisk(report.analysis.costRisk, incoming.severity),
|
|
64
|
+
reason: note
|
|
65
|
+
? `${report.analysis.reason.trimEnd()} ${note}`.trim()
|
|
66
|
+
: report.analysis.reason,
|
|
67
|
+
};
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Builds the canonical identity of an orphaned resource of the given type,
|
|
72
|
+
* e.g. `orphan.microsoft.compute/disks`.
|
|
73
|
+
*/
|
|
74
|
+
export function orphanRecommendationId(resourceType) {
|
|
75
|
+
return `${ORPHAN_RECOMMENDATION_PREFIX}${resourceType.toLowerCase()}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Short sentence appended to the surviving finding so the reader still sees
|
|
79
|
+
* that a second source flagged the same resource. Omitted for same-source
|
|
80
|
+
* duplicates, where there is no extra provenance to record, and when the same
|
|
81
|
+
* note is already there — repeated rows must corroborate, not accumulate.
|
|
82
|
+
*/
|
|
83
|
+
function corroborationNote(surviving, incoming) {
|
|
84
|
+
if (surviving.source === incoming.source) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const note = isOrphanRecommendation(incoming.recommendationId)
|
|
88
|
+
? `Also flagged by ${incoming.source} as an orphaned resource.`
|
|
89
|
+
: `Also flagged by ${incoming.source}.`;
|
|
90
|
+
return surviving.reason.includes(note) ? undefined : note;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Returns the already-collected finding that covers the same problem as
|
|
94
|
+
* `incoming`, or `undefined` when the incoming finding is genuinely new.
|
|
95
|
+
*
|
|
96
|
+
* Two findings describe the same problem when either:
|
|
97
|
+
*
|
|
98
|
+
* 1. they share the exact canonical identity (`recommendationId`) on the same
|
|
99
|
+
* resource — a true duplicate, e.g. the same AZQR row ingested twice; or
|
|
100
|
+
* 2. `incoming` reports the resource as orphaned and a live source already
|
|
101
|
+
* reported billable waste on it with no stronger identity of its own (e.g.
|
|
102
|
+
* a sentence-level custom finding with no `recommendationId`). An orphaned
|
|
103
|
+
* resource has a single cost problem — it exists — so the live finding,
|
|
104
|
+
* which can carry a monetary estimate, supersedes the static one. A live
|
|
105
|
+
* finding that already carries its own distinct `recommendationId` (e.g.
|
|
106
|
+
* an Advisor right-sizing recommendation) is a different problem and is
|
|
107
|
+
* never absorbed this way.
|
|
108
|
+
*
|
|
109
|
+
* Findings without a `recommendationId` never match: sentences whose text
|
|
110
|
+
* matches no known template (still `code: "custom.unknown"`, see
|
|
111
|
+
* `finding-code.ts`) must not be collapsed on guesswork.
|
|
112
|
+
*/
|
|
113
|
+
function findDuplicateFinding(incoming, existing) {
|
|
114
|
+
const identity = incoming.recommendationId?.toLowerCase();
|
|
115
|
+
if (!identity) {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
const sameIdentity = existing.find((candidate) => candidate.recommendationId?.toLowerCase() === identity);
|
|
119
|
+
if (sameIdentity) {
|
|
120
|
+
return sameIdentity;
|
|
121
|
+
}
|
|
122
|
+
if (!isOrphanRecommendation(incoming.recommendationId)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
return existing.find((candidate) => candidate.category === "cost" &&
|
|
126
|
+
LIVE_SOURCES.has(candidate.source) &&
|
|
127
|
+
!candidate.recommendationId);
|
|
128
|
+
}
|
|
129
|
+
function isOrphanRecommendation(recommendationId) {
|
|
130
|
+
return (recommendationId ?? "").startsWith(ORPHAN_RECOMMENDATION_PREFIX);
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=finding-dedup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"finding-dedup.js","sourceRoot":"","sources":["../../src/azure/finding-dedup.ts"],"names":[],"mappings":"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"}
|
package/dist/finding.d.ts
CHANGED
|
@@ -14,7 +14,8 @@ import type { CostRisk } from "./types.js";
|
|
|
14
14
|
* A single, atomic observation about a resource.
|
|
15
15
|
*
|
|
16
16
|
* One resource can produce multiple findings (e.g. "no tags" + "low CPU").
|
|
17
|
-
* Findings are
|
|
17
|
+
* Findings are deduplicated on `(resourceId, recommendationId)` — see
|
|
18
|
+
* {@link Finding.recommendationId}.
|
|
18
19
|
*/
|
|
19
20
|
export type Finding = {
|
|
20
21
|
/**
|
|
@@ -25,8 +26,8 @@ export type Finding = {
|
|
|
25
26
|
/**
|
|
26
27
|
* Stable machine-readable identifier for the kind of finding, e.g.
|
|
27
28
|
* `vm.deallocated`, `disk.unattached`, `advisor.right-size-vm`.
|
|
28
|
-
*
|
|
29
|
-
*
|
|
29
|
+
* Free-form and source-specific: use `recommendationId` for
|
|
30
|
+
* deduplication across sources.
|
|
30
31
|
*/
|
|
31
32
|
code: string;
|
|
32
33
|
/**
|
|
@@ -40,6 +41,19 @@ export type Finding = {
|
|
|
40
41
|
* legacy `AnalysisResult.reason` field.
|
|
41
42
|
*/
|
|
42
43
|
reason: string;
|
|
44
|
+
/**
|
|
45
|
+
* Canonical, source-independent identity of the underlying problem.
|
|
46
|
+
* Together with `resourceId` it forms the deduplication key, so the same
|
|
47
|
+
* waste reported by different sources collapses into a single finding
|
|
48
|
+
* (see `azure/finding-dedup.ts`).
|
|
49
|
+
*
|
|
50
|
+
* Producers normalise it whenever a shared vocabulary exists — orphaned
|
|
51
|
+
* resources use `orphan.<resourceType>` regardless of the source that
|
|
52
|
+
* detected them — and fall back to their native recommendation ID
|
|
53
|
+
* otherwise. Left unset when the source has no stable identifier: such
|
|
54
|
+
* findings are never deduplicated.
|
|
55
|
+
*/
|
|
56
|
+
recommendationId?: string;
|
|
43
57
|
/**
|
|
44
58
|
* Optional, machine-friendly hint about how to remediate. For Advisor
|
|
45
59
|
* this typically maps to `shortDescription.solution`.
|
|
@@ -84,10 +98,8 @@ export type Money = {
|
|
|
84
98
|
/**
|
|
85
99
|
* Aggregate view: one resource with the list of findings emitted for it.
|
|
86
100
|
*
|
|
87
|
-
* This is the type future report generators should consume
|
|
88
|
-
* report layer still works on `AzureDetailedResourceReport
|
|
89
|
-
* (`legacyReportFromResourceReport`) bridges the two until the report
|
|
90
|
-
* layer is migrated.
|
|
101
|
+
* This is the type future report generators should consume; the current
|
|
102
|
+
* report layer still works on `AzureDetailedResourceReport`.
|
|
91
103
|
*/
|
|
92
104
|
export type ResourceReport<TResource = unknown> = {
|
|
93
105
|
findings: Finding[];
|
package/dist/finding.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finding.d.ts","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C
|
|
1
|
+
{"version":3,"file":"finding.d.ts","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C;;;;;;GAMG;AACH,MAAM,MAAM,OAAO,GAAG;IACpB;;;OAGG;IACH,QAAQ,EAAE,eAAe,CAAC;IAC1B;;;;;OAKG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC;IAChC;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;OAEG;IACH,UAAU,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,QAAQ,EAAE,QAAQ,CAAC;IACnB;;OAEG;IACH,MAAM,EAAE,aAAa,CAAC;CACvB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,eAAe,GACzB,MAAM,GAAG,uBAAuB,GAAG,aAAa,GAAG,aAAa,GAAG,UAAU,CAAC;AAEhF;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAC;AAElE;;;GAGG;AACH,MAAM,MAAM,KAAK,GAAG;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,cAAc,CAAC,SAAS,GAAG,OAAO,IAAI;IAChD,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,QAAQ,EAAE,SAAS,CAAC;CACrB,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE;IAC/C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,QAAQ,CAAC;IACnB,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB,GAAG,OAAO,EAAE,CAqBZ"}
|
package/dist/finding.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"finding.js","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;
|
|
1
|
+
{"version":3,"file":"finding.js","sourceRoot":"","sources":["../src/finding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAyGH;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,0BAA0B,CAAC,IAM1C;IACC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,GAAG,QAAQ,EAAE,GAAG,IAAI,CAAC;IACvE,MAAM,SAAS,GAAG,wBAAwB,CAAC,MAAM,CAAC,CAAC;IACnD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,EAAE,CAAC;IACZ,CAAC;IACD,sEAAsE;IACtE,qEAAqE;IACrE,oEAAoE;IACpE,oEAAoE;IACpE,kEAAkE;IAClE,8DAA8D;IAC9D,qBAAqB;IACrB,OAAO,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;QAClC,QAAQ,EAAE,MAAe;QACzB,IAAI,EAAE,IAAI,IAAI,gBAAgB;QAC9B,MAAM,EAAE,QAAQ;QAChB,UAAU;QACV,QAAQ;QACR,MAAM;KACP,CAAC,CAAC,CAAC;AACN,CAAC;AAED;;;;GAIG;AACH,SAAS,wBAAwB,CAAC,MAAc;IAC9C,OAAO,MAAM;SACV,KAAK,CAAC,WAAW,CAAC;SAClB,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;SACpB,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;SAC3B,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC;AACjD,CAAC"}
|
package/dist/types.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type BaseConfig = {
|
|
|
22
22
|
timespanDays: number;
|
|
23
23
|
};
|
|
24
24
|
export type CostRisk = "high" | "low" | "medium";
|
|
25
|
+
/** Cost risk ordering, from the most to the least severe. */
|
|
26
|
+
export declare const COST_RISK_ORDER: Record<CostRisk, number>;
|
|
25
27
|
/**
|
|
26
28
|
* Configurable thresholds used during resource analysis.
|
|
27
29
|
* Derived from `ThresholdsSchema` — the schema is the single source of truth.
|
|
@@ -57,6 +59,12 @@ export declare const DEFAULT_THRESHOLDS: {
|
|
|
57
59
|
networkInBytesPerDay: number;
|
|
58
60
|
};
|
|
59
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* Returns the more severe of two cost risks. Used whenever two observations
|
|
64
|
+
* about the same resource (or the same problem seen by two sources) are
|
|
65
|
+
* combined, so severity never degrades silently.
|
|
66
|
+
*/
|
|
67
|
+
export declare function maxCostRisk(a: CostRisk, b: CostRisk): CostRisk;
|
|
60
68
|
/**
|
|
61
69
|
* Merges analysis results, preserving existing reasons and combining suspectedUnused flags.
|
|
62
70
|
*/
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAI1C,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjD;;;GAGG;AACH,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;CAA6B,CAAC;AAE7D;;GAEG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,cAAc,GAC7B,cAAc,CAUhB"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAI1C,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,QAAQ,CAAC;IACnB;;;;;OAKG;IACH,uBAAuB,CAAC,EAAE,KAAK,CAAC;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,eAAe,EAAE,OAAO,CAAC;CAC1B,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,KAAK,GAAG,QAAQ,CAAC;AAEjD,6DAA6D;AAC7D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,CAIpD,CAAC;AAEF;;;GAGG;AACH,YAAY,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C;;;GAGG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;CAA6B,CAAC;AAE7D;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAE9D;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,cAAc,GAC7B,cAAc,CAUhB"}
|
package/dist/types.js
CHANGED
|
@@ -2,11 +2,25 @@
|
|
|
2
2
|
* Common types shared across all cloud providers
|
|
3
3
|
*/
|
|
4
4
|
import { ThresholdsSchema } from "./schema.js";
|
|
5
|
+
/** Cost risk ordering, from the most to the least severe. */
|
|
6
|
+
export const COST_RISK_ORDER = {
|
|
7
|
+
high: 0,
|
|
8
|
+
low: 2,
|
|
9
|
+
medium: 1,
|
|
10
|
+
};
|
|
5
11
|
/**
|
|
6
12
|
* Default threshold values — produced by parsing an empty object through
|
|
7
13
|
* `ThresholdsSchema`, which applies all schema-defined defaults.
|
|
8
14
|
*/
|
|
9
15
|
export const DEFAULT_THRESHOLDS = ThresholdsSchema.parse({});
|
|
16
|
+
/**
|
|
17
|
+
* Returns the more severe of two cost risks. Used whenever two observations
|
|
18
|
+
* about the same resource (or the same problem seen by two sources) are
|
|
19
|
+
* combined, so severity never degrades silently.
|
|
20
|
+
*/
|
|
21
|
+
export function maxCostRisk(a, b) {
|
|
22
|
+
return COST_RISK_ORDER[a] <= COST_RISK_ORDER[b] ? a : b;
|
|
23
|
+
}
|
|
10
24
|
/**
|
|
11
25
|
* Merges analysis results, preserving existing reasons and combining suspectedUnused flags.
|
|
12
26
|
*/
|
package/dist/types.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAyB/C,6DAA6D;AAC7D,MAAM,CAAC,MAAM,eAAe,GAA6B;IACvD,IAAI,EAAE,CAAC;IACP,GAAG,EAAE,CAAC;IACN,MAAM,EAAE,CAAC;CACV,CAAC;AAQF;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,gBAAgB,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;AAE7D;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,CAAW,EAAE,CAAW;IAClD,OAAO,eAAe,CAAC,CAAC,CAAC,IAAI,eAAe,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAC1D,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,YAAY,CAC1B,UAA0B,EAC1B,cAA8B;IAE9B,OAAO;QACL,QAAQ,EAAE,cAAc,CAAC,QAAQ;QACjC,uBAAuB,EACrB,cAAc,CAAC,uBAAuB;YACtC,UAAU,CAAC,uBAAuB;QACpC,MAAM,EAAE,UAAU,CAAC,MAAM,GAAG,cAAc,CAAC,MAAM;QACjD,eAAe,EACb,UAAU,CAAC,eAAe,IAAI,cAAc,CAAC,eAAe;KAC/D,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the AZQR ingestion module.
|
|
3
3
|
*
|
|
4
|
-
* Cover
|
|
4
|
+
* Cover:
|
|
5
5
|
* 1. `parseAzqrReport` validates the JSON shape (accept valid, reject invalid).
|
|
6
6
|
* 2. `isAzqrReportMasked` detects AZQR's default subscription-ID masking.
|
|
7
7
|
* 3. `classifyAzqrRow` / `azqrImpactedToFindings` promote billable waste as
|
|
@@ -235,4 +235,48 @@ describe("azqrImpactedToFindings", () => {
|
|
|
235
235
|
});
|
|
236
236
|
expect(azqrImpactedToFindings(report)[0].severity).toBe("low");
|
|
237
237
|
});
|
|
238
|
+
|
|
239
|
+
it("gives orphaned resources the shared cross-source recommendation id", () => {
|
|
240
|
+
const report = parseAzqrReport({
|
|
241
|
+
impacted: [
|
|
242
|
+
orphanRow("Microsoft.Compute/disks", {
|
|
243
|
+
recommendation: "Managed Disks not attached",
|
|
244
|
+
recommendationId: "orphan-disk",
|
|
245
|
+
}),
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(azqrImpactedToFindings(report)[0].recommendationId).toBe(
|
|
250
|
+
"orphan.microsoft.compute/disks",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("namespaces the native recommendation id of non-orphan rows", () => {
|
|
255
|
+
const report = parseAzqrReport({
|
|
256
|
+
impacted: [
|
|
257
|
+
impactedRow({
|
|
258
|
+
category: "Cost",
|
|
259
|
+
recommendationId: "aprl-1234",
|
|
260
|
+
resourceType: "microsoft.compute/disks",
|
|
261
|
+
}),
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(azqrImpactedToFindings(report)[0].recommendationId).toBe(
|
|
266
|
+
"azqr.aprl-1234",
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("leaves the recommendation id unset when the row carries none", () => {
|
|
271
|
+
const report = parseAzqrReport({
|
|
272
|
+
impacted: [
|
|
273
|
+
impactedRow({
|
|
274
|
+
category: "Cost",
|
|
275
|
+
resourceType: "microsoft.compute/disks",
|
|
276
|
+
}),
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(azqrImpactedToFindings(report)[0].recommendationId).toBeUndefined();
|
|
281
|
+
});
|
|
238
282
|
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the categorisation of custom analyzer reasons.
|
|
3
|
+
*
|
|
4
|
+
* The per-resource analyzers describe governance observations and billable
|
|
5
|
+
* waste in the same `reason` string, so the classifier is what keeps the
|
|
6
|
+
* `cost` category meaning "money is being wasted".
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import type { Finding } from "../../finding.js";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
categorizeCustomFindings,
|
|
15
|
+
MISSING_TAGS_REASON,
|
|
16
|
+
NO_ANALYZER_REASON,
|
|
17
|
+
unpreferredLocationReason,
|
|
18
|
+
} from "../finding-category.js";
|
|
19
|
+
|
|
20
|
+
const RESOURCE_ID =
|
|
21
|
+
"/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.Compute/disks/disk1";
|
|
22
|
+
|
|
23
|
+
function categorize(reason: string): Finding["category"] {
|
|
24
|
+
const [finding] = categorizeCustomFindings([customFinding(reason)]);
|
|
25
|
+
return finding.category;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function customFinding(reason: string): Finding {
|
|
29
|
+
return {
|
|
30
|
+
category: "cost",
|
|
31
|
+
code: "custom.unknown",
|
|
32
|
+
reason,
|
|
33
|
+
resourceId: RESOURCE_ID,
|
|
34
|
+
severity: "low",
|
|
35
|
+
source: "custom",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("categorizeCustomFindings", () => {
|
|
40
|
+
it.each([
|
|
41
|
+
MISSING_TAGS_REASON,
|
|
42
|
+
NO_ANALYZER_REASON,
|
|
43
|
+
unpreferredLocationReason("italynorth"),
|
|
44
|
+
"Could not retrieve detailed NIC information.",
|
|
45
|
+
])("reports %j as operational excellence, not cost", (reason) => {
|
|
46
|
+
expect(categorize(reason)).toBe("operationalExcellence");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
"Disk is unattached.",
|
|
51
|
+
"VM is deallocated.",
|
|
52
|
+
"Public IP is not associated with any resource.",
|
|
53
|
+
])("keeps %j as a cost finding", (reason) => {
|
|
54
|
+
expect(categorize(reason)).toBe("cost");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("preserves every other field of the finding", () => {
|
|
58
|
+
const finding: Finding = {
|
|
59
|
+
...customFinding(MISSING_TAGS_REASON),
|
|
60
|
+
recommendedAction: "Add the required tags.",
|
|
61
|
+
severity: "medium",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
expect(categorizeCustomFindings([finding])).toEqual([
|
|
65
|
+
{ ...finding, category: "operationalExcellence" },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|