Skip to content

Commit

Permalink
feat(instrumentation/reporting): add report option (#26087)
Browse files Browse the repository at this point in the history
Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
  • Loading branch information
3 people committed Mar 17, 2024
1 parent 8dc9705 commit 481aa21
Show file tree
Hide file tree
Showing 15 changed files with 421 additions and 2 deletions.
19 changes: 19 additions & 0 deletions docs/usage/self-hosted-configuration.md
Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions lib/config/options/index.ts
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions lib/config/types.ts
Expand Up @@ -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;
Expand Down
50 changes: 50 additions & 0 deletions lib/config/validation.spec.ts
Expand Up @@ -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);
});
});
});
15 changes: 15 additions & 0 deletions lib/config/validation.ts
Expand Up @@ -170,7 +170,9 @@ export async function validateConfig(
val,
optionTypes[key],
warnings,
errors,
currentPath,
config,
);
continue;
} else {
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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',
Expand Down
177 changes: 177 additions & 0 deletions 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();
});
});

0 comments on commit 481aa21

Please sign in to comment.