-
Notifications
You must be signed in to change notification settings - Fork 8
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #248 from ESLFACEITGROUP/opa-backend-processor
Feature: OPA Entity Checker Catalog Processor Module - validate all entities always
- Loading branch information
Showing
24 changed files
with
745 additions
and
99 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
--- | ||
'@parsifal-m/plugin-opa-backend': minor | ||
'@parsifal-m/backstage-plugin-opa-entity-checker-processor': patch | ||
--- | ||
|
||
Add a new plugin that implements a catalog entity processor to validate entities during ingestion. The opa-backend was refactored to exposed the entity Checker Api as a service that can be used by other backend plugins. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
plugins/backstage-opa-backend/src/api/EntityCheckerApi.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
import { | ||
countResultByLevel, | ||
determineOverallStatus, | ||
EntityCheckerApiImpl, | ||
OPAResult, | ||
} from './EntityCheckerApi'; | ||
import { mockServices } from '@backstage/backend-test-utils'; | ||
|
||
describe('EntityCheckerApiImpl', () => { | ||
it('should error if OPA package is not set', async () => { | ||
const mockLogger = mockServices.logger.mock(); | ||
const config = mockServices.rootConfig({ | ||
data: { | ||
opaClient: { | ||
baseUrl: 'http://localhost:8181', | ||
}, | ||
}, | ||
}); | ||
|
||
const error = () => { | ||
/* eslint-disable no-new */ | ||
new EntityCheckerApiImpl({ | ||
logger: mockLogger, | ||
opaBaseUrl: config.getOptionalString('opaClient.baseUrl'), | ||
entityCheckerEntrypoint: config.getOptionalString( | ||
'opaClient.policies.entityChecker.entrypoint', | ||
), | ||
}); | ||
}; | ||
|
||
expect(error).toThrow('OPA package not set or missing!'); | ||
}); | ||
|
||
it('should error url if OPA not set', async () => { | ||
const mockLogger = mockServices.logger.mock(); | ||
const config = mockServices.rootConfig({ | ||
data: { | ||
opaClient: { | ||
policies: { | ||
entityChecker: { | ||
entrypoint: 'entityCheckerEntrypoint', | ||
}, | ||
}, | ||
}, | ||
}, | ||
}); | ||
|
||
const error = () => { | ||
/* eslint-disable no-new */ | ||
new EntityCheckerApiImpl({ | ||
logger: mockLogger, | ||
opaBaseUrl: config.getOptionalString('opaClient.baseUrl'), | ||
entityCheckerEntrypoint: config.getOptionalString( | ||
'opaClient.policies.entityChecker.entrypoint', | ||
), | ||
}); | ||
}; | ||
|
||
expect(error).toThrow('OPA URL not set or missing!'); | ||
}); | ||
}); | ||
|
||
describe('countResultByLevel', () => { | ||
it('should count the occurrences of each level', () => { | ||
const results: OPAResult[] = [ | ||
{ level: 'error', message: 'Error message 1' }, | ||
{ level: 'warning', message: 'Warning message 1' }, | ||
{ level: 'error', message: 'Error message 2' }, | ||
{ level: 'info', message: 'Info message 1' }, | ||
{ level: 'warning', message: 'Warning message 2' }, | ||
{ level: 'error', message: 'Error message 3' }, | ||
]; | ||
|
||
const expectedCounts = new Map<string, number>([ | ||
['error', 3], | ||
['warning', 2], | ||
['info', 1], | ||
]); | ||
|
||
const actualCounts = countResultByLevel(results); | ||
|
||
expect(actualCounts).toEqual(expectedCounts); | ||
}); | ||
|
||
it('should return an empty map for an empty array', () => { | ||
const results: OPAResult[] = []; | ||
const expectedCounts = new Map<string, number>(); | ||
const actualCounts = countResultByLevel(results); | ||
expect(actualCounts).toEqual(expectedCounts); | ||
}); | ||
}); | ||
|
||
describe('determineOverallStatus', () => { | ||
it('should return error if count of errors > 0', () => { | ||
const levelCounts = new Map([ | ||
['error', 2], | ||
['warning', 1], | ||
['info', 3], | ||
]); | ||
const priorityOrder = ['error', 'warning', 'info']; | ||
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('error'); | ||
}); | ||
|
||
it('should return "warning" when there are no errors', () => { | ||
const levelCounts = new Map([ | ||
['warning', 1], | ||
['info', 3], | ||
]); | ||
const priorityOrder = ['error', 'warning', 'info']; | ||
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('warning'); | ||
}); | ||
|
||
it('should return "info" when there are no errors nor warnings', () => { | ||
const levelCounts = new Map([['info', 3]]); | ||
const priorityOrder = ['error', 'warning', 'info']; | ||
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('info'); | ||
}); | ||
|
||
it('should return "info" when all counts are 0', () => { | ||
const levelCounts = new Map([ | ||
['error', 0], | ||
['warning', 0], | ||
['info', 0], | ||
]); | ||
const priorityOrder = ['error', 'warning', 'info']; | ||
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('pass'); | ||
}); | ||
|
||
it('should return "pass" when the map is empty', () => { | ||
const levelCounts = new Map(); | ||
const priorityOrder = ['error', 'warning', 'info']; | ||
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('pass'); | ||
}); | ||
|
||
it('should use the provided priority order, let swap things around', () => { | ||
const levelCounts = new Map([ | ||
['error', 1], | ||
['warning', 1], | ||
['info', 1], | ||
]); | ||
const priorityOrder = ['warning', 'error', 'info']; // Different order | ||
expect(determineOverallStatus(levelCounts, priorityOrder)).toBe('warning'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import fetch from 'node-fetch'; | ||
import { LoggerService } from '@backstage/backend-plugin-api'; | ||
import { Entity } from '@backstage/catalog-model'; | ||
|
||
export interface EntityCheckerApi { | ||
checkEntity(options: checkEntityOptions): Promise<OpaEntityCheckResult>; | ||
} | ||
|
||
export type checkEntityOptions = { | ||
entityMetadata: Entity; | ||
}; | ||
|
||
export type EntityCheckerConfig = { | ||
logger: LoggerService; | ||
opaBaseUrl: string | undefined; | ||
entityCheckerEntrypoint: string | undefined; | ||
}; | ||
|
||
export interface OpaEntityCheckResult { | ||
result?: OPAResult[]; | ||
} | ||
|
||
export interface OPAResult { | ||
id?: string; | ||
check_title?: string; | ||
level: 'error' | 'warning' | 'info'; | ||
message: string; | ||
} | ||
|
||
/** | ||
* countResultByLevel is a utility function that can be used tha generate statics about policy results | ||
* @param arr | ||
*/ | ||
export function countResultByLevel(arr: OPAResult[]): Map<string, number> { | ||
return arr.reduce((acc: Map<string, number>, val) => { | ||
const count = acc.get(val.level) || 0; | ||
acc.set(val.level, count + 1); | ||
return acc; | ||
}, new Map<string, number>()); | ||
} | ||
|
||
/** | ||
* determineOverallStatus is meant to be used in concision with countResultByLevel, the status is which ever >0 given a priority list | ||
* @param levelCounts | ||
* @param priorityOrder | ||
*/ | ||
export function determineOverallStatus( | ||
levelCounts: Map<string, number>, | ||
priorityOrder: string[], | ||
): string { | ||
for (const level of priorityOrder) { | ||
if (levelCounts.get(level) && levelCounts.get(level)! > 0) { | ||
return level; | ||
} | ||
} | ||
return 'pass'; // Default to 'pass' | ||
} | ||
|
||
export class EntityCheckerApiImpl implements EntityCheckerApi { | ||
constructor(private readonly config: EntityCheckerConfig) { | ||
const logger = this.config.logger; | ||
|
||
if (!config.opaBaseUrl) { | ||
logger.error('OPA URL not set or missing!'); | ||
throw new Error('OPA URL not set or missing!'); | ||
} | ||
|
||
if (!config.entityCheckerEntrypoint) { | ||
logger.error('OPA package not set or missing!'); | ||
throw new Error('OPA package not set or missing!'); | ||
} | ||
} | ||
|
||
async checkEntity( | ||
options: checkEntityOptions, | ||
): Promise<OpaEntityCheckResult> { | ||
const logger = this.config.logger; | ||
const entityMetadata = options.entityMetadata; | ||
|
||
if (!entityMetadata) { | ||
logger.error('Entity metadata is missing!'); | ||
throw new Error('Entity metadata is missing!'); | ||
} | ||
|
||
const opaUrl = `${this.config.opaBaseUrl}/v1/data/${this.config.entityCheckerEntrypoint}`; | ||
logger.debug( | ||
`Sending entity metadata to OPA: ${JSON.stringify(entityMetadata)}`, | ||
); | ||
return await fetch(opaUrl, { | ||
method: 'POST', | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
body: JSON.stringify({ input: entityMetadata }), | ||
}).then(response => { | ||
return response.json() as Promise<OpaEntityCheckResult>; | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,4 @@ | ||
export * from './service/router'; | ||
export * from './api/EntityCheckerApi'; | ||
export { entityCheckerServiceRef } from './plugin'; | ||
export { opaPlugin as default } from './plugin'; |
Oops, something went wrong.