refact: cleanup

This commit is contained in:
Morten Olsen
2025-10-16 16:43:44 +02:00
parent 7c30e43ef7
commit 9ba5788d20
19 changed files with 61 additions and 65 deletions

View File

@@ -0,0 +1,15 @@
import { KubeConfig } from '@kubernetes/client-node';
class K8sConfig {
#config?: KubeConfig;
public get config() {
if (!this.#config) {
this.#config = new KubeConfig();
this.#config.loadFromDefault();
}
return this.#config;
}
}
export { K8sConfig };

View File

@@ -0,0 +1,82 @@
import { ApiException, ApiextensionsV1Api } from '@kubernetes/client-node';
import { z, type ZodType } from 'zod';
import { K8sConfig } from './k8s.config.ts';
import type { Services } from '#root/utils/services.ts';
type CreateCrdOptions = {
kind: string;
apiVersion: string;
plural?: string;
scope: 'Cluster' | 'Namespaced';
spec: ZodType;
};
class K8sCrds {
#services: Services;
constructor(services: Services) {
this.#services = services;
}
install = async (definition: CreateCrdOptions) => {
const { config } = this.#services.get(K8sConfig);
const plural = definition.plural ?? definition.kind.toLowerCase() + 's';
const [version, group] = definition.apiVersion.split('/').toReversed();
const manifest = {
apiVersion: 'apiextensions.k8s.io/v1',
kind: 'CustomResourceDefinition',
metadata: {
name: `${plural}.${group}`,
},
spec: {
group: group,
names: {
kind: definition.kind,
plural: plural,
singular: definition.kind.toLowerCase(),
},
scope: definition.scope,
versions: [
{
name: version,
served: true,
storage: true,
schema: {
openAPIV3Schema: {
type: 'object',
properties: {
spec: {
...z.toJSONSchema(definition.spec, { io: 'input' }),
$schema: undefined,
additionalProperties: undefined,
} as ExplicitAny,
},
},
},
subresources: {
status: {},
},
},
],
},
};
const extensionsApi = config.makeApiClient(ApiextensionsV1Api);
try {
await extensionsApi.createCustomResourceDefinition({
body: manifest,
});
} catch (error) {
if (error instanceof ApiException && error.code === 409) {
await extensionsApi.patchCustomResourceDefinition({
name: manifest.metadata.name,
body: [{ op: 'replace', path: '/spec', value: manifest.spec }],
});
} else {
throw error;
}
}
};
}
export { K8sCrds };

View File

@@ -0,0 +1,62 @@
import { V1Secret, type KubernetesObject } from '@kubernetes/client-node';
import { K8sWatcher } from './k8s.watcher.ts';
import type { K8sBackboneClient, K8sBackboneTopic } from './k8s.schemas.ts';
import { K8sConfig } from './k8s.config.ts';
import type { Services } from '#root/utils/services.ts';
class K8sResources {
#services: Services;
#secrets?: K8sWatcher<V1Secret>;
#clients?: K8sWatcher<KubernetesObject & { spec: K8sBackboneClient }>;
#topics?: K8sWatcher<KubernetesObject & { spec: K8sBackboneTopic }>;
constructor(services: Services) {
this.#services = services;
}
public get secrets() {
if (!this.#secrets) {
const { config } = this.#services.get(K8sConfig);
this.#secrets = new K8sWatcher({
config,
apiVersion: 'v1',
kind: 'Secret',
});
}
return this.#secrets;
}
public get clients() {
if (!this.#clients) {
const { config } = this.#services.get(K8sConfig);
this.#clients = new K8sWatcher({
config,
apiVersion: 'backbone.mortenolsen.pro/v1',
kind: 'Client',
});
}
return this.#clients;
}
public get topics() {
if (!this.#topics) {
const { config } = this.#services.get(K8sConfig);
this.#topics = new K8sWatcher({
config,
apiVersion: 'backbone.mortenolsen.pro/v1',
kind: 'Topic',
});
}
return this.#topics;
}
public start = async () => {
await this.secrets.start();
await this.clients.start();
await this.topics.start();
};
}
export { K8sResources };

View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
import { statementSchema } from '#root/auth/auth.schemas.ts';
const k8sBackboneClientSchema = z.object({
statements: z.array(statementSchema),
});
type K8sBackboneClient = z.infer<typeof k8sBackboneClientSchema>;
const k8sBackboneTopicSchema = z.object({
matches: z.array(z.string()),
schema: z.record(z.string(), z.object()),
});
type K8sBackboneTopic = z.infer<typeof k8sBackboneTopicSchema>;
export type { K8sBackboneClient, K8sBackboneTopic };
export { k8sBackboneClientSchema, k8sBackboneTopicSchema };

37
src/services/k8s/k8s.ts Normal file
View File

