Thomas Zhang 2 месяцев назад
Родитель
Сommit
2dd5cc3dee

+ 13 - 1
src/lib/server/catalog/catalog.ts

@@ -1,11 +1,14 @@
 import { NotFoundError } from '$lib/server/errors';
 import { type GitClient, LocalClient } from '$lib/server/git';
+import { K8sClient } from '$lib/server/k8s';
 import type { Workspace } from '$lib/types/workspace';
 
 import { type AgentCatalog, GitAgentCatalog } from './agents';
 import { type DocumentCatalog, GitDocumentCatalog } from './documents';
 import { GitKBCatalog, type KBCatalog } from './kbs';
 import { GitModelCatalog, type ModelCatalog } from './models';
+import { K8sSecretCatalog, type SecretCatalog } from './secrets';
+import { K8sStackCatalog, type StackCatalog } from './stacks';
 import { GitToolCatalog, type ToolCatalog } from './tools';
 
 type WorkspaceCatalog = {
@@ -14,6 +17,8 @@ type WorkspaceCatalog = {
 	kbs: KBCatalog;
 	documents: DocumentCatalog;
 	agents: AgentCatalog;
+	stacks: StackCatalog;
+	secrets: SecretCatalog;
 };
 
 class Catalog {
@@ -29,12 +34,17 @@ class Catalog {
 	constructor(workspaces: Workspace[]) {
 		for (const workspace of workspaces) {
 			// TODO: github support
-			if (workspace.persistence.type === 'local') {
+			if (workspace.persistence.type === 'local' && workspace.deployment.type === 'k8s') {
 				const gitClient = new LocalClient({
 					directory: workspace.persistence.directory,
 					authorEmail: workspace.persistence.authorEmail,
 					authorName: workspace.persistence.authorName,
 				});
+				const k8sClient = new K8sClient({
+					apiUrl: workspace.deployment.apiUrl,
+					token: workspace.deployment.token,
+					skipTlsVerify: workspace.deployment.skipTlsVerify ?? false,
+				});
 				this.#workspaces.set(workspace.id, {
 					workspace,
 					gitClient,
@@ -44,6 +54,8 @@ class Catalog {
 						kbs: new GitKBCatalog(gitClient),
 						documents: new GitDocumentCatalog(gitClient),
 						agents: new GitAgentCatalog(gitClient),
+						stacks: new K8sStackCatalog(k8sClient, workspace.deployment.namespace),
+						secrets: new K8sSecretCatalog(k8sClient, workspace.deployment.namespace),
 					},
 				});
 			}

+ 2 - 0
src/lib/server/catalog/secrets/index.ts

@@ -0,0 +1,2 @@
+export { K8sSecretCatalog } from './k8s';
+export type { SecretCatalog } from './types';

+ 80 - 0
src/lib/server/catalog/secrets/k8s.ts

@@ -0,0 +1,80 @@
+import { setHeaderOptions } from '@kubernetes/client-node';
+
+import type { K8sClient } from '$lib/server/k8s';
+import { fromSecretCR, type SecretCR, slugify, toSecretCR } from '$lib/types/custom-resources';
+import type { Secret } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+import type { SecretCatalog } from './types';
+
+class K8sSecretCatalog implements SecretCatalog {
+	#k8sClient: K8sClient;
+	#namespace: string;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#k8sClient = k8sClient;
+		this.#namespace = namespace;
+	}
+
+	async #init(): Promise<void> {
+		await this.#k8sClient.watchResources<SecretCR>({
+			group: '',
+			version: 'v1',
+			resource: 'secrets',
+			namespace: this.#namespace,
+			labelSelector: 'locostack.com/managed-by=loco-admin-web',
+		});
+	}
+
+	async list(): Promise<Secret[]> {
+		await this.#init();
+		const secrets = this.#k8sClient.listResources<SecretCR>('', 'v1', 'secrets', this.#namespace);
+		return secrets.map((s) => fromSecretCR(s));
+	}
+
+	async listChanges(entityId: string): Promise<Changes> {
+		await this.#init();
+		return {
+			gitPullRequests: [],
+			gitCommits: [],
+		};
+	}
+
+	async create(secret: Secret): Promise<{ entity: Secret; cr: SecretCR }> {
+		await this.#init();
+		secret.id = slugify(secret.name);
+		const coreApi = this.#k8sClient.coreApi();
+		const cr = await coreApi.createNamespacedSecret({
+			namespace: this.#namespace,
+			body: toSecretCR(secret),
+		});
+		return { entity: secret, cr };
+	}
+
+	async update(secret: Secret): Promise<{ entity: Secret; cr: SecretCR }> {
+		await this.#init();
+		const coreApi = this.#k8sClient.coreApi();
+		const cr = await coreApi.patchNamespacedSecret(
+			{
+				name: secret.id,
+				namespace: this.#namespace,
+				body: toSecretCR(secret),
+				fieldManager: 'locostack-admin-web',
+				force: true,
+			},
+			setHeaderOptions('Content-Type', 'application/apply-patch+yaml'),
+		);
+		return { entity: secret, cr };
+	}
+
+	async delete(id: string): Promise<void> {
+		await this.#init();
+		const coreApi = this.#k8sClient.coreApi();
+		await coreApi.deleteNamespacedSecret({
+			name: id,
+			namespace: this.#namespace,
+		});
+	}
+}
+
+export { K8sSecretCatalog };

