Skip to content

Commit

Permalink
Merge pull request #534 from kubeshop/razvantopliceanu/feat/test-crd
Browse files Browse the repository at this point in the history
feat: crd x-kubernetes-validation
  • Loading branch information
topliceanurazvan authored Oct 2, 2023
2 parents 3aaf736 + a2f0b07 commit 33b0780
Show file tree
Hide file tree
Showing 9 changed files with 306 additions and 33 deletions.
5 changes: 5 additions & 0 deletions .changeset/nasty-stingrays-explode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@monokle/validation": patch
---

Added crds x-kubernetes-validation validate of custom resources
28 changes: 15 additions & 13 deletions packages/validation/src/MonokleValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export class MonokleValidator implements Validator {
_previousPluginsInit?: Record<string, boolean>;
_plugins: Plugin[] = [];
_failedPlugins: string[] = [];
_customSchemas: Set<string> = new Set();
_customSchemas: Record<string, CustomSchema> = {};
_suppressions: Suppression[] = [];
private _suppressor: SuppressEngine;

Expand Down Expand Up @@ -234,7 +234,7 @@ export class MonokleValidator implements Validator {
await nextTick();
throwIfAborted(loadAbortSignal, externalAbortSignal);

this.preprocessCustomResourceDefinitions(resources);
await this.preprocessCustomResourceDefinitions(resources);

const allRuns = await Promise.allSettled(validators.map(v => v.validate(resources, {incremental})));
throwIfAborted(loadAbortSignal, externalAbortSignal);
Expand Down Expand Up @@ -325,29 +325,30 @@ export class MonokleValidator implements Validator {
}
}

private preprocessCustomResourceDefinitions(resources: Resource[]) {
private async preprocessCustomResourceDefinitions(resources: Resource[]) {
const crds = resources.filter(r => r.kind === 'CustomResourceDefinition');

for (const crd of crds) {
const spec = crd.content.spec;
const kind = spec?.names?.kind;
const apiVersion = findDefaultVersion(crd.content);

const apiVersion = findDefaultVersion(crd);

if (!apiVersion) {
continue;
}

const schema = extractSchema(crd.content, apiVersion);
const schema = extractSchema(crd, apiVersion);

if (!schema) {
continue;
}

this.registerCustomSchema({kind, apiVersion, schema});
await this.registerCustomSchema({kind, apiVersion: `${crd.content.spec.group}/${apiVersion}`, schema}, crd);
}
}

async registerCustomSchema(schema: CustomSchema) {
async registerCustomSchema(schema: CustomSchema, crd?: Resource) {
if (!this.isPluginLoaded('kubernetes-schema')) {
this.debug('Cannot register custom schema.', {
reason: 'Kubernetes Schema plugin must be loaded.',
Expand All @@ -356,7 +357,8 @@ export class MonokleValidator implements Validator {
}

const key = `${schema.apiVersion}-${schema.kind}`;
if (this._customSchemas.has(key)) {

if (this._customSchemas[key] && isEqual(this._customSchemas[key], schema)) {
this.debug('Cannot register custom schema.', {
reason: 'The schema is already registered.',
});
Expand All @@ -368,10 +370,10 @@ export class MonokleValidator implements Validator {
const otherPlugins = this.getPlugins().filter(p => p.metadata.name !== 'kubernetes-schema');

for (const plugin of [kubernetesSchemaPlugin, ...otherPlugins]) {
await plugin?.registerCustomSchema(schema);
await plugin?.registerCustomSchema(schema, plugin.metadata.name === 'admission-policy' ? crd : undefined);
}

this._customSchemas.add(key);
this._customSchemas[key] = schema;
}

async unregisterCustomSchema(schema: Omit<CustomSchema, 'schema'>) {
Expand All @@ -383,7 +385,7 @@ export class MonokleValidator implements Validator {
}

const key = `${schema.apiVersion}-${schema.kind}`;
if (this._customSchemas.has(key)) {
if (this._customSchemas[key] && isEqual(this._customSchemas[key], schema)) {
this.debug('Cannot register custom schema.', {
reason: 'The schema is not registered.',
});
Expand All @@ -398,7 +400,7 @@ export class MonokleValidator implements Validator {
await plugin?.unregisterCustomSchema(schema);
}

this._customSchemas.delete(key);
delete this._customSchemas[key];
}

/**
Expand All @@ -413,7 +415,7 @@ export class MonokleValidator implements Validator {
*/
async unload(): Promise<void> {
this.cancelLoad('unload');
for (const schema of this._customSchemas) {
for (const schema of Object.keys(this._customSchemas)) {
const [apiVersion, kind] = schema.split('-');
await this.unregisterCustomSchema({apiVersion, kind});
}
Expand Down
24 changes: 18 additions & 6 deletions packages/validation/src/__tests__/MonokleValidator.vap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
PARAMS_VALIDATING_ADMISSION_POLICY,
PARAMS_VALIDATING_ADMISSION_POLICY_BINDING,
} from './resources/admissionPolicy/ParamsValidatorResources.js';
import {CRD, CRD_2, CUSTOM_RESOURCE} from './resources/admissionPolicy/CRDValidatorResources.js';

it('test basic admission policy', async () => {
const parser = new ResourceParser();
Expand Down Expand Up @@ -47,18 +48,31 @@ it('test params admission policy', async () => {

const response = await validator.validate({
resources: [
PARAMS_CONFIG_MAP,
PARAMS_DEPLOYMENT,
PARAMS_NAMESPACE,
PARAMS_VALIDATING_ADMISSION_POLICY,
PARAMS_VALIDATING_ADMISSION_POLICY_BINDING,
PARAMS_DEPLOYMENT,
PARAMS_CONFIG_MAP,
],
});

const hasErrors = response.runs.reduce((sum, r) => sum + r.results.length, 0);
expect(hasErrors).toBe(1);
});

it('test crd admission policy', async () => {
const parser = new ResourceParser();

const validator = createTestValidator(parser);

const response = await validator.validate({
resources: [CRD, CUSTOM_RESOURCE],
});

const hasErrors = response.runs.reduce((sum, r) => sum + r.results.length, 0);
expect(hasErrors).toBe(1);
});

function createTestValidator(parser: ResourceParser, config?: ValidationConfig) {
return new MonokleValidator(
{
Expand All @@ -70,10 +84,8 @@ function createTestValidator(parser: ResourceParser, config?: ValidationConfig)
},
config ?? {
plugins: {
'yaml-syntax': true,
'resource-links': true,
'kubernetes-schema': true,
'open-policy-agent': true,
'admission-policy': true,
'kubernetes-schema': false,
},
settings: {
'kubernetes-schema': {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import {Resource} from '../../../common/types.js';

export const CRD: Resource = {
fileId: 'd4fc87d28c6bd',
filePath: 'crd.yaml',
fileOffset: 0,
text: 'apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n name: mycustomresources.example.com\nspec:\n group: example.com\n names:\n kind: MyCustomResource\n plural: mycustomresources\n singular: mycustomresource\n shortNames:\n - mcr\n scope: Namespaced\n versions:\n - name: v1\n schema:\n openAPIV3Schema:\n type: object\n properties:\n spec:\n type: object\n x-kubernetes-validation:\n - rule: "self.replicas <= self.maxReplicas"\n message: "replicas must be less than or equal to maxReplicas"\n properties:\n replicas:\n type: integer\n maxReplicas:\n type: integer\n required:\n - replicas\n - maxReplicas\n',
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
content: {
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
metadata: {
name: 'mycustomresources.example.com',
},
spec: {
group: 'example.com',
names: {
kind: 'MyCustomResource',
plural: 'mycustomresources',
singular: 'mycustomresource',
shortNames: ['mcr'],
},
scope: 'Namespaced',
versions: [
{
name: 'v1',
schema: {
openAPIV3Schema: {
type: 'object',
properties: {
spec: {
type: 'object',
'x-kubernetes-validation': [
{
rule: 'self.replicas <= self.maxReplicas',
message: 'replicas must be less than or equal to maxReplicas',
},
],
properties: {
replicas: {
type: 'integer',
},
maxReplicas: {
type: 'integer',
},
},
required: ['replicas', 'maxReplicas'],
},
},
},
},
},
],
},
},
id: 'd4fc87d28c6bd-0',
name: 'mycustomresources.example.com',
};

export const CRD_2: Resource = {
fileId: 'd4fc87d28c6bd',
filePath: 'crd.yaml',
fileOffset: 0,
text: 'apiVersion: apiextensions.k8s.io/v1\nkind: CustomResourceDefinition\nmetadata:\n name: mycustomresources.example.com\nspec:\n group: example.com\n names:\n kind: MyCustomResource\n plural: mycustomresources\n singular: mycustomresource\n shortNames:\n - mcr\n scope: Namespaced\n versions:\n - name: v1\n schema:\n openAPIV3Schema:\n type: object\n properties:\n spec:\n type: object\n x-kubernetes-validation:\n - rule: "self.replicas <= self.maxReplicas"\n message: "replicas must be less than or equal to maxReplicas"\n properties:\n replicas:\n type: integer\n maxReplicas:\n type: integer\n required:\n - replicas\n - maxReplicas\n',
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
content: {
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
metadata: {
name: 'mycustomresources.example.com',
},
spec: {
group: 'example.com',
names: {
kind: 'MyCustomResource',
plural: 'mycustomresources',
singular: 'mycustomresource',
shortNames: ['mcr'],
},
scope: 'Namespaced',
versions: [
{
name: 'v1',
schema: {
openAPIV3Schema: {
type: 'object',
'x-kubernetes-validation': [
{
rule: 'self.spec.replicas <= self.status.maxReplicas',
message: 'replicas must be less than or equal to maxReplicas',
},
],
properties: {
spec: {
type: 'object',
properties: {
replicas: {
type: 'integer',
},
},
required: ['replicas'],
},
status: {
type: 'object',
properties: {
maxReplicas: {
type: 'integer',
},
},
required: ['maxReplicas'],
},
},
},
},
},
],
},
},
id: 'd4fc87d28c6bd-0',
name: 'mycustomresources.example.com',
};

export const CUSTOM_RESOURCE: Resource = {
fileId: '13cf92c8d3181f',
filePath: 'custom-resource.yaml',
fileOffset: 0,
text: 'apiVersion: example.com/v1\nkind: MyCustomResource\nmetadata:\n name: my-new-cron-object\nspec:\n replicas: 20\n maxReplicas: 10\n',
apiVersion: 'example.com/v1',
kind: 'MyCustomResource',
content: {
apiVersion: 'example.com/v1',
kind: 'MyCustomResource',
metadata: {
name: 'my-new-cron-object',
},
spec: {
replicas: 20,
maxReplicas: 10,
},
},
id: '13cf92c8d3181f-0',
name: 'my-new-cron-object',
};
4 changes: 2 additions & 2 deletions packages/validation/src/common/AbstractPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ export abstract class AbstractPlugin implements Plugin {
return Promise.resolve();
}

registerCustomSchema(schema: CustomSchema): void | Promise<void> {
return;
registerCustomSchema(schema: CustomSchema, crd?: Resource): void | Promise<void> {
return Promise.resolve();
}

unregisterCustomSchema(schema: Omit<CustomSchema, 'schema'>): void | Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion packages/validation/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ export interface Plugin {
*/
configure(config: {rules?: RuleMap; settings?: any}): Promise<void>;

registerCustomSchema(schema: CustomSchema): Promise<void> | void;
registerCustomSchema(schema: CustomSchema, crd?: Resource): Promise<void> | void;
unregisterCustomSchema(schema: Omit<CustomSchema, 'schema'>): Promise<void> | void;

validate(resources: Resource[], options: ValidateOptions): Promise<ValidationResult[]>;
Expand Down
3 changes: 3 additions & 0 deletions packages/validation/src/validators/admission-policy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export type PolicyBindingFilterResponse = Record<
string,
{resources: Resource[]; level: RuleLevel; paramRef?: ParamRef}
>;

// here the key of the record will be of type apiGroup/apiVersion#kind
export type CRDExpressions = Record<string, Record<string, Expression[]>>;
42 changes: 42 additions & 0 deletions packages/validation/src/validators/admission-policy/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {CustomSchema, Resource} from '../../common/types.js';
import {Expression} from './types.js';

export function getCRDExpressions(schema: CustomSchema, crd: Resource): Record<string, Expression[]> {
const expressions: Record<string, Expression[]> = {};

const versions = crd.content?.spec?.versions;

if (!versions?.length) return expressions;

const [, apiName] = schema.apiVersion.split('/');

const version = versions.find((v: any) => v.name === apiName);

if (!version) return expressions;

const rootRules = version.schema?.openAPIV3Schema?.['x-kubernetes-validation'];

if (rootRules?.length) {
expressions['<root>'] = [];

for (const rule of rootRules) {
expressions['<root>'].push({expression: rule.rule, message: rule.message ?? ''});
}
}

const properties = version.schema?.openAPIV3Schema?.properties as Record<string, any>;

if (!properties) return expressions;

for (const [key, values] of Object.entries(properties)) {
if (values['x-kubernetes-validation']?.length) {
expressions[key] = [];

for (const rule of values['x-kubernetes-validation']) {
expressions[key].push({expression: rule.rule, message: rule.message ?? ''});
}
}
}

return expressions;
}
Loading

0 comments on commit 33b0780

Please sign in to comment.