Introduce Lighthouse Backend with Scheduling of Audits

Signed-off-by: Dominik Pfaffenbauer <dominik@pfaffenbauer.at>
This commit is contained in:
Dominik Pfaffenbauer
2023-01-19 08:59:01 +01:00
parent 49ea9cf086
commit eef62546ce
43 changed files with 1367 additions and 190 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+70
View File
@@ -0,0 +1,70 @@
# Lighthouse Backend
Lighthouse Backend allows you to run scheduled lighthouse Tests for each Website with the annotation `lighthouse.com/website-url`.
## Setup
1. Install the plugin using:
```bash
# From your Backstage root directory
yarn add --cwd packages/backend @backstage/lighthouse-backend
```
2. Create a `lighthouse.ts` file inside `packages/backend/src/plugins/`:
```typescript
import { Router } from 'express';
import { create } from '@backstage/plugin-lighthouse-backend';
import { PluginEnvironment } from '../types';
import { CatalogClient } from '@backstage/catalog-client';
export default async function createPlugin(env: PluginEnvironment) {
const { logger, scheduler, config } = env;
const catalogClient = new CatalogClient({
discoveryApi: env.discovery,
});
await create({ logger, scheduler, config, catalogClient });
}
```
3. Modify your `packages/backend/src/index.ts` to include:
```diff
...
import { Config } from '@backstage/config';
import app from './plugins/app';
+import lighthouse from './plugins/lighthouse';
import scaffolder from './plugins/scaffolder';
...
async function main() {
...
const authEnv = useHotMemoize(module, () => createEnv('auth'));
+ const lighthouseEnv = useHotMemoize(module, () => createEnv('lighthouse'));
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
...
const apiRouter = Router();
apiRouter.use('/catalog', await catalog(catalogEnv));
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
+ await lighthouse(lighthouseEnv)
```
## Configuration
You can define how often and when the scheduler should run the audits:
```yaml
lighthouse:
schedule:
days: 1
```
+286
View File
@@ -0,0 +1,286 @@
## API Report File for "@backstage/plugin-kubernetes-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Entity } from '@backstage/catalog-model';
import type { JsonObject } from '@backstage/types';
import { PodStatus } from '@kubernetes/client-node';
import { V1ConfigMap } from '@kubernetes/client-node';
import { V1CronJob } from '@kubernetes/client-node';
import { V1DaemonSet } from '@kubernetes/client-node';
import { V1Deployment } from '@kubernetes/client-node';
import { V1HorizontalPodAutoscaler } from '@kubernetes/client-node';
import { V1Ingress } from '@kubernetes/client-node';
import { V1Job } from '@kubernetes/client-node';
import { V1LimitRange } from '@kubernetes/client-node';
import { V1Pod } from '@kubernetes/client-node';
import { V1ReplicaSet } from '@kubernetes/client-node';
import { V1Service } from '@kubernetes/client-node';
import { V1StatefulSet } from '@kubernetes/client-node';
// @public (undocumented)
export type AuthProviderType = 'google' | 'serviceAccount' | 'aws' | 'azure';
// @public (undocumented)
export interface ClientContainerStatus {
// (undocumented)
container: string;
// (undocumented)
cpuUsage: ClientCurrentResourceUsage;
// (undocumented)
memoryUsage: ClientCurrentResourceUsage;
}
// @public (undocumented)
export interface ClientCurrentResourceUsage {
// (undocumented)
currentUsage: number | string;
// (undocumented)
limitTotal: number | string;
// (undocumented)
requestTotal: number | string;
}
// @public (undocumented)
export interface ClientPodStatus {
// (undocumented)
containers: ClientContainerStatus[];
// (undocumented)
cpu: ClientCurrentResourceUsage;
// (undocumented)
memory: ClientCurrentResourceUsage;
// (undocumented)
pod: V1Pod;
}
// @public (undocumented)
export interface ClusterAttributes {
dashboardApp?: string;
dashboardParameters?: JsonObject;
dashboardUrl?: string;
name: string;
}
// @public (undocumented)
export interface ClusterObjects {
// (undocumented)
cluster: ClusterAttributes;
// (undocumented)
errors: KubernetesFetchError[];
// (undocumented)
podMetrics: ClientPodStatus[];
// (undocumented)
resources: FetchResponse[];
}
// @public (undocumented)
export interface ConfigMapFetchResponse {
// (undocumented)
resources: Array<V1ConfigMap>;
// (undocumented)
type: 'configmaps';
}
// @public (undocumented)
export interface CronJobsFetchResponse {
// (undocumented)
resources: Array<V1CronJob>;
// (undocumented)
type: 'cronjobs';
}
// @public (undocumented)
export interface CustomObjectsByEntityRequest {
// (undocumented)
auth: KubernetesRequestAuth;
// (undocumented)
customResources: CustomResourceMatcher[];
// (undocumented)
entity: Entity;
}
// @public (undocumented)
export interface CustomResourceFetchResponse {
// (undocumented)
resources: Array<any>;
// (undocumented)
type: 'customresources';
}
// @public (undocumented)
export interface CustomResourceMatcher {
// (undocumented)
apiVersion: string;
// (undocumented)
group: string;
// (undocumented)
plural: string;
}
// @public (undocumented)
export interface DaemonSetsFetchResponse {
// (undocumented)
resources: Array<V1DaemonSet>;
// (undocumented)
type: 'daemonsets';
}
// @public (undocumented)
export interface DeploymentFetchResponse {
// (undocumented)
resources: Array<V1Deployment>;
// (undocumented)
type: 'deployments';
}
// @public (undocumented)
export type FetchResponse =
| PodFetchResponse
| ServiceFetchResponse
| ConfigMapFetchResponse
| DeploymentFetchResponse
| LimitRangeFetchReponse
| ReplicaSetsFetchResponse
| HorizontalPodAutoscalersFetchResponse
| JobsFetchResponse
| CronJobsFetchResponse
| IngressesFetchResponse
| CustomResourceFetchResponse
| StatefulSetsFetchResponse
| DaemonSetsFetchResponse
| PodStatusFetchResponse;
// @public (undocumented)
export interface HorizontalPodAutoscalersFetchResponse {
// (undocumented)
resources: Array<V1HorizontalPodAutoscaler>;
// (undocumented)
type: 'horizontalpodautoscalers';
}
// @public (undocumented)
export interface IngressesFetchResponse {
// (undocumented)
resources: Array<V1Ingress>;
// (undocumented)
type: 'ingresses';
}
// @public (undocumented)
export interface JobsFetchResponse {
// (undocumented)
resources: Array<V1Job>;
// (undocumented)
type: 'jobs';
}
// @public (undocumented)
export type KubernetesErrorTypes =
| 'BAD_REQUEST'
| 'UNAUTHORIZED_ERROR'
| 'NOT_FOUND'
| 'SYSTEM_ERROR'
| 'UNKNOWN_ERROR';
// @public (undocumented)
export type KubernetesFetchError = StatusError | RawFetchError;
// @public (undocumented)
export interface KubernetesRequestAuth {
// (undocumented)
google?: string;
// (undocumented)
oidc?: {
[key: string]: string;
};
}
// @public (undocumented)
export interface KubernetesRequestBody {
// (undocumented)
auth?: KubernetesRequestAuth;
// (undocumented)
entity: Entity;
}
// @public (undocumented)
export interface LimitRangeFetchReponse {
// (undocumented)
resources: Array<V1LimitRange>;
// (undocumented)
type: 'limitranges';
}
// @public (undocumented)
export interface ObjectsByEntityResponse {
// (undocumented)
items: ClusterObjects[];
}
// @public (undocumented)
export interface PodFetchResponse {
// (undocumented)
resources: Array<V1Pod>;
// (undocumented)
type: 'pods';
}
// @public (undocumented)
export interface PodStatusFetchResponse {
// (undocumented)
resources: Array<PodStatus>;
// (undocumented)
type: 'podstatus';
}
// @public (undocumented)
export interface RawFetchError {
// (undocumented)
errorType: 'FETCH_ERROR';
// (undocumented)
message: string;
}
// @public (undocumented)
export interface ReplicaSetsFetchResponse {
// (undocumented)
resources: Array<V1ReplicaSet>;
// (undocumented)
type: 'replicasets';
}
// @public (undocumented)
export interface ServiceFetchResponse {
// (undocumented)
resources: Array<V1Service>;
// (undocumented)
type: 'services';
}
// @public (undocumented)
export interface StatefulSetsFetchResponse {
// (undocumented)
resources: Array<V1StatefulSet>;
// (undocumented)
type: 'statefulsets';
}
// @public (undocumented)
export interface StatusError {
// (undocumented)
errorType: KubernetesErrorTypes;
// (undocumented)
resourcePath?: string;
// (undocumented)
statusCode?: number;
}
// @public (undocumented)
export interface WorkloadsByEntityRequest {
// (undocumented)
auth: KubernetesRequestAuth;
// (undocumented)
entity: Entity;
}
```
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@backstage/plugin-lighthouse-backend",
"description": "Backend functionalities for lighthouse",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "common-library"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/lighthouse-backend"
},
"keywords": [
"lighthouse"
],
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean"
},
"bugs": {
"url": "https://github.com/backstage/backstage/issues"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-lighthouse-common": "workspace:^",
"@backstage/types": "workspace:^",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "workspace:^"
},
"jest": {
"roots": [
".."
]
}
}
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config } from '@backstage/config';
import { HumanDuration as HumanDuration } from '@backstage/types';
export interface LighthouseAuditScheduleConfig {
schedule: HumanDuration;
timeout: HumanDuration;
auditDetail: HumanDuration;
}
/** @public */
export type LighthouseAuditSchedule = {
getSchedule: () => HumanDuration;
getTimeout: () => HumanDuration;
};
/** @public */
export class LighthouseAuditScheduleImpl implements LighthouseAuditSchedule {
static fromConfig(config: Config) {
const lighthouse = config.getOptionalConfig('lighthouse');
let schedule: HumanDuration = { days: 1 };
let timeout: HumanDuration = {};
if (lighthouse) {
const scheduleConfig = lighthouse.getOptionalConfig('schedule');
const timeoutConfig = lighthouse.getOptionalConfig('timeout');
if (scheduleConfig) {
schedule = {
milliseconds: scheduleConfig.getOptionalNumber('milliseconds'),
seconds: scheduleConfig.getOptionalNumber('seconds'),
minutes: scheduleConfig.getOptionalNumber('minutes'),
hours: scheduleConfig.getOptionalNumber('hours'),
days: scheduleConfig.getOptionalNumber('days'),
weeks: scheduleConfig.getOptionalNumber('weeks'),
months: scheduleConfig.getOptionalNumber('months'),
years: scheduleConfig.getOptionalNumber('years'),
};
}
if (timeoutConfig) {
timeout = {
milliseconds: timeoutConfig.getOptionalNumber('milliseconds'),
seconds: timeoutConfig.getOptionalNumber('seconds'),
minutes: timeoutConfig.getOptionalNumber('minutes'),
hours: timeoutConfig.getOptionalNumber('hours'),
days: timeoutConfig.getOptionalNumber('days'),
weeks: timeoutConfig.getOptionalNumber('weeks'),
months: timeoutConfig.getOptionalNumber('months'),
years: timeoutConfig.getOptionalNumber('years'),
};
}
}
return new LighthouseAuditScheduleImpl(schedule, timeout);
}
constructor(
private schedule: HumanDuration,
private timeout: HumanDuration,
) {}
getSchedule(): HumanDuration {
return this.schedule;
}
getTimeout(): HumanDuration {
return this.timeout;
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './service/plugin';
@@ -0,0 +1,32 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
CatalogClient,
CATALOG_FILTER_EXISTS,
} from '@backstage/catalog-client';
export async function loadLighthouseEntities(catalogClient: CatalogClient) {
const filter: Record<string, symbol | string> = {
kind: 'Component',
'spec.type': 'website',
['lighthouse.com/website-url']: CATALOG_FILTER_EXISTS,
};
return await catalogClient.getEntities({
filter: [filter],
});
}
@@ -0,0 +1,124 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Logger } from 'winston';
import { PluginTaskScheduler } from '@backstage/backend-tasks';
import {
CATALOG_FILTER_EXISTS,
CatalogClient,
} from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { LighthouseRestApi } from '@backstage/plugin-lighthouse-common';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { LighthouseAuditScheduleImpl } from '../config';
export interface RouterOptions {
logger: Logger;
config: Config;
scheduler?: PluginTaskScheduler;
catalogClient: CatalogClient;
}
export async function create(options: RouterOptions) {
const { logger, scheduler, catalogClient, config } = options;
const lighthouseApi = LighthouseRestApi.fromConfig(config);
const lighthouseAuditConfig = LighthouseAuditScheduleImpl.fromConfig(config);
const formFactorToScreenEmulationMap = {
// the default is mobile, so no need to override
mobile: undefined,
// Values from lighthouse's cli "desktop" preset
// https://github.com/GoogleChrome/lighthouse/blob/a6738e0033e7e5ca308b97c1c36f298b7d399402/lighthouse-core/config/constants.js#L71-L77
desktop: {
mobile: false,
width: 1350,
height: 940,
deviceScaleFactor: 1,
disabled: false,
},
};
logger.info(
`Running with Scheduler Config ${JSON.stringify(
lighthouseAuditConfig.getSchedule(),
)} and timeout ${JSON.stringify(lighthouseAuditConfig.getTimeout())}`,
);
if (scheduler) {
await scheduler.scheduleTask({
id: 'lighthouse_audit',
frequency: lighthouseAuditConfig.getSchedule(),
timeout: lighthouseAuditConfig.getTimeout(),
initialDelay: { minutes: 15 },
fn: async () => {
const filter: Record<string, symbol | string> = {
kind: 'Component',
'spec.type': 'website',
['metadata.annotations.lighthouse.com/website-url']:
CATALOG_FILTER_EXISTS,
};
logger.info('Running Lighthouse Audit Task');
const websitesWithUrl = await catalogClient.getEntities({
filter: [filter],
});
let index = 0;
for (const entity of websitesWithUrl.items) {
const websiteUrl =
entity.metadata.annotations?.['lighthouse.com/website-url'] ?? '';
if (!websiteUrl) {
continue;
}
const controller = new AbortController();
await scheduler.scheduleTask({
id: `lighthouse_audit_${stringifyEntityRef(entity)}`,
frequency: {},
timeout: {},
initialDelay: { minutes: index * 2 },
signal: controller.signal,
fn: async () => {
logger.info(
`Processing Website Url ${websiteUrl} for Entity ${entity.metadata.name}`,
);
await lighthouseApi.triggerAudit({
url: websiteUrl,
options: {
lighthouseConfig: {
settings: {
formFactor: 'mobile',
emulatedFormFactor: 'mobile',
screenEmulation: formFactorToScreenEmulationMap.mobile,
},
},
},
});
logger.info('Stop Scheduled Task');
controller.abort();
},
});
index++;
}
},
});
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};