+ 13 - 0
src/lib/server/catalog/secrets/types.ts

@@ -0,0 +1,13 @@
+import type { SecretCR } from '$lib/types/custom-resources';
+import type { Secret } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+interface SecretCatalog {
+	list(): Promise<Secret[]>;
+	listChanges(id: string): Promise<Changes>;
+	create(secret: Secret): Promise<{ entity: Secret; cr: SecretCR }>;
+	update(secret: Secret): Promise<{ entity: Secret; cr: SecretCR }>;
+	delete(id: string): Promise<void>;
+}
+
+export type { SecretCatalog };

+ 2 - 0
src/lib/server/catalog/stacks/index.ts

@@ -0,0 +1,2 @@
+export { K8sStackCatalog } from './k8s';
+export type { StackCatalog } from './types';

+ 93 - 0
src/lib/server/catalog/stacks/k8s.ts

@@ -0,0 +1,93 @@
+import { setHeaderOptions } from '@kubernetes/client-node';
+
+import type { K8sClient } from '$lib/server/k8s';
+import { fromStackCR, slugify, type StackCR, toStackCR } from '$lib/types/custom-resources';
+import type { Stack } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+import type { StackCatalog } from './types';
+
+class K8sStackCatalog implements StackCatalog {
+	#k8sClient: K8sClient;
+	#namespace: string;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#k8sClient = k8sClient;
+		this.#namespace = namespace;
+	}
+
+	async #init(): Promise<void> {
+		await this.#k8sClient.watchResources<StackCR>({
+			group: 'locostack.com',
+			version: 'v1alpha1',
+			resource: 'stacks',
+			namespace: this.#namespace,
+		});
+	}
+
+	async list(): Promise<Stack[]> {
+		await this.#init();
+		const stacks = this.#k8sClient.listResources<StackCR>(
+			'locostack.com',
+			'v1alpha1',
+			'stacks',
+			this.#namespace,
+		);
+		return stacks.map((s) => fromStackCR(s));
+	}
+
+	async listChanges(entityId: string): Promise<Changes> {
+		await this.#init();
+		return {
+			gitPullRequests: [],
+			gitCommits: [],
+		};
+	}
+
+	async create(stack: Stack): Promise<{ entity: Stack; cr: StackCR }> {
+		await this.#init();
+		stack.id = slugify(stack.name);
+		const customObjectsApi = this.#k8sClient.customObjectsApi();
+		const cr = await customObjectsApi.createNamespacedCustomObject({
+			group: 'locostack.com',
+			version: 'v1alpha1',
+			namespace: this.#namespace,
+			plural: 'stacks',
+			body: toStackCR(stack),
+		});
+		return { entity: stack, cr };
+	}
+
+	async update(stack: Stack): Promise<{ entity: Stack; cr: StackCR }> {
+		await this.#init();
+		const customObjectsApi = this.#k8sClient.customObjectsApi();
+		const cr = await customObjectsApi.patchNamespacedCustomObject(
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace: this.#namespace,
+				plural: 'stacks',
+				name: stack.id,
+				body: toStackCR(stack),
+				fieldManager: 'locostack-admin-web',
+				force: true,
+			},
+			setHeaderOptions('Content-Type', 'application/apply-patch+yaml'),
+		);
+		return { entity: stack, cr };
+	}
+
+	async delete(id: string): Promise<void> {
+		await this.#init();
+		const customObjectsApi = this.#k8sClient.customObjectsApi();
+		await customObjectsApi.deleteNamespacedCustomObject({
+			group: 'locostack.com',
+			version: 'v1alpha1',
+			namespace: this.#namespace,
+			plural: 'stacks',
+			name: id,
+		});
+	}
+}
+
+export { K8sStackCatalog };