@@ -0,0 +1,37 @@
import { K8sResources } from './k8s.resources.ts';
import { K8sCrds } from './k8s.crd.ts';
import { k8sBackboneClientSchema, k8sBackboneTopicSchema } from './k8s.schemas.ts';
import { API_VERSION } from '#root/utils/consts.ts';
import type { Services } from '#root/utils/services.ts';
class K8sService {
#services: Services;
constructor(services: Services) {
this.#services = services;
}
public get resources() {
return this.#services.get(K8sResources);
}
public setup = async () => {
const crds = this.#services.get(K8sCrds);
await crds.install({
apiVersion: API_VERSION,
kind: 'Client',
scope: 'Namespaced',
spec: k8sBackboneClientSchema,
});
await crds.install({
apiVersion: API_VERSION,
kind: 'Topic',
scope: 'Namespaced',
spec: k8sBackboneTopicSchema,
});
await this.resources.start();
};
}
export { K8sService };

View File

@@ -0,0 +1,87 @@
import {
KubeConfig,
KubernetesObjectApi,
makeInformer,
type Informer,
type KubernetesObject,
} from '@kubernetes/client-node';
import { EventEmitter } from '#root/utils/event-emitter.ts';
type K8sWatcherOptions = {
config: KubeConfig;
apiVersion: string;
plural?: string;
kind: string;
selector?: string;
};
type K8sWatcherEvents<TType extends KubernetesObject> = {
updated: (manifest: TType) => void;
removed: (manifest: TType) => void;
};
class K8sWatcher<TType extends KubernetesObject> extends EventEmitter<K8sWatcherEvents<TType>> {
#options: K8sWatcherOptions;
#informer: Informer<TType>;
#manifests: Map<string, TType>;
constructor(options: K8sWatcherOptions) {
super();
this.#options = options;
this.#manifests = new Map();
this.#informer = this.#setupInformer();
}
public get manifests() {
return Array.from(this.#manifests.values());
}
#setupInformer = () => {
const { config, apiVersion, kind, plural, selector } = this.#options;
const objectApi = config.makeApiClient(KubernetesObjectApi);
const derivedPlural = plural ?? kind.toLowerCase() + 's';
const [version, group] = apiVersion.split('/').toReversed();
const path = group ? `/apis/${group}/${version}/${derivedPlural}` : `/api/${version}/${derivedPlural}`;
const informer = makeInformer<TType>(
config,
path,
async () => {
return objectApi.list(apiVersion, kind);
},
selector,
);
informer.on('add', this.#handleResource.bind(this, 'add'));
informer.on('update', this.#handleResource.bind(this, 'update'));
informer.on('delete', this.#handleResource.bind(this, 'delete'));
informer.on('error', (err) => {
console.log('Watcher failed, will retry in 3 seconds', path, err);
setTimeout(this.start, 3000);
});
return informer;
};
#handleResource = (action: string, manifest: TType) => {
const uid = manifest.metadata?.uid;
if (!uid) {
return;
}
if (action === 'delete') {
this.#manifests.delete(uid);
return this.emit('removed', manifest);
}
this.#manifests.set(uid, manifest);
this.emit('updated', manifest);
};
public start = () => {
return this.#informer.start();
};
public stop = () => {
return this.#informer.stop();
};
}
export { K8sWatcher };

View File

@@ -0,0 +1,23 @@
import type { AuthProvider } from '#root/auth/auth.provider.ts';
class SessionProvider {
#handlers: Map<string, AuthProvider>;
constructor() {
this.#handlers = new Map();
}
public register = (name: string, provider: AuthProvider) => {
this.#handlers.set(name, provider);
};
public validate = (provider: string, token: string) => {
const handler = this.#handlers.get(provider);
if (!handler) {
throw new Error('Provider not available');
}
return handler.getAccess(token);
};
}
export { SessionProvider };

View File

@@ -0,0 +1,34 @@
import { validate } from './sessions.utils.ts';
import type { Statement } from '#root/auth/auth.schemas.ts';
type SessionOptions = {
statements: Statement[];
};
type ValidateOptions = {
action: string;
resource: string;
};
class Session {
#options: SessionOptions;
constructor(options: SessionOptions) {
this.#options = options;
}
public get statements() {
return this.#options.statements;
}
public validate = (options: ValidateOptions) => {
const { statements } = this.#options;
return validate({
...options,
statements,
});
};
}
export { Session };

View File

@@ -0,0 +1,25 @@
import micromatch from 'micromatch';
import type { Statement } from '#root/auth/auth.schemas.ts';
type ValidateOptions = {
action: string;
resource: string;
statements: Statement[];
};
const validate = (options: ValidateOptions) => {
const { statements, resource, action } = options;
const matches = statements.filter(
(statement) => micromatch.isMatch(resource, statement.resources) && micromatch.isMatch(action, statement.actions),
);
if (matches.length === 0) {
return false;
}
if (matches.find((statement) => statement.effect === 'disallow')) {
return false;
}
return true;
};
export { validate };