fix: harden dashboard model validation
This commit is contained in:
parent
7cfd5b4069
commit
74651a49de
4 changed files with 288 additions and 13 deletions
|
|
@ -44,7 +44,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
|
||||||
metric("system-disk-peak", "System Disk Peak", "percent", 49, "linux /boot", "ok"),
|
metric("system-disk-peak", "System Disk Peak", "percent", 49, "linux /boot", "ok"),
|
||||||
metric("peak-cpu-busy", "Peak CPU Busy", "percent", 3, "linux-infra", "ok"),
|
metric("peak-cpu-busy", "Peak CPU Busy", "percent", 3, "linux-infra", "ok"),
|
||||||
metric("top-cpu-container", "Top CPU Container", "percent", 7.9, "container placeholder", "ok"),
|
metric("top-cpu-container", "Top CPU Container", "percent", 7.9, "container placeholder", "ok"),
|
||||||
metric("top-ram-container", "Top RAM Container", "bytes", "5.2 GB", "container placeholder", "danger"),
|
metric("top-ram-container", "Top RAM Container", "bytes", 5.2 * 1024 ** 3, "container placeholder", "danger"),
|
||||||
metric("gpu-3060-load", "3060 GPU Load", "percent", 0, "RTX 3060", "ok"),
|
metric("gpu-3060-load", "3060 GPU Load", "percent", 0, "RTX 3060", "ok"),
|
||||||
metric("gpu-3060-vram", "3060 VRAM", "percent", 0, "RTX 3060", "ok"),
|
metric("gpu-3060-vram", "3060 VRAM", "percent", 0, "RTX 3060", "ok"),
|
||||||
metric("gpu-3060-temp", "3060 Temp", "temperature", 54, "RTX 3060", "ok"),
|
metric("gpu-3060-temp", "3060 Temp", "temperature", 54, "RTX 3060", "ok"),
|
||||||
|
|
@ -111,14 +111,14 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
type ValueKind = "percent" | "bytes" | "temperature" | "latency" | "number" | "text";
|
type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number";
|
||||||
type Severity = "neutral" | "ok" | "warning" | "danger" | "stale" | "unavailable";
|
type Severity = "neutral" | "ok" | "warning" | "danger" | "stale" | "unavailable";
|
||||||
|
|
||||||
function metric(
|
function metric(
|
||||||
id: string,
|
id: string,
|
||||||
label: string,
|
label: string,
|
||||||
kind: ValueKind,
|
kind: NumericValueKind,
|
||||||
value: number | string,
|
value: number,
|
||||||
detail: string,
|
detail: string,
|
||||||
severity: Severity,
|
severity: Severity,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,77 @@ describe("dashboard model validation", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes offending additional property names in errors", () => {
|
||||||
|
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||||
|
invalid.metadata.unexpected = true;
|
||||||
|
|
||||||
|
const result = validateDashboardDocument(invalid);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.errors.join(" ")).toContain("unexpected");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-finite numbers that cannot roundtrip through JSON", () => {
|
||||||
|
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||||
|
invalid.telemetry[0].value.value = Number.NaN;
|
||||||
|
|
||||||
|
const result = validateDashboardDocument(invalid);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.errors.join(" ")).toContain("finite JSON number");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects dangling layout references", () => {
|
||||||
|
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||||
|
invalid.layout.telemetry.push("missing-card");
|
||||||
|
|
||||||
|
const result = validateDashboardDocument(invalid);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.errors.join(" ")).toContain("missing-card");
|
||||||
|
expect(result.errors.join(" ")).toContain("must reference an existing item");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects duplicate IDs within collections", () => {
|
||||||
|
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||||
|
invalid.telemetry[1].id = invalid.telemetry[0].id;
|
||||||
|
|
||||||
|
const result = validateDashboardDocument(invalid);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.errors.join(" ")).toContain("must be unique");
|
||||||
|
expect(result.errors.join(" ")).toContain(invalid.telemetry[0].id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects string values for numeric metric kinds", () => {
|
||||||
|
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||||
|
invalid.telemetry[0].value.value = "not a percent";
|
||||||
|
|
||||||
|
const result = validateDashboardDocument(invalid);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects contradictory warning and danger thresholds", () => {
|
||||||
|
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||||
|
invalid.telemetry[0].thresholds = { warning: 90, danger: 80 };
|
||||||
|
|
||||||
|
const result = validateDashboardDocument(invalid);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
if (!result.valid) {
|
||||||
|
expect(result.errors.join(" ")).toContain("warning threshold");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("exports JSON Schema for external tool contracts", () => {
|
it("exports JSON Schema for external tool contracts", () => {
|
||||||
expect(dashboardDocumentJsonSchema.$id).toContain("dashboard-document.v1");
|
expect(dashboardDocumentJsonSchema.$id).toContain("dashboard-document.v1");
|
||||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("schemaVersion");
|
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("schemaVersion");
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ const DatasourceReferenceSchema = Type.Union([
|
||||||
ExternalDatasourceSchema,
|
ExternalDatasourceSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const MetricValueSchema = Type.Object(
|
const NumericMetricValueSchema = Type.Object(
|
||||||
{
|
{
|
||||||
kind: Type.Union([
|
kind: Type.Union([
|
||||||
Type.Literal("percent"),
|
Type.Literal("percent"),
|
||||||
|
|
@ -72,21 +72,34 @@ const MetricValueSchema = Type.Object(
|
||||||
Type.Literal("temperature"),
|
Type.Literal("temperature"),
|
||||||
Type.Literal("latency"),
|
Type.Literal("latency"),
|
||||||
Type.Literal("number"),
|
Type.Literal("number"),
|
||||||
Type.Literal("text"),
|
|
||||||
]),
|
]),
|
||||||
value: Type.Union([Type.Number(), Type.String()]),
|
value: Type.Number(),
|
||||||
unit: Type.Optional(Type.String({ minLength: 1 })),
|
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
|
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const TextMetricValueSchema = Type.Object(
|
||||||
|
{
|
||||||
|
kind: Type.Literal("text"),
|
||||||
|
value: Type.String(),
|
||||||
|
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const MetricValueSchema = Type.Union([
|
||||||
|
NumericMetricValueSchema,
|
||||||
|
TextMetricValueSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
const ThresholdSchema = Type.Object(
|
const ThresholdSchema = Type.Object(
|
||||||
{
|
{
|
||||||
warning: Type.Optional(Type.Number()),
|
warning: Type.Optional(Type.Number()),
|
||||||
danger: Type.Optional(Type.Number()),
|
danger: Type.Optional(Type.Number()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false, minProperties: 1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TelemetryCardSchema = Type.Object(
|
export const TelemetryCardSchema = Type.Object(
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,20 @@ import {
|
||||||
type DashboardDocument,
|
type DashboardDocument,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
|
type DashboardValidationIssue = ErrorObject | SemanticValidationIssue;
|
||||||
|
|
||||||
|
interface SemanticValidationIssue {
|
||||||
|
instancePath: string;
|
||||||
|
schemaPath: string;
|
||||||
|
keyword: "semantic";
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DashboardValidationFailure {
|
export interface DashboardValidationFailure {
|
||||||
valid: false;
|
valid: false;
|
||||||
errors: string[];
|
errors: string[];
|
||||||
details: ErrorObject[];
|
details: DashboardValidationIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardValidationSuccess {
|
export interface DashboardValidationSuccess {
|
||||||
|
|
@ -24,27 +34,46 @@ const ajv = addFormats(
|
||||||
new Ajv({
|
new Ajv({
|
||||||
allErrors: true,
|
allErrors: true,
|
||||||
strict: false,
|
strict: false,
|
||||||
|
strictNumbers: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
|
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
|
||||||
|
|
||||||
export function formatValidationErrors(errors: ErrorObject[] = []): string[] {
|
export function formatValidationErrors(
|
||||||
|
errors: DashboardValidationIssue[] = [],
|
||||||
|
): string[] {
|
||||||
return errors.map((error) => {
|
return errors.map((error) => {
|
||||||
const path = error.instancePath || "/";
|
const path = error.instancePath || "/";
|
||||||
|
const suffix = formatErrorParams(error);
|
||||||
const message = error.message || "is invalid";
|
const message = error.message || "is invalid";
|
||||||
return `${path} ${message}`;
|
return `${path} ${message}${suffix}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateDashboardDocument(
|
export function validateDashboardDocument(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): DashboardValidationResult {
|
): DashboardValidationResult {
|
||||||
|
const finiteNumberIssues: SemanticValidationIssue[] = [];
|
||||||
|
collectFiniteNumberIssues(value, "", finiteNumberIssues);
|
||||||
|
|
||||||
if (validateDashboard(value)) {
|
if (validateDashboard(value)) {
|
||||||
|
const semanticIssues = [
|
||||||
|
...finiteNumberIssues,
|
||||||
|
...validateSemanticRules(value),
|
||||||
|
];
|
||||||
|
if (semanticIssues.length === 0) {
|
||||||
return { valid: true, data: value };
|
return { valid: true, data: value };
|
||||||
}
|
}
|
||||||
|
|
||||||
const details = [...(validateDashboard.errors || [])];
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: formatValidationErrors(semanticIssues),
|
||||||
|
details: semanticIssues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = [...(validateDashboard.errors || []), ...finiteNumberIssues];
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
errors: formatValidationErrors(details),
|
errors: formatValidationErrors(details),
|
||||||
|
|
@ -64,3 +93,165 @@ export function assertDashboardDocument(
|
||||||
export function isDashboardDocument(value: unknown): value is DashboardDocument {
|
export function isDashboardDocument(value: unknown): value is DashboardDocument {
|
||||||
return validateDashboardDocument(value).valid;
|
return validateDashboardDocument(value).valid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatErrorParams(error: DashboardValidationIssue): string {
|
||||||
|
if (error.keyword === "additionalProperties") {
|
||||||
|
const additionalProperty = error.params.additionalProperty;
|
||||||
|
return typeof additionalProperty === "string"
|
||||||
|
? `: ${additionalProperty}`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.keyword === "semantic") {
|
||||||
|
const id = error.params.id;
|
||||||
|
const ref = error.params.ref;
|
||||||
|
if (typeof id === "string") return `: ${id}`;
|
||||||
|
if (typeof ref === "string") return `: ${ref}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSemanticRules(document: DashboardDocument): SemanticValidationIssue[] {
|
||||||
|
const issues: SemanticValidationIssue[] = [];
|
||||||
|
|
||||||
|
collectDuplicateIdIssues("telemetry", document.telemetry, issues);
|
||||||
|
collectDuplicateIdIssues("serviceGroups", document.serviceGroups, issues);
|
||||||
|
collectDuplicateIdIssues("statusStrips", document.statusStrips, issues);
|
||||||
|
collectDuplicateIdIssues("modules", document.modules || [], issues);
|
||||||
|
|
||||||
|
document.serviceGroups.forEach((group, groupIndex) => {
|
||||||
|
collectDuplicateIdIssues(
|
||||||
|
`serviceGroups/${groupIndex}/services`,
|
||||||
|
group.services,
|
||||||
|
issues,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
collectMissingReferenceIssues(
|
||||||
|
"layout/telemetry",
|
||||||
|
document.layout.telemetry,
|
||||||
|
new Set(document.telemetry.map((item) => item.id)),
|
||||||
|
issues,
|
||||||
|
);
|
||||||
|
collectMissingReferenceIssues(
|
||||||
|
"layout/serviceGroups",
|
||||||
|
document.layout.serviceGroups,
|
||||||
|
new Set(document.serviceGroups.map((item) => item.id)),
|
||||||
|
issues,
|
||||||
|
);
|
||||||
|
collectMissingReferenceIssues(
|
||||||
|
"layout/statusStrips",
|
||||||
|
document.layout.statusStrips,
|
||||||
|
new Set(document.statusStrips.map((item) => item.id)),
|
||||||
|
issues,
|
||||||
|
);
|
||||||
|
collectMissingReferenceIssues(
|
||||||
|
"layout/modules",
|
||||||
|
document.layout.modules || [],
|
||||||
|
new Set((document.modules || []).map((item) => item.id)),
|
||||||
|
issues,
|
||||||
|
);
|
||||||
|
|
||||||
|
document.telemetry.forEach((card, index) => {
|
||||||
|
const warning = card.thresholds?.warning;
|
||||||
|
const danger = card.thresholds?.danger;
|
||||||
|
if (warning !== undefined && danger !== undefined && warning > danger) {
|
||||||
|
issues.push(
|
||||||
|
semanticIssue(
|
||||||
|
`/telemetry/${index}/thresholds`,
|
||||||
|
"warning threshold must be less than or equal to danger threshold",
|
||||||
|
{ id: card.id },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFiniteNumberIssues(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
issues: SemanticValidationIssue[],
|
||||||
|
) {
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
issues.push(
|
||||||
|
semanticIssue(path || "/", "must be a finite JSON number", {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value.forEach((item, index) => {
|
||||||
|
collectFiniteNumberIssues(item, `${path}/${index}`, issues);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
Object.entries(value).forEach(([key, item]) => {
|
||||||
|
collectFiniteNumberIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDuplicateIdIssues(
|
||||||
|
collectionPath: string,
|
||||||
|
items: Array<{ id: string }>,
|
||||||
|
issues: SemanticValidationIssue[],
|
||||||
|
) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
if (seen.has(item.id)) {
|
||||||
|
issues.push(
|
||||||
|
semanticIssue(
|
||||||
|
`/${collectionPath}/${index}/id`,
|
||||||
|
"must be unique within its collection",
|
||||||
|
{ id: item.id },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(item.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMissingReferenceIssues(
|
||||||
|
layoutPath: string,
|
||||||
|
refs: string[],
|
||||||
|
validIds: Set<string>,
|
||||||
|
issues: SemanticValidationIssue[],
|
||||||
|
) {
|
||||||
|
refs.forEach((ref, index) => {
|
||||||
|
if (!validIds.has(ref)) {
|
||||||
|
issues.push(
|
||||||
|
semanticIssue(
|
||||||
|
`/${layoutPath}/${index}`,
|
||||||
|
"must reference an existing item",
|
||||||
|
{ ref },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticIssue(
|
||||||
|
instancePath: string,
|
||||||
|
message: string,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
): SemanticValidationIssue {
|
||||||
|
return {
|
||||||
|
instancePath,
|
||||||
|
schemaPath: "",
|
||||||
|
keyword: "semantic",
|
||||||
|
params,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeJsonPointer(value: string): string {
|
||||||
|
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue