From 481aa216b22a5fd5c26a1bda8aafe3c5b41e719f Mon Sep 17 00:00:00 2001 From: Sebastian Poxhofer <secustor@users.noreply.github.com> Date: Sun, 17 Mar 2024 10:22:42 +0100 Subject: [PATCH] feat(instrumentation/reporting): add report option (#26087) Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com> Co-authored-by: Michael Kriese <michael.kriese@visualon.de> --- docs/usage/self-hosted-configuration.md | 19 ++ lib/config/options/index.ts | 18 ++ lib/config/types.ts | 2 + lib/config/validation.spec.ts | 50 +++++ lib/config/validation.ts | 15 ++ lib/instrumentation/reporting.spec.ts | 177 ++++++++++++++++++ lib/instrumentation/reporting.ts | 97 ++++++++++ lib/instrumentation/types.ts | 11 ++ lib/util/cache/repository/types.ts | 2 +- lib/util/fs/index.spec.ts | 9 + lib/util/fs/index.ts | 7 + lib/workers/global/index.ts | 3 + .../finalize/repository-statistics.ts | 2 + lib/workers/repository/index.ts | 7 +- test/s3.ts | 4 + 15 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 lib/instrumentation/reporting.spec.ts create mode 100644 lib/instrumentation/reporting.ts create mode 100644 test/s3.ts diff --git a/docs/usage/self-hosted-configuration.md b/docs/usage/self-hosted-configuration.md index cfbfbd8604..5dcbf4f77d 100644 --- a/docs/usage/self-hosted-configuration.md +++ b/docs/usage/self-hosted-configuration.md @@ -909,6 +909,25 @@ For TLS/SSL-enabled connections, use rediss prefix Example URL structure: `rediss://[[username]:[password]]@localhost:6379/0`. +## reportPath + +`reportPath` describes the location where the report is written to. + +If [`reportType`](#reporttype) is set to `file`, then set `reportPath` to a filepath. +For example: `/foo/bar.json`. + +If the value `s3` is used in [`reportType`](#reporttype), then use a S3 URI. +For example: `s3://bucket-name/key-name`. + +## reportType + +Defines how the report is exposed: + +- `<unset>` If unset, no report will be provided, though the debug logs will still have partial information of the report +- `logging` The report will be printed as part of the log messages on `INFO` level +- `file` The report will be written to a path provided by [`reportPath`](#reportpath) +- `s3` The report is pushed to an S3 bucket defined by [`reportPath`](#reportpath). This option reuses [`RENOVATE_X_S3_ENDPOINT`](./self-hosted-experimental.md#renovatexs3endpoint) and [`RENOVATE_X_S3_PATH_STYLE`](./self-hosted-experimental.md#renovatexs3pathstyle) + ## repositories Elements in the `repositories` array can be an object if you wish to define more settings: diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 72eecec44c..1e07029e2b 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -299,6 +299,24 @@ const options: RenovateOptions[] = [ stage: 'repository', default: 'local', }, + { + name: 'reportType', + description: 'Set how, or if, reports should be generated.', + globalOnly: true, + type: 'string', + default: null, + experimental: true, + allowedValues: ['logging', 'file', 's3'], + }, + { + name: 'reportPath', + description: + 'Path to where the file should be written. In case of `s3` this has to be a full S3 URI.', + globalOnly: true, + type: 'string', + default: null, + experimental: true, + }, { name: 'force', description: diff --git a/lib/config/types.ts b/lib/config/types.ts index ae3c2364ed..06ea517e84 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -215,6 +215,8 @@ export interface RenovateConfig AssigneesAndReviewersConfig, ConfigMigration, Record<string, unknown> { + reportPath?: string; + reportType?: 'logging' | 'file' | 's3' | null; depName?: string; baseBranches?: string[]; commitBody?: string; diff --git a/lib/config/validation.spec.ts b/lib/config/validation.spec.ts index b92743957a..9d675b5b7c 100644 --- a/lib/config/validation.spec.ts +++ b/lib/config/validation.spec.ts @@ -1620,5 +1620,55 @@ describe('config/validation', () => { expect(warnings).toHaveLength(0); expect(errors).toHaveLength(0); }); + + it('fails for missing reportPath if reportType is "s3"', async () => { + const config: RenovateConfig = { + reportType: 's3', + }; + const { warnings, errors } = await configValidation.validateConfig( + 'global', + config, + ); + expect(warnings).toHaveLength(0); + expect(errors).toHaveLength(1); + }); + + it('validates reportPath if reportType is "s3"', async () => { + const config: RenovateConfig = { + reportType: 's3', + reportPath: 's3://bucket-name/key-name', + }; + const { warnings, errors } = await configValidation.validateConfig( + 'global', + config, + ); + expect(warnings).toHaveLength(0); + expect(errors).toHaveLength(0); + }); + + it('fails for missing reportPath if reportType is "file"', async () => { + const config: RenovateConfig = { + reportType: 'file', + }; + const { warnings, errors } = await configValidation.validateConfig( + 'global', + config, + ); + expect(warnings).toHaveLength(0); + expect(errors).toHaveLength(1); + }); + + it('validates reportPath if reportType is "file"', async () => { + const config: RenovateConfig = { + reportType: 'file', + reportPath: './report.json', + }; + const { warnings, errors } = await configValidation.validateConfig( + 'global', + config, + ); + expect(warnings).toHaveLength(0); + expect(errors).toHaveLength(0); + }); }); }); diff --git a/lib/config/validation.ts b/lib/config/validation.ts index f555df8fd6..17c67b5692 100644 --- a/lib/config/validation.ts +++ b/lib/config/validation.ts @@ -170,7 +170,9 @@ export async function validateConfig( val, optionTypes[key], warnings, + errors, currentPath, + config, ); continue; } else { @@ -828,7 +830,9 @@ async function validateGlobalConfig( val: unknown, type: string, warnings: ValidationMessage[], + errors: ValidationMessage[], currentPath: string | undefined, + config: RenovateConfig, ): Promise<void> { if (val !== null) { if (type === 'string') { @@ -882,6 +886,17 @@ async function validateGlobalConfig( message: `Invalid value \`${val}\` for \`${currentPath}\`. The allowed values are ${['default', 'ssh', 'endpoint'].join(', ')}.`, }); } + + if ( + key === 'reportType' && + ['s3', 'file'].includes(val) && + !is.string(config.reportPath) + ) { + errors.push({ + topic: 'Configuration Error', + message: `reportType '${val}' requires a configured reportPath`, + }); + } } else { warnings.push({ topic: 'Configuration Error', diff --git a/lib/instrumentation/reporting.spec.ts b/lib/instrumentation/reporting.spec.ts new file mode 100644 index 0000000000..8705ac618b --- /dev/null +++ b/lib/instrumentation/reporting.spec.ts @@ -0,0 +1,177 @@ +import type { S3Client } from '@aws-sdk/client-s3'; +import { mockDeep } from 'jest-mock-extended'; +import { s3 } from '../../test/s3'; +import { fs, logger } from '../../test/util'; +import type { RenovateConfig } from '../config/types'; +import type { PackageFile } from '../modules/manager/types'; +import type { BranchCache } from '../util/cache/repository/types'; +import { + addBranchStats, + addExtractionStats, + exportStats, + getReport, +} from './reporting'; + +jest.mock('../util/fs', () => mockDeep()); +jest.mock('../util/s3', () => mockDeep()); + +describe('instrumentation/reporting', () => { + const branchInformation: Partial<BranchCache>[] = [ + { + branchName: 'a-branch-name', + prNo: 20, + upgrades: [ + { + currentVersion: '21.1.1', + currentValue: 'v21.1.1', + newVersion: '22.0.0', + newValue: 'v22.0.0', + }, + ], + }, + ]; + const packageFiles: Record<string, PackageFile[]> = { + terraform: [ + { + packageFile: 'terraform/versions.tf', + deps: [ + { + currentValue: 'v21.1.1', + currentVersion: '4.4.3', + updates: [ + { + bucket: 'non-major', + newVersion: '4.7.0', + newValue: '~> 4.7.0', + }, + ], + }, + ], + }, + ], + }; + + const expectedReport = { + repositories: { + 'myOrg/myRepo': { + branches: branchInformation, + packageFiles, + }, + }, + }; + + it('return empty report if no stats have been added', () => { + const config = {}; + addBranchStats(config, []); + addExtractionStats(config, { + branchList: [], + branches: [], + packageFiles: {}, + }); + + expect(getReport()).toEqual({ + repositories: {}, + }); + }); + + it('return report if reportType is set to logging', () => { + const config: RenovateConfig = { + repository: 'myOrg/myRepo', + reportType: 'logging', + }; + + addBranchStats(config, branchInformation); + addExtractionStats(config, { branchList: [], branches: [], packageFiles }); + + expect(getReport()).toEqual(expectedReport); + }); + + it('log report if reportType is set to logging', async () => { + const config: RenovateConfig = { + repository: 'myOrg/myRepo', + reportType: 'logging', + }; + + addBranchStats(config, branchInformation); + addExtractionStats(config, { branchList: [], branches: [], packageFiles }); + + await exportStats(config); + expect(logger.logger.info).toHaveBeenCalledWith( + { report: expectedReport }, + 'Printing report', + ); + }); + + it('write report if reportType is set to file', async () => { + const config: RenovateConfig = { + repository: 'myOrg/myRepo', + reportType: 'file', + reportPath: './report.json', + }; + + addBranchStats(config, branchInformation); + addExtractionStats(config, { branchList: [], branches: [], packageFiles }); + + await exportStats(config); + expect(fs.writeSystemFile).toHaveBeenCalledWith( + config.reportPath, + JSON.stringify(expectedReport), + ); + }); + + it('send report to an S3 bucket if reportType is s3', async () => { + const mockClient = mockDeep<S3Client>(); + s3.parseS3Url.mockReturnValue({ Bucket: 'bucket-name', Key: 'key-name' }); + // @ts-expect-error TS2589 + s3.getS3Client.mockReturnValue(mockClient); + + const config: RenovateConfig = { + repository: 'myOrg/myRepo', + reportType: 's3', + reportPath: 's3://bucket-name/key-name', + }; + + addBranchStats(config, branchInformation); + addExtractionStats(config, { branchList: [], branches: [], packageFiles }); + + await exportStats(config); + expect(mockClient.send.mock.calls[0][0]).toMatchObject({ + input: { + Body: JSON.stringify(expectedReport), + }, + }); + }); + + it('handle failed parsing of S3 url', async () => { + s3.parseS3Url.mockReturnValue(null); + + const config: RenovateConfig = { + repository: 'myOrg/myRepo', + reportType: 's3', + reportPath: 'aPath', + }; + + addBranchStats(config, branchInformation); + addExtractionStats(config, { branchList: [], branches: [], packageFiles }); + + await exportStats(config); + expect(logger.logger.warn).toHaveBeenCalledWith( + { reportPath: config.reportPath }, + 'Failed to parse s3 URL', + ); + }); + + it('catch exception', async () => { + const config: RenovateConfig = { + repository: 'myOrg/myRepo', + reportType: 'file', + reportPath: './report.json', + }; + + addBranchStats(config, branchInformation); + addExtractionStats(config, { branchList: [], branches: [], packageFiles }); + + fs.writeSystemFile.mockRejectedValue(null); + await expect(exportStats(config)).toResolve(); + }); +}); diff --git a/lib/instrumentation/reporting.ts b/lib/instrumentation/reporting.ts new file mode 100644 index 0000000000..2167212ffb --- /dev/null +++ b/lib/instrumentation/reporting.ts @@ -0,0 +1,97 @@ +import { PutObjectCommand, PutObjectCommandInput } from '@aws-sdk/client-s3'; +import is from '@sindresorhus/is'; +import type { RenovateConfig } from '../config/types'; +import { logger } from '../logger'; +import type { BranchCache } from '../util/cache/repository/types'; +import { writeSystemFile } from '../util/fs'; +import { getS3Client, parseS3Url } from '../util/s3'; +import type { ExtractResult } from '../workers/repository/process/extract-update'; +import type { Report } from './types'; + +const report: Report = { + repositories: {}, +}; + +export function addBranchStats( + config: RenovateConfig, + branchesInformation: Partial<BranchCache>[], +): void { + if (is.nullOrUndefined(config.reportType)) { + return; + } + + coerceRepo(config.repository!); + report.repositories[config.repository!].branches = branchesInformation; +} + +export function addExtractionStats( + config: RenovateConfig, + extractResult: ExtractResult, +): void { + if (is.nullOrUndefined(config.reportType)) { + return; + } + + coerceRepo(config.repository!); + report.repositories[config.repository!].packageFiles = + extractResult.packageFiles; +} + +export async function exportStats(config: RenovateConfig): Promise<void> { + try { + if (is.nullOrUndefined(config.reportType)) { + return; + } + + if (config.reportType === 'logging') { + logger.info({ report }, 'Printing report'); + return; + } + + if (config.reportType === 'file') { + const path = config.reportPath!; + await writeSystemFile(path, JSON.stringify(report)); + logger.debug({ path }, 'Writing report'); + return; + } + + if (config.reportType === 's3') { + const s3Url = parseS3Url(config.reportPath!); + if (is.nullOrUndefined(s3Url)) { + logger.warn( + { reportPath: config.reportPath }, + 'Failed to parse s3 URL', + ); + return; + } + + const s3Params: PutObjectCommandInput = { + Bucket: s3Url.Bucket, + Key: s3Url.Key, + Body: JSON.stringify(report), + ContentType: 'application/json', + }; + + const client = getS3Client(); + const command = new PutObjectCommand(s3Params); + await client.send(command); + } + } catch (err) { + logger.warn({ err }, 'Reporting.exportStats() - failure'); + } +} + +export function getReport(): Report { + return structuredClone(report); +} + +function coerceRepo(repository: string): void { + if (!is.undefined(report.repositories[repository])) { + return; + } + + report.repositories[repository] = { + branches: [], + packageFiles: {}, + }; +} diff --git a/lib/instrumentation/types.ts b/lib/instrumentation/types.ts index a753ecb56d..aa29b4ebdb 100644 --- a/lib/instrumentation/types.ts +++ b/lib/instrumentation/types.ts @@ -1,4 +1,6 @@ import type { Attributes, SpanKind } from '@opentelemetry/api'; +import type { PackageFile } from '../modules/manager/types'; +import type { BranchCache } from '../util/cache/repository/types'; /** * The instrumentation decorator parameters. @@ -24,3 +26,12 @@ export interface SpanParameters { */ kind?: SpanKind; } + +export interface Report { + repositories: Record<string, RepoReport>; +} + +interface RepoReport { + branches: Partial<BranchCache>[]; + packageFiles: Record<string, PackageFile[]>; +} diff --git a/lib/util/cache/repository/types.ts b/lib/util/cache/repository/types.ts index 4e52dc617b..7859e03cfa 100644 --- a/lib/util/cache/repository/types.ts +++ b/lib/util/cache/repository/types.ts @@ -83,7 +83,7 @@ export interface BranchCache { */ branchName: string; /** - * Whether the update branch is behind base branh + * Whether the update branch is behind base branch */ isBehindBase?: boolean; /** diff --git a/lib/util/fs/index.spec.ts b/lib/util/fs/index.spec.ts index 13c1757168..f8e71562ab 100644 --- a/lib/util/fs/index.spec.ts +++ b/lib/util/fs/index.spec.ts @@ -33,6 +33,7 @@ import { rmCache, statLocalFile, writeLocalFile, + writeSystemFile, } from '.'; jest.mock('../exec/env'); @@ -480,6 +481,14 @@ describe('util/fs/index', () => { }); }); + describe('writeSystemFile', () => { + it('writes file', async () => { + const path = `${tmpDir}/file.txt`; + await writeSystemFile(path, 'foobar'); + expect(await readSystemFile(path)).toEqual(Buffer.from('foobar')); + }); + }); + describe('getLocalFiles', () => { it('reads list of files from local fs', async () => { const fileContentMap = { diff --git a/lib/util/fs/index.ts b/lib/util/fs/index.ts index 0d34ea3a09..14930c03b7 100644 --- a/lib/util/fs/index.ts +++ b/lib/util/fs/index.ts @@ -302,6 +302,13 @@ export function readSystemFile( return encoding ? fs.readFile(fileName, encoding) : fs.readFile(fileName); } +export async function writeSystemFile( + fileName: string, + data: string | Buffer, +): Promise<void> { + await fs.outputFile(fileName, data); +} + export async function getLocalFiles( fileNames: string[], ): Promise<Record<string, string | null>> { diff --git a/lib/workers/global/index.ts b/lib/workers/global/index.ts index cd285c06ee..e9b4f5abc6 100644 --- a/lib/workers/global/index.ts +++ b/lib/workers/global/index.ts @@ -16,6 +16,7 @@ import type { import { CONFIG_PRESETS_INVALID } from '../../constants/error-messages'; import { pkg } from '../../expose.cjs'; import { instrument } from '../../instrumentation'; +import { exportStats } from '../../instrumentation/reporting'; import { getProblems, logger, setMeta } from '../../logger'; import { setGlobalLogLevelRemaps } from '../../logger/remap'; import * as hostRules from '../../util/host-rules'; @@ -210,6 +211,8 @@ export async function start(): Promise<number> { }, ); } + + await exportStats(config); } catch (err) /* istanbul ignore next */ { if (err.message.startsWith('Init: ')) { logger.fatal(err.message.substring(6)); diff --git a/lib/workers/repository/finalize/repository-statistics.ts b/lib/workers/repository/finalize/repository-statistics.ts index 28c795a662..36d450c929 100644 --- a/lib/workers/repository/finalize/repository-statistics.ts +++ b/lib/workers/repository/finalize/repository-statistics.ts @@ -1,4 +1,5 @@ import type { RenovateConfig } from '../../../config/types'; +import { addBranchStats } from '../../../instrumentation/reporting'; import { logger } from '../../../logger'; import type { Pr } from '../../../modules/platform'; import { getCache, isCacheModified } from '../../../util/cache/repository'; @@ -152,6 +153,7 @@ export function runBranchSummary(config: RenovateConfig): void { if (branches?.length) { const branchesInformation = filterDependencyDashboardData(branches); + addBranchStats(config, branchesInformation); logger.debug({ branchesInformation }, 'branches info extended'); } } diff --git a/lib/workers/repository/index.ts b/lib/workers/repository/index.ts index fa7fa880e8..f9d30bcf78 100644 --- a/lib/workers/repository/index.ts +++ b/lib/workers/repository/index.ts @@ -9,6 +9,7 @@ import { } from '../../constants/error-messages'; import { pkg } from '../../expose.cjs'; import { instrument } from '../../instrumentation'; +import { addExtractionStats } from '../../instrumentation/reporting'; import { logger, setMeta } from '../../logger'; import { resetRepositoryLogLevelRemaps } from '../../logger/remap'; import { removeDanglingContainers } from '../../util/exec/docker'; @@ -64,9 +65,13 @@ export async function renovateRepository( config.repoIsOnboarded! || !OnboardingState.onboardingCacheValid || OnboardingState.prUpdateRequested; - const { branches, branchList, packageFiles } = performExtract + const extractResult = performExtract ? await instrument('extract', () => extractDependencies(config)) : emptyExtract(config); + addExtractionStats(config, extractResult); + + const { branches, branchList, packageFiles } = extractResult; + if (config.semanticCommits === 'auto') { config.semanticCommits = await detectSemanticCommits(); } diff --git a/test/s3.ts b/test/s3.ts new file mode 100644 index 0000000000..85f94122e7 --- /dev/null +++ b/test/s3.ts @@ -0,0 +1,4 @@ +import { jest } from '@jest/globals'; +import * as _s3 from '../lib/util/s3'; + +export const s3 = jest.mocked(_s3); -- GitLab