diff --git a/lib/modules/platform/github/common.ts b/lib/modules/platform/github/common.ts index 2b9e146bab655244bba016fc5b42af2e2284b267..9a4853d3c2790527f0d6fe1b3b8058770f83b2fd 100644 --- a/lib/modules/platform/github/common.ts +++ b/lib/modules/platform/github/common.ts @@ -1,6 +1,8 @@ import is from '@sindresorhus/is'; import { PrState } from '../../../types'; +import { checkSchema } from '../../../util/schema'; import { getPrBodyStruct } from '../pr-body'; +import * as platformSchemas from '../schemas'; import type { GhPr, GhRestPr } from './types'; /** @@ -50,5 +52,6 @@ export function coerceRestPr(pr: GhRestPr): GhPr { result.closedAt = pr.closed_at; } + checkSchema(platformSchemas.Pr, result); return result; } diff --git a/lib/modules/platform/schemas.ts b/lib/modules/platform/schemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac905936bff63f6ba529cdfbc5483a2d79953572 --- /dev/null +++ b/lib/modules/platform/schemas.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const Pr = z.object( + { + sourceBranch: z.string().min(1), + number: z.number(), + state: z.string().min(1), + title: z.string().min(1), + }, + { + description: 'Pull Request', + } +); diff --git a/lib/util/schema.spec.ts b/lib/util/schema.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..608b8dadc239460e8061359c65beca4177784298 --- /dev/null +++ b/lib/util/schema.spec.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; +import { logger } from '../../test/util'; +import * as memCache from './cache/memory'; +import { checkSchema, reportErrors } from './schema'; + +describe('util/schema', () => { + beforeEach(() => { + jest.resetAllMocks(); + memCache.init(); + }); + + it('validates data', () => { + const schema = z.object({ foo: z.string() }); + const validData = { foo: 'bar' }; + + const res = checkSchema(schema, validData); + expect(res).toBeTrue(); + + reportErrors(); + expect(logger.logger.warn).not.toHaveBeenCalledOnce(); + }); + + it('reports nothing if there are no any reports', () => { + reportErrors(); + expect(logger.logger.warn).not.toHaveBeenCalled(); + }); + + it('reports same warning once', () => { + const schema = z.object( + { foo: z.string() }, + { description: 'Some test schema' } + ); + const invalidData = { foo: 42 }; + + checkSchema(schema, invalidData); + checkSchema(schema, invalidData); + checkSchema(schema, invalidData); + checkSchema(schema, invalidData); + reportErrors(); + + expect(logger.logger.warn).toHaveBeenCalledOnce(); + expect(logger.logger.warn.mock.calls[0]).toMatchObject([ + { description: 'Some test schema' }, + 'Schema validation error', + ]); + }); + + it('reports unspecified schema', () => { + const schema = z.object({ foo: z.string() }); + const invalidData = { foo: 42 }; + + checkSchema(schema, invalidData); + reportErrors(); + + expect(logger.logger.warn).toHaveBeenCalledOnce(); + expect(logger.logger.warn.mock.calls[0]).toMatchObject([ + { description: 'Unspecified schema' }, + 'Schema validation error', + ]); + }); +}); diff --git a/lib/util/schema.ts b/lib/util/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..22e91073793c5ec88b542e50067b62aa62fdb00d --- /dev/null +++ b/lib/util/schema.ts @@ -0,0 +1,59 @@ +import hasha from 'hasha'; +import type { z } from 'zod'; +import { logger } from '../logger'; +import * as memCache from './cache/memory'; +import { safeStringify } from './stringify'; + +type SchemaErrorsMap = Record<string, Record<string, z.ZodError>>; + +function getCacheKey(error: z.ZodError): string { + const content = safeStringify(error); + const key = hasha(content).slice(0, 32); + return `err_${key}`; +} + +function collectError<T extends z.ZodSchema>( + schema: T, + error: z.ZodError +): void { + const { description = 'Unspecified schema' } = schema; + const schemaErrorsMap = memCache.get<SchemaErrorsMap>('schema-errors') ?? {}; + const schemaErrors = schemaErrorsMap[description] ?? {}; + const key = getCacheKey(error); + const schemaError = schemaErrors[key]; + if (!schemaError) { + schemaErrors[key] = error; + schemaErrorsMap[description] = schemaErrors; + } + memCache.set('schema-errors', schemaErrorsMap); +} + +export function reportErrors(): void { + const schemaErrorsMap = memCache.get<SchemaErrorsMap>('schema-errors'); + if (!schemaErrorsMap) { + return; + } + + for (const [description, schemaErrors] of Object.entries(schemaErrorsMap)) { + const errors = Object.values(schemaErrors); + for (const err of errors) { + logger.warn({ description, err }, `Schema validation error`); + } + } + + memCache.set('schema-errors', null); +} + +export function checkSchema<T extends z.ZodSchema>( + schema: T, + input: unknown +): input is z.infer<T> { + const res = schema.safeParse(input); + const { success } = res; + if (!success) { + collectError(schema, res.error); + return false; + } + + return true; +} diff --git a/lib/workers/repository/index.ts b/lib/workers/repository/index.ts index 1ff989bc6d70569e5a4b03f1fe85f3ce9c8d42b5..c829742543b359b59eecf5de29483be702ad78f8 100644 --- a/lib/workers/repository/index.ts +++ b/lib/workers/repository/index.ts @@ -8,6 +8,7 @@ import { removeDanglingContainers } from '../../util/exec/docker'; import { deleteLocalFile, privateCacheDir } from '../../util/fs'; import { clearDnsCache, printDnsStats } from '../../util/http/dns'; import * as queue from '../../util/http/queue'; +import * as schemaUtil from '../../util/schema'; import { addSplit, getSplits, splitInit } from '../../util/split'; import { setBranchCache } from './cache'; import { ensureDependencyDashboard } from './dependency-dashboard'; @@ -89,6 +90,7 @@ export async function renovateRepository( printRequestStats(); printDnsStats(); clearDnsCache(); + schemaUtil.reportErrors(); logger.info({ durationMs: splits.total }, 'Repository finished'); return repoResult; } diff --git a/package.json b/package.json index 70eb6ca59a245913898badfd7d8e6487ca8f1d1b..bb0fcbb6ee877b28a1e7d8f87419b6c26ab44828 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,8 @@ "upath": "2.0.1", "url-join": "4.0.1", "validate-npm-package-name": "4.0.0", - "xmldoc": "1.2.0" + "xmldoc": "1.2.0", + "zod": "3.19.1" }, "optionalDependencies": { "re2": "1.17.7" diff --git a/yarn.lock b/yarn.lock index 13dcc55e7b332e15897612b23e57e2d13dc1e7ac..0d15c2c81e0b30458e756309ff64d4440bb85276 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9694,6 +9694,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zod@3.19.1: + version "3.19.1" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.19.1.tgz#112f074a97b50bfc4772d4ad1576814bd8ac4473" + integrity sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"