feat: lots of stuff

This commit is contained in:
Morten Olsen
2025-10-16 00:23:18 +02:00
parent 521ffd395f
commit 8e594d59fd
30 changed files with 1739 additions and 31 deletions

70
src/k8s/k8s.clients.ts Normal file
View File

@@ -0,0 +1,70 @@
import { KubernetesObjectApi, PatchStrategy, type KubeConfig, type KubernetesObject } from '@kubernetes/client-node';
import type { K8sResources } from './k8s.resources.ts';
import type { K8sBackboneClient } from './k8s.schemas.ts';
import type { AccessProvider } from '#root/access/access.provider.ts';
import type { Statement } from '#root/access/access.schemas.ts';
type K8sClientsOptions = {
config: KubeConfig;
resources: K8sResources;
};
type K8sClient = {
statements: Statement[];
};
class K8sClients implements AccessProvider {
#options: K8sClientsOptions;
#clients: Map<string, K8sClient>;
constructor(options: K8sClientsOptions) {
this.#clients = new Map();
this.#options = options;
const { clients } = options.resources;
clients.on('updated', this.#handleClientAdded);
}
#handleClientAdded = async (manifest: KubernetesObject & { spec: K8sBackboneClient }) => {
const { resources, config } = this.#options;
const secretName = `${manifest.metadata?.name}-secret`;
const secret = resources.secrets.manifests.find(
(m) => m.metadata?.namespace === manifest.metadata?.namespace && m.metadata?.name === secretName,
);
const token = secret?.data?.token || crypto.randomUUID();
if (!secret) {
const objectsApi = config.makeApiClient(KubernetesObjectApi);
const body = {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: secretName,
namespace: manifest.metadata?.namespace,
},
data: {
token: Buffer.from(token).toString('base64'),
},
};
await objectsApi.create(body, undefined, undefined, undefined, undefined);
}
if (!token) {
throw new Error('Secret is missing token');
}
const tokenValue = Buffer.from(token, 'base64').toString('utf8');
this.#clients.set(tokenValue, {
statements: manifest.spec.statements,
});
};
public getAccess = async (token: string) => {
const client = this.#clients.get(token);
if (!client) {
throw new Error('invalid credentials');
}
return client;
};
}
export { K8sClients };

71
src/k8s/k8s.crd.ts Normal file
View File

@@ -0,0 +1,71 @@
import { type KubeConfig, ApiException, ApiextensionsV1Api } from '@kubernetes/client-node';
import { z, type ZodType } from 'zod';
type CreateCrdOptions = {
config: KubeConfig;
kind: string;
apiVersion: string;
plural?: string;
scope: 'Cluster' | 'Namespaced';
spec: ZodType;
};
const createCrd = async (options: CreateCrdOptions) => {
const { config, ...definition } = options;
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 { createCrd };

49
src/k8s/k8s.resources.ts Normal file
View File

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

19
src/k8s/k8s.schemas.ts Normal file
View File

@@ -0,0 +1,19 @@
import { z } from 'zod';
import { statementSchema } from '#root/access/access.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 };

52
src/k8s/k8s.ts Normal file
View File

@@ -0,0 +1,52 @@
import { KubeConfig } from '@kubernetes/client-node';
import { K8sResources } from './k8s.resources.ts';
import { createCrd } from './k8s.crd.ts';
import { k8sBackboneClientSchema, k8sBackboneTopicSchema } from './k8s.schemas.ts';
import { K8sClients } from './k8s.clients.ts';
import { API_VERSION } from '#root/utils/consts.ts';
class K8sService {
#config: KubeConfig;
#resources: K8sResources;
#clients: K8sClients;
constructor() {
this.#config = new KubeConfig();
this.#config.loadFromDefault();
this.#resources = new K8sResources(this.#config);
this.#clients = new K8sClients({
config: this.#config,
resources: this.resources,
});
}
public get resources() {
return this.#resources;
}
public get clients() {
return this.#clients;
}
public setup = async () => {
await createCrd({
config: this.#config,
apiVersion: API_VERSION,
kind: 'Client',
scope: 'Namespaced',
spec: k8sBackboneClientSchema,
});
await createCrd({
config: this.#config,
apiVersion: API_VERSION,
kind: 'Topic',
scope: 'Namespaced',
spec: k8sBackboneTopicSchema,
});
await this.#resources.start();
};
}
export { K8sService };

87
src/k8s/k8s.watcher.ts Normal file
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 };