diff --git a/admission-controller/server/src/kubernetes/resource.service.ts b/admission-controller/server/src/kubernetes/resource.service.ts index 1ee68b4..702fda3 100644 --- a/admission-controller/server/src/kubernetes/resource.service.ts +++ b/admission-controller/server/src/kubernetes/resource.service.ts @@ -6,17 +6,20 @@ import k8s from '@kubernetes/client-node'; export class ResourceService { constructor(private readonly $client: ClientService) {} - listNamespaces() { - return this.$client - .api(k8s.CoreV1Api) - .listNamespace() - .then((res) => res.body.items); + async listNamespaces() { + const res = await this.$client.api(k8s.CoreV1Api).listNamespace(); + return res.body.items; } - getNamespace(name: string) { - return this.$client - .api(k8s.CoreV1Api) - .readNamespace(name) - .then((res) => res.body); + async getNamespace(name: string) { + const res = await this.$client.api(k8s.CoreV1Api).readNamespace(name); + return res.body; + } + + async list(apiVersion: string, kind: string, namespace?: string) { + const res = await this.$client + .api(k8s.KubernetesObjectApi) + .list(apiVersion, kind, namespace); + return res.body.items; } } diff --git a/admission-controller/server/src/policies/policies.service.ts b/admission-controller/server/src/policies/policies.service.ts index 8df5e43..e511ab6 100644 --- a/admission-controller/server/src/policies/policies.service.ts +++ b/admission-controller/server/src/policies/policies.service.ts @@ -24,7 +24,7 @@ import { WatcherService } from '../kubernetes/watcher.service'; export class PoliciesService implements OnModuleInit { private static readonly PLUGIN_BLOCKLIST = ['resource-links']; - private readonly _logger = new Logger(PoliciesService.name); + private readonly log = new Logger(PoliciesService.name); private readonly policyStore = new Map(); // Map private readonly bindingStore = new Map(); // Map @@ -87,7 +87,7 @@ export class PoliciesService implements OnModuleInit { if (!this.validatorStore.has(policy.binding.policyName)) { // This should not happen and means there is a bug in other place in the code. Raise warning and skip. // Do not create validator instance here to keep this function sync and to keep processing time low. - this._logger.warn( + this.log.warn( `Validator not found for policy: ${policy.binding.policyName}`, ); return null; @@ -107,11 +107,9 @@ export class PoliciesService implements OnModuleInit { resource: AdmissionRequestObject, resourceNamespace?: V1Namespace, ): MonokleApplicablePolicy[] { - this._logger.debug({ - policies: this.policyStore.size, - bindings: this.bindingStore.size, - }); - + this.log.debug( + `policies: ${this.policyStore.size}, bindings: ${this.bindingStore.size}`, + ); if (this.bindingStore.size === 0) { return []; } @@ -121,7 +119,7 @@ export class PoliciesService implements OnModuleInit { const policy = this.policyStore.get(binding.spec.policyName); if (!policy) { - this._logger.error('Binding is pointing to missing policy', binding); + this.log.error('Binding is pointing to missing policy', binding); return null; } @@ -150,8 +148,8 @@ export class PoliciesService implements OnModuleInit { private async onPolicy(rawPolicy: MonoklePolicy) { const policy = PoliciesService.postprocess(rawPolicy); - this._logger.log(`Policy change received: ${rawPolicy.metadata!.name}`); - this._logger.verbose({ rawPolicy, policy }); + this.log.log(`Policy change received: ${rawPolicy.metadata!.name}`); + this.log.verbose({ rawPolicy, policy }); this.policyStore.set(rawPolicy.metadata!.name!, policy); @@ -172,7 +170,7 @@ export class PoliciesService implements OnModuleInit { // Run separately (instead of passing config to constructor) to make sure that validator // is ready when 'setupValidator' function call fulfills. await validator.preload(policy.spec); - this._logger.log(`Policy reconciled: ${rawPolicy.metadata!.name}`); + this.log.log(`Policy reconciled: ${rawPolicy.metadata!.name}`); this.validatorStore.set(policy.metadata!.name!, validator); } @@ -184,8 +182,8 @@ export class PoliciesService implements OnModuleInit { private onPolicyRemoval(rawPolicy: MonoklePolicy) { const policy = PoliciesService.postprocess(rawPolicy); - this._logger.log(`Policy removed: ${rawPolicy.metadata!.name}`); - this._logger.verbose({ rawPolicy, policy }); + this.log.log(`Policy removed: ${rawPolicy.metadata!.name}`); + this.log.verbose({ rawPolicy, policy }); this.policyStore.delete(rawPolicy.metadata!.name!); this.validatorStore.delete(policy.metadata!.name!); @@ -200,8 +198,8 @@ export class PoliciesService implements OnModuleInit { ':update', ) private onBinding(rawBinding: MonoklePolicyBinding) { - this._logger.log(`Binding updated: ${rawBinding.metadata!.name}`); - this._logger.verbose({ rawBinding }); + this.log.log(`Binding updated: ${rawBinding.metadata!.name}`); + this.log.verbose({ rawBinding }); this.bindingStore.set(rawBinding.metadata!.name!, rawBinding); } @@ -211,8 +209,8 @@ export class PoliciesService implements OnModuleInit { ':delete', ) private onBindingRemoval(rawBinding: MonoklePolicyBinding) { - this._logger.log(`Binding removed: ${rawBinding.metadata!.name}`); - this._logger.verbose({ rawBinding }); + this.log.log(`Binding removed: ${rawBinding.metadata!.name}`); + this.log.verbose({ rawBinding }); this.bindingStore.delete(rawBinding.metadata!.name!); } @@ -232,7 +230,7 @@ export class PoliciesService implements OnModuleInit { ((resource as any).namespace || resource.metadata.namespace) === undefined; - this._logger.verbose('Checking if resource matches binding', { + this.log.verbose('Checking if resource matches binding', { namespaceMatchLabels, namespaceMatchExpressions, kind, diff --git a/admission-controller/server/src/reporting/reporting.models.ts b/admission-controller/server/src/reporting/reporting.models.ts new file mode 100644 index 0000000..1a37cb2 --- /dev/null +++ b/admission-controller/server/src/reporting/reporting.models.ts @@ -0,0 +1,4 @@ +export type ResourceIdentifier = { + kind: string; + apiVersion: string; +}; diff --git a/admission-controller/server/src/reporting/reporting.module.ts b/admission-controller/server/src/reporting/reporting.module.ts new file mode 100644 index 0000000..5e51563 --- /dev/null +++ b/admission-controller/server/src/reporting/reporting.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { KubernetesModule } from '../kubernetes/kubernetes.module'; +import { ReportingService } from './reporting.service'; +import { PoliciesModule } from '../policies/policies.module'; + +@Module({ + imports: [KubernetesModule, PoliciesModule], + providers: [ReportingService], +}) +export class ReportingModule {} diff --git a/admission-controller/server/src/reporting/reporting.service.ts b/admission-controller/server/src/reporting/reporting.service.ts new file mode 100644 index 0000000..fe90b06 --- /dev/null +++ b/admission-controller/server/src/reporting/reporting.service.ts @@ -0,0 +1,119 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ResourceService } from '../kubernetes/resource.service'; +import { PoliciesService } from '../policies/policies.service'; +import { ResourceIdentifier } from './reporting.models'; +import { Resource } from '@monokle/validation'; +import { V1Namespace } from '@kubernetes/client-node'; + +type ScannedResourceKind = ResourceIdentifier; + +@Injectable() +export class ReportingService implements OnModuleInit { + private static readonly VALIDATOR_RESOURCE_DEFAULTS = { + id: '', + fileId: '', + filePath: '', + fileOffset: 0, + text: '', + }; + + private SCANNED_RESOURCES: ScannedResourceKind[] = [ + { apiVersion: 'apps/v1', kind: 'Deployment' }, + { apiVersion: 'apps/v1', kind: 'StatefulSet' }, + { apiVersion: 'apps/v1', kind: 'DaemonSet' }, + { apiVersion: 'batch/v1', kind: 'CronJob' }, + { apiVersion: 'batch/v1', kind: 'Job' }, + { apiVersion: 'autoscaling/v2', kind: 'HorizontalPodAutoscaler' }, + { apiVersion: 'autoscaling/v1', kind: 'HorizontalPodAutoscaler' }, + { apiVersion: 'v1', kind: 'Pod' }, + { apiVersion: 'v1', kind: 'Service' }, + { apiVersion: 'v1', kind: 'ConfigMap' }, + { apiVersion: 'v1', kind: 'Secret' }, + { apiVersion: 'networking.k8s.io/v1', kind: 'Ingress' }, + { apiVersion: 'networking.k8s.io/v1', kind: 'NetworkPolicy' }, + { apiVersion: 'policy/v1beta1', kind: 'PodSecurityPolicy' }, + { apiVersion: 'rbac.authorization.k8s.io/v1', kind: 'Role' }, + { apiVersion: 'rbac.authorization.k8s.io/v1', kind: 'RoleBinding' }, + { apiVersion: 'v1', kind: 'ServiceAccount' }, + // { apiVersion: 'apiextensions.k8s.io/v1', kind: 'customresourcedefinitions' }, + ]; + + private readonly log = new Logger(ReportingService.name); + + constructor( + private readonly $client: ResourceService, + private readonly $policies: PoliciesService, + ) {} + + // todo: replace with correct trigger / entrypoint + async onModuleInit() { + this.log.log('Starting cluster report.'); + setTimeout( + () => + this.$client.listNamespaces().then(async (namespaces) => { + for (const namespace of namespaces) { + const response = await this.validate(namespace); + this.log.log( + `Namespace ${namespace.metadata!.name} has ${ + response!.runs[0].results.length ?? 'no' + } violations`, + ); + } + }), + 5000, + ); + } + + public async validate(namespace: V1Namespace) { + this.log.debug(`Running scan on namespace ${namespace.metadata!.name}`); + + const validator = this.$policies + .getMatchingValidators(namespace as any) + .at(0); + if (!validator) { + this.log.log( + `No validator found for namespace ${namespace.metadata!.name}`, + ); + return; + } + + const resources = await this.buildInventory(namespace.metadata!.name!); + return await validator.validator.validate({ resources }); + } + + private async buildInventory(namespace: string) { + const inventory = new Set(); + + for (const { apiVersion, kind } of this.SCANNED_RESOURCES) { + const resources = await this.$client + .list(apiVersion, kind, namespace) + .catch((err) => { + // todo: sentry should handle this and report the available API versions for the given kind + // ie: HPA is not available in v2, but in v2beta2 + this.log.warn( + `Failed to list resources for ${apiVersion}/${kind} in namespace ${namespace}`, + ); + return []; + }); + resources.forEach((resource) => { + resource.apiVersion ??= apiVersion; + resource.kind ??= kind; + + inventory.add( + Object.assign( + { + name: resource.metadata!.name ?? '', + apiVersion: resource.apiVersion, + kind: resource.kind, + namespace: resource.metadata?.namespace, + content: resource, + }, + ReportingService.VALIDATOR_RESOURCE_DEFAULTS, + ), + ); + }); + } + + return [...inventory]; + } +} diff --git a/admission-controller/server/src/server.module.ts b/admission-controller/server/src/server.module.ts index 21bcf02..e8b7a81 100644 --- a/admission-controller/server/src/server.module.ts +++ b/admission-controller/server/src/server.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { AdmissionModule } from './admission/admission.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ReportingModule } from './reporting/reporting.module'; @Module({ - imports: [EventEmitterModule.forRoot(), AdmissionModule], + imports: [EventEmitterModule.forRoot(), AdmissionModule, ReportingModule], controllers: [], providers: [], }) diff --git a/admission-controller/synchronizer/tsconfig.json b/admission-controller/synchronizer/tsconfig.json index 37cb71d..83ac392 100644 --- a/admission-controller/synchronizer/tsconfig.json +++ b/admission-controller/synchronizer/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "./dist" /* Specify an output folder for all emitted files. */, "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, - "experimentalDecorators": true + "experimentalDecorators": true, "strict": true /* Enable all strict type-checking options. */ }, "exclude": ["node_modules", "dist"],