+ 13 - 0
src/lib/server/catalog/stacks/types.ts

@@ -0,0 +1,13 @@
+import type { StackCR } from '$lib/types/custom-resources';
+import type { Stack } from '$lib/types/entities';
+import type { Changes } from '$lib/types/workspace';
+
+interface StackCatalog {
+	list(): Promise<Stack[]>;
+	listChanges(id: string): Promise<Changes>;
+	create(stack: Stack): Promise<{ entity: Stack; cr: StackCR }>;
+	update(stack: Stack): Promise<{ entity: Stack; cr: StackCR }>;
+	delete(id: string): Promise<void>;
+}
+
+export type { StackCatalog };

+ 2 - 0
src/lib/types/custom-resources/index.ts

@@ -1,5 +1,7 @@
 export * from './agent';
 export * from './kb';
 export * from './model';
+export * from './secret';
+export * from './stack';
 export * from './tool';
 export * from './types';

+ 58 - 0
src/lib/types/custom-resources/secret.ts

@@ -0,0 +1,58 @@
+import type { V1ObjectMeta } from '@kubernetes/client-node';
+import type { V1Secret } from '@kubernetes/client-node';
+
+import type { Secret } from '$lib/types/entities';
+
+type SecretCR = V1Secret;
+
+const secretAnnotation = (key: string, value: string | undefined): { [key: string]: string } =>
+	value
+		? {
+				[`stacks.locosecret.com/${key}`]: value,
+			}
+		: {};
+
+const secretAnnotations = (secret: Secret): { [key: string]: string } => ({
+	...secretAnnotation('name', secret.name),
+});
+
+const readSecretAnnotation = (metadata: V1ObjectMeta, key: string): string | undefined =>
+	metadata.annotations?.[`stacks.locosecret.com/${key}`];
+
+const toSecretCR = (secret: Secret): SecretCR => {
+	return {
+		apiVersion: 'v1',
+		kind: 'Secret',
+		metadata: {
+			name: secret.id,
+			labels: {
+				'locostack.com/managed-by': 'loco-admin-web',
+			},
+			annotations: secretAnnotations(secret),
+		},
+		data: {
+			...Object.fromEntries(
+				secret.items.map((item) => [item.key, Buffer.from(item.value).toString('base64')]),
+			),
+		},
+		type: 'Opaque',
+	};
+};
+
+const fromSecretCR = (cr: SecretCR): Secret => {
+	const data = cr.data;
+	const metadata = cr.metadata;
+	return {
+		id: metadata?.name ?? '',
+		name: readSecretAnnotation(metadata!, 'name') ?? metadata?.name ?? '',
+		items: data
+			? Object.entries(data).map(([key, value]) => ({
+					key,
+					value: Buffer.from(value, 'base64').toString('utf-8'),
+				}))
+			: [],
+	};
+};
+
+export type { SecretCR };
+export { fromSecretCR, toSecretCR };

+ 117 - 0
src/lib/types/custom-resources/stack.ts

@@ -0,0 +1,117 @@
+import type { V1ObjectMeta } from '@kubernetes/client-node';
+
+import type { Stack } from '$lib/types/entities';
+import * as V1alpha1Stack from '$lib/types/locostack.com/v1alpha1/Stack';
+
+import { fromTemplate, toTemplate } from './types';
+
+type StackCR = Omit<V1alpha1Stack.components['schemas']['Stack'], 'metadata'> & {
+	metadata: V1ObjectMeta;
+};
+
+const stackAnnotation = (key: string, value: string | undefined): { [key: string]: string } =>
+	value
+		? {
+				[`stacks.locostack.com/${key}`]: value,
+			}
+		: {};
+
+const stackAnnotations = (stack: Stack): { [key: string]: string } => ({
+	...stackAnnotation('name', stack.name),
+});
+
+const readStackAnnotation = (metadata: V1ObjectMeta, key: string): string | undefined =>
+	metadata.annotations?.[`stacks.locostack.com/${key}`];
+
+const toStackCR = (stack: Stack): StackCR => {
+	const gatewayEnabled = stack.gateway?.enabled ?? true;
+	const vectorStoreEnabled = stack.vectorStore?.enabled ?? true;
+	const graphStoreEnabled = stack.graphStore?.enabled ?? false;
+	const databaseEnabled = stack.database?.enabled ?? true;
+	const observabilityEnabled = stack.observability?.enabled ?? false;
+
+	return {
+		apiVersion: 'locostack.com/v1alpha1',
+		kind: 'Stack',
+		metadata: {
+			name: stack.id,
+			annotations: stackAnnotations(stack),
+		},
+		spec: {
+			...(stack.sharedVolumes ? { sharedVolumes: stack.sharedVolumes } : {}),
+			gateway: {
+				enabled: gatewayEnabled,
+				...(stack.gateway?.runtime ? { template: toTemplate(stack.gateway.runtime) } : {}),
+			},
+			vectorStore: {
+				enabled: vectorStoreEnabled,
+				...(stack.vectorStore?.runtime ? { template: toTemplate(stack.vectorStore.runtime) } : {}),
+			},
+			...(graphStoreEnabled && {
+				graphStore: {
+					enabled: true,
+					...(stack.graphStore?.runtime ? { template: toTemplate(stack.graphStore.runtime) } : {}),
+				},
+			}),
+			database: {
+				enabled: databaseEnabled,
+				...(stack.database?.runtime ? { template: toTemplate(stack.database.runtime) } : {}),
+			},
+			...(observabilityEnabled && {
+				observability: {
+					enabled: true,
+					...(stack.observability?.runtime
+						? { template: toTemplate(stack.observability.runtime) }
+						: {}),
+				},
+			}),
+		},
+	};
+};
+
+const fromStackCR = (cr: StackCR): Stack => {
+	const spec = cr.spec;
+	const metadata = cr.metadata;
+	const id = metadata.name;
+	if (id === undefined) {
+		throw new Error('metadata.name is required');
+	}
+	return {
+		id,
+		name: readStackAnnotation(metadata, 'name') ?? id,
+		sharedVolumes: spec.sharedVolumes,
+		gateway: spec.gateway
+			? {
+					enabled: spec.gateway.enabled ?? false,
+					runtime: spec.gateway.template && fromTemplate(spec.gateway.template),
+				}
+			: undefined,
+		vectorStore: spec.vectorStore
+			? {
+					enabled: spec.vectorStore.enabled ?? false,
+					runtime: spec.vectorStore.template && fromTemplate(spec.vectorStore.template),
+				}
+			: undefined,
+		graphStore: spec.graphStore
+			? {
+					enabled: spec.graphStore.enabled ?? false,
+					runtime: spec.graphStore.template && fromTemplate(spec.graphStore.template),
+				}
+			: undefined,
+		database: spec.database
+			? {
+					enabled: spec.database.enabled ?? false,
+					runtime: spec.database.template && fromTemplate(spec.database.template),
+				}
+			: undefined,
+		observability: spec.observability
+			? {
+					enabled: spec.observability.enabled ?? false,
+					runtime: spec.observability.template && fromTemplate(spec.observability.template),
+				}
+			: undefined,
+	};
+};
+
+export type { StackCR };
+export { fromStackCR, toStackCR };