kubricate
Version:
A TypeScript framework for building reusable, type-safe Kubernetes infrastructure — without the YAML mess.
120 lines (98 loc) • 3.64 kB
text/typescript
import { createHash } from 'node:crypto';
import { cloneDeep } from 'lodash-es';
import { LABELS } from './constants.js';
export interface MetadataInjectorOptions {
type: 'stack' | 'secret';
kubricateVersion: string;
managedAt?: string;
// Stack fields
stackId?: string;
stackName?: string;
resourceId?: string;
// Secret fields
secretManagerId?: string;
secretManagerName?: string;
/**
* Inject Options to enable/disable to the labels and annotations injected into the resource.
*/
inject?: {
managedAt?: boolean;
resourceHash?: boolean;
version?: boolean;
};
}
export class MetadataInjector {
constructor(private readonly options: MetadataInjectorOptions) {}
inject(resource: Record<string, unknown>): Record<string, unknown> {
if (typeof resource !== 'object' || resource == null) {
return resource;
}
const metadata = this.ensureMetadata(resource);
metadata.labels ??= {};
metadata.annotations ??= {};
metadata.labels[LABELS.kubricate] = 'true';
if (this.options.type === 'stack') {
metadata.labels[LABELS.stackId] = this.options.stackId!;
metadata.annotations[LABELS.stackName] = this.options.stackName!;
metadata.labels[LABELS.resourceId] = this.options.resourceId!;
} else if (this.options.type === 'secret') {
metadata.labels[LABELS.secretManagerId] = this.options.secretManagerId!;
metadata.annotations[LABELS.secretManagerName] = this.options.secretManagerName!;
}
if (this.options.inject?.version) {
metadata.annotations[LABELS.version] = this.options.kubricateVersion;
}
if (this.options.inject?.resourceHash) {
metadata.annotations[LABELS.resourceHash] = this.calculateHash(resource);
}
if (this.options.inject?.managedAt) {
metadata.annotations[LABELS.managedAt] = this.options.managedAt ?? new Date().toISOString();
}
return resource;
}
private ensureMetadata(resource: Record<string, unknown>) {
if (!('metadata' in resource)) {
resource.metadata = {};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metadata = resource.metadata as Record<string, any>;
metadata.labels ??= {};
metadata.annotations ??= {};
return metadata;
}
private calculateHash(resource: Record<string, unknown>): string {
const cleaned = this.cleanForHash(resource);
const sorted = this.sortKeysRecursively(cleaned);
const serialized = JSON.stringify(sorted);
return createHash('sha256').update(serialized).digest('hex');
}
private cleanForHash(resource: Record<string, unknown>): Record<string, unknown> {
const clone = cloneDeep(resource);
if (clone.metadata && typeof clone.metadata === 'object') {
const metadata = clone.metadata as Record<string, unknown>;
delete metadata.creationTimestamp;
delete metadata.resourceVersion;
delete metadata.uid;
delete metadata.selfLink;
delete metadata.generation;
delete metadata.managedFields;
}
return clone;
}
private sortKeysRecursively(obj: unknown): unknown {
if (Array.isArray(obj)) {
return obj.map(item => this.sortKeysRecursively(item));
}
if (obj && typeof obj === 'object') {
return Object.keys(obj)
.sort()
.reduce((acc, key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(acc as any)[key] = this.sortKeysRecursively((obj as any)[key]);
return acc;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}, {} as any);
}
return obj;
}
}