فهرست منبع

feat: add workload resources

Thomas Zhang 1 ماه پیش
والد
کامیت
50e7edd4f8

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

@@ -13,5 +13,7 @@ export {
 	ValidationError,
 	wrapInfraError,
 } from './errors';
+export type { K8sClientConfig, WatchedResource } from './k8s';
+export { K8sClient } from './k8s';
 export * from './logger';
 export { Server, server } from './server';

+ 420 - 0
src/lib/server/k8s.ts

@@ -0,0 +1,420 @@
+/**
+ * Kubernetes client with list-watch and in-memory cache.
+ *
+ * Watches a set of Custom Resources and keeps an in-memory cache updated via
+ * Kubernetes informers (list + watch). Cache reads are synchronous and
+ * allocation-free; all I/O happens in the background after init().
+ *
+ * KubeConfig is built from K8sClientConfig supplied by the caller (Server):
+ *   - apiUrl + token present → explicit out-of-cluster config
+ *   - apiUrl + token absent  → loadFromDefault (in-cluster SA or ~/.kube/config)
+ *
+ * TLS:
+ *   - skipTlsVerify: true disables certificate verification (dev only).
+ *   - In production, mount the cluster CA and let KubeConfig handle it.
+ */
+import {
+	ApiException,
+	AppsV1Api,
+	BatchV1Api,
+	CoreV1Api,
+	CustomObjectsApi,
+	type Informer,
+	KubeConfig,
+	type KubernetesObject,
+	makeInformer,
+	NetworkingV1Api,
+	setHeaderOptions,
+} from '@kubernetes/client-node';
+
+import { k8sLogger as log } from '$lib/server/logger';
+
+// --- Types ---
+
+/** Identifies a single Kubernetes resource type to watch. */
+type WatchedResource = {
+	group: string;
+	version: string;
+	resource: string;
+	namespace: string;
+	labelSelector?: string;
+	fieldSelector?: string;
+};
+
+type K8sClientConfig = {
+	/** Explicit API server URL (out-of-cluster / dev). When absent, loadFromDefault() is used. */
+	apiUrl?: string;
+	/** Bearer token for explicit config. Required when apiUrl is set. */
+	token?: string;
+	/** Skip TLS certificate verification. Dev only. */
+	skipTlsVerify?: boolean;
+};
+
+// --- Helpers ---
+
+const cacheKey = (r: WatchedResource): string =>
+	`${r.group}/${r.version}/namespaces/${r.namespace}/${r.resource}`;
+
+const selectorQueryString = (
+	r: Pick<WatchedResource, 'labelSelector' | 'fieldSelector'>,
+): string => {
+	const params = new URLSearchParams();
+	if (r.labelSelector) params.set('labelSelector', r.labelSelector);
+	if (r.fieldSelector) params.set('fieldSelector', r.fieldSelector);
+	const query = params.toString();
+	return query ? `?${query}` : '';
+};
+
+const isCoreGroup = (group: string): boolean => group === '' || group === 'core';
+
+const isSupportedBuiltinResource = (r: WatchedResource): boolean => {
+	if (r.version !== 'v1') return false;
+
+	if (isCoreGroup(r.group)) {
+		return r.resource === 'pods' || r.resource === 'services' || r.resource === 'secrets';
+	}
+
+	return r.group === 'apps' && r.resource === 'deployments';
+};
+
+const watchPath = (r: WatchedResource): string =>
+	`${isCoreGroup(r.group) ? '/api' : '/apis'}/${isCoreGroup(r.group) ? r.version : `${r.group}/${r.version}`}/namespaces/${r.namespace}/${r.resource}${selectorQueryString(r)}`;
+
+const buildKubeConfig = (cfg: K8sClientConfig): KubeConfig => {
+	const kc = new KubeConfig();
+
+	if (cfg.apiUrl && cfg.token) {
+		log.debug('Loading kubeconfig from explicit config');
+		kc.loadFromOptions({
+			clusters: [
+				{ name: 'current', server: cfg.apiUrl, skipTLSVerify: cfg.skipTlsVerify ?? false },
+			],
+			users: [{ name: 'current', token: cfg.token }],
+			contexts: [{ name: 'current', cluster: 'current', user: 'current' }],
+			currentContext: 'current',
+		});
+	} else {
+		log.debug('Loading kubeconfig from cluster / default');
+		kc.loadFromDefault();
+	}
+
+	return kc;
+};
+
+// --- Helpers ---
+
+const isNotFound = (err: unknown): boolean => err instanceof ApiException && err.code === 404;
+const isAlreadyExists = (err: unknown): boolean => err instanceof ApiException && err.code === 409;
+
+// --- K8sClient ---
+
+const RECONNECT_DELAY_MS = 5_000;
+
+class K8sClient {
+	readonly #cfg: K8sClientConfig;
+	readonly #cache = new Map<string, Map<string, KubernetesObject>>();
+	readonly #kubeConfig: KubeConfig;
+	#informers: Informer<KubernetesObject>[] = [];
+
+	constructor(cfg: K8sClientConfig) {
+		this.#cfg = cfg;
+		this.#kubeConfig = buildKubeConfig(cfg);
+	}
+
+	async init(): Promise<void> {}
+
+	async shutdown(): Promise<void> {
+		log.info('Stopping all informers');
+		await Promise.all(this.#informers.map((i) => i.stop()));
+		this.#informers = [];
+		log.info('All informers stopped');
+	}
+
+	async watchResources<T extends KubernetesObject>(
+		res: WatchedResource,
+		onEvent?: (type: 'add' | 'update' | 'delete', obj: T) => void,
+	): Promise<T[]> {
+		const key = cacheKey(res);
+		this.#cache.set(key, new Map());
+
+		const listFn = async () => {
+			if (isSupportedBuiltinResource(res)) {
+				if (res.group === 'apps' && res.resource === 'deployments') {
+					const appsApi = this.#kubeConfig.makeApiClient(AppsV1Api);
+					return appsApi.listNamespacedDeployment({
+						namespace: res.namespace,
+						labelSelector: res.labelSelector,
+						fieldSelector: res.fieldSelector,
+					});
+				}
+
+				const coreApi = this.#kubeConfig.makeApiClient(CoreV1Api);
+				if (res.resource === 'pods') {
+					return coreApi.listNamespacedPod({
+						namespace: res.namespace,
+						labelSelector: res.labelSelector,
+						fieldSelector: res.fieldSelector,
+					});
+				}
+
+				if (res.resource === 'services') {
+					return coreApi.listNamespacedService({
+						namespace: res.namespace,
+						labelSelector: res.labelSelector,
+						fieldSelector: res.fieldSelector,
+					});
+				}
+
+				if (res.resource === 'secrets') {
+					return coreApi.listNamespacedSecret({
+						namespace: res.namespace,
+						labelSelector: res.labelSelector,
+						fieldSelector: res.fieldSelector,
+					});
+				}
+			}
+
+			const customApi = this.#kubeConfig.makeApiClient(CustomObjectsApi);
+			return customApi.listNamespacedCustomObject({
+				group: res.group,
+				version: res.version,
+				namespace: res.namespace,
+				plural: res.resource,
+				labelSelector: res.labelSelector,
+				fieldSelector: res.fieldSelector,
+			});
+		};
+
+		const informer = makeInformer<T>(this.#kubeConfig, watchPath(res), listFn);
+
+		const upsert = (obj: T) => {
+			const name = obj.metadata?.name;
+			if (name) this.#cache.get(key)!.set(name, obj as unknown as T);
+		};
+
+		informer.on('add', (obj: T) => {
+			upsert(obj);
+			onEvent?.('add', obj);
+		});
+		informer.on('update', (obj: T) => {
+			upsert(obj);
+			onEvent?.('update', obj);
+		});
+		informer.on('delete', (obj: T) => {
+			const name = obj.metadata?.name;
+			if (name) this.#cache.get(key)!.delete(name);
+			onEvent?.('delete', obj);
+		});
+
+		informer.on('error', (err: Error) => {
+			log.error('Watch error, reconnecting', {
+				resource: `${res.group}/${res.resource}`,
+				namespace: res.namespace,
+				err,
+			});
+			setTimeout(() => informer.start(), RECONNECT_DELAY_MS);
+		});
+
+		this.#informers.push(informer);
+		log.info('Starting informer', { resource: res.resource, namespace: res.namespace });
+		await informer.start();
+		log.info('Informer ready', {
+			resource: res.resource,
+			namespace: res.namespace,
+			count: this.#cache.get(key)!.size,
+		});
+
+		return this.listResources<T>(res.group, res.version, res.resource, res.namespace);
+	}
+
+	async watchDeployments<T extends KubernetesObject>(
+		namespace: string,
+		selectors: Pick<WatchedResource, 'labelSelector' | 'fieldSelector'> = {},
+	): Promise<T[]> {
+		return this.watchResources<T>({
+			group: 'apps',
+			version: 'v1',
+			resource: 'deployments',
+			namespace,
+			...selectors,
+		});
+	}
+
+	async watchPods<T extends KubernetesObject>(
+		namespace: string,
+		selectors: Pick<WatchedResource, 'labelSelector' | 'fieldSelector'> = {},
+	): Promise<T[]> {
+		return this.watchResources<T>({
+			group: '',
+			version: 'v1',
+			resource: 'pods',
+			namespace,
+			...selectors,
+		});
+	}
+
+	async watchServices<T extends KubernetesObject>(
+		namespace: string,
+		selectors: Pick<WatchedResource, 'labelSelector' | 'fieldSelector'> = {},
+	): Promise<T[]> {
+		return this.watchResources<T>({
+			group: '',
+			version: 'v1',
+			resource: 'services',
+			namespace,
+			...selectors,
+		});
+	}
+
+	/** Returns all cached resources for the given resource type. Synchronous. */
+	listResources<T>(group: string, version: string, resource: string, namespace: string): T[] {
+		const key = cacheKey({ group, version, resource, namespace });
+		const cache = this.#cache.get(key);
+		if (!cache) return [];
+		return Array.from(cache.values()) as unknown as T[];
+	}
+
+	/** Returns a single cached resource by name. Synchronous. Returns undefined if not found. */
+	getResource<T>(
+		group: string,
+		version: string,
+		resource: string,
+		name: string,
+		namespace: string,
+	): T | undefined {
+		const key = cacheKey({ group, version, resource, namespace });
+		return this.#cache.get(key)?.get(name) as unknown as T | undefined;
+	}
+
+	/** Returns all cached resource statuses for the given resource type. Synchronous. */
+	listResourceStatuses<TStatus>(
+		group: string,
+		version: string,
+		resource: string,
+		namespace: string,
+	): TStatus[] {
+		const objects = this.listResources<{ status?: TStatus }>(group, version, resource, namespace);
+		return objects.flatMap((obj) => (obj.status === undefined ? [] : [obj.status]));
+	}
+
+	/** Returns a single cached resource status by name. Synchronous. Returns undefined if not found. */
+	getResourceStatus<TStatus>(
+		group: string,
+		version: string,
+		resource: string,
+		name: string,
+		namespace: string,
+	): TStatus | undefined {
+		const object = this.getResource<{ status?: TStatus }>(
+			group,
+			version,
+			resource,
+			name,
+			namespace,
+		);
+		return object?.status;
+	}
+
+	/**
+	 * Create or patch a custom resource.
+	 *
+	 * Attempts to create the resource first (POST). If it already exists (409), falls back to
+	 * Server-Side Apply (PATCH with `application/apply-patch+yaml`, `fieldManager: 'locostack-admin-web'`,
+	 * `force: true`) which takes ownership of all fields and updates in-place.
+	 */
+	async applyCustomResource(
+		group: string,
+		version: string,
+		plural: string,
+		namespace: string,
+		obj: KubernetesObject,
+	): Promise<void> {
+		const name = obj.metadata?.name;
+		if (!name) throw new Error('metadata.name is required for applyCustomResource');
+
+		const customApi = this.#kubeConfig.makeApiClient(CustomObjectsApi);
+
+		// Try to create the resource; if it already exists, fall through to SSA patch.
+		try {
+			await customApi.createNamespacedCustomObject({
+				group,
+				version,
+				namespace,
+				plural,
+				body: obj,
+			});
+			log.debug('Created custom resource', { group, version, plural, namespace, name });
+			return;
+		} catch (err: unknown) {
+			if (!isAlreadyExists(err)) throw err;
+		}
+
+		await customApi.patchNamespacedCustomObject(
+			{
+				group,
+				version,
+				namespace,
+				plural,
+				name,
+				body: obj,
+				fieldManager: 'locostack-admin-web',
+				force: true,
+			},
+			setHeaderOptions('Content-Type', 'application/apply-patch+yaml'),
+		);
+		log.debug('Patched custom resource', { group, version, plural, namespace, name });
+	}
+
+	/**
+	 * Delete a custom resource by name.
+	 * Does nothing (logs a warning) when the resource does not exist.
+	 */
+	async deleteCustomResource(
+		group: string,
+		version: string,
+		plural: string,
+		namespace: string,
+		name: string,
+	): Promise<void> {
+		const customApi = this.#kubeConfig.makeApiClient(CustomObjectsApi);
+		try {
+			await customApi.deleteNamespacedCustomObject({ group, version, namespace, plural, name });
+			log.debug('Deleted custom resource', { group, version, plural, namespace, name });
+		} catch (err: unknown) {
+			if (isNotFound(err)) {
+				log.warn('Custom resource not found on delete (ignored)', {
+					group,
+					version,
+					plural,
+					namespace,
+					name,
+				});
+			} else {
+				throw err;
+			}
+		}
+	}
+
+	coreApi(): CoreV1Api {
+		return this.#kubeConfig.makeApiClient(CoreV1Api);
+	}
+
+	appsApi(): AppsV1Api {
+		return this.#kubeConfig.makeApiClient(AppsV1Api);
+	}
+
+	batchApi(): BatchV1Api {
+		return this.#kubeConfig.makeApiClient(BatchV1Api);
+	}
+
+	networkingApi(): NetworkingV1Api {
+		return this.#kubeConfig.makeApiClient(NetworkingV1Api);
+	}
+
+	customObjectsApi(): CustomObjectsApi {
+		return this.#kubeConfig.makeApiClient(CustomObjectsApi);
+	}
+}
+
+export type { K8sClientConfig, WatchedResource };
+export { K8sClient };

+ 2 - 1
src/lib/server/logger.ts

@@ -60,5 +60,6 @@ const child = (domain: string) => root.child({ domain });
 const serverLogger = child('server');
 const authLogger = child('auth');
 const gitLogger = child('git');
+const k8sLogger = child('k8s');
 
-export { authLogger, gitLogger, root as logger, serverLogger };
+export { authLogger, gitLogger, k8sLogger, root as logger, serverLogger };

+ 2 - 0
src/lib/server/resources/agents/index.ts

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

+ 79 - 0
src/lib/server/resources/agents/k8s.ts

@@ -0,0 +1,79 @@
+import type { K8sClient } from '$lib/server/k8s';
+import { K8sResource } from '$lib/server/resources/k8s';
+import type { AgentCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+import type { AgentResources } from './types';
+
+class K8sAgentResourceImpl extends K8sResource<AgentCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'agents',
+			},
+			[
+				{
+					group: 'apps',
+					version: 'v1',
+					namespace,
+					resource: 'deployments',
+					labelSelector: 'locostack.com/component=Agent',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'pods',
+					labelSelector: 'locostack.com/component=Agent',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'services',
+					labelSelector: 'locostack.com/component=Agent',
+				},
+			],
+		);
+	}
+}
+
+class K8sAgentResources implements AgentResources {
+	#agentResources: K8sAgentResourceImpl;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#agentResources = new K8sAgentResourceImpl(k8sClient, namespace);
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		return this.#agentResources.subscribe(handler);
+	}
+
+	async getAgents(): Promise<AgentCR[]> {
+		return this.#agentResources.getResources();
+	}
+
+	async getAgentResources(agentId: string): Promise<CoreResources> {
+		return this.#agentResources.getCoreResources(`agent-${agentId}`);
+	}
+
+	async getAgentStatus(agentId: string): Promise<AgentCR['status'] | undefined> {
+		return this.#agentResources.getResourceStatus<AgentCR['status']>(agentId);
+	}
+
+	async applyAgent(cr: AgentCR): Promise<void> {
+		await this.#agentResources.apply(cr);
+	}
+
+	async deleteAgent(id: string): Promise<void> {
+		await this.#agentResources.remove(id);
+	}
+}
+
+export { K8sAgentResources };

+ 14 - 0
src/lib/server/resources/agents/types.ts

@@ -0,0 +1,14 @@
+import type { AgentCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+interface AgentResources {
+	getAgents(): Promise<AgentCR[]>;
+	getAgentResources(agentId: string): Promise<CoreResources>;
+	getAgentStatus(agentId: string): Promise<AgentCR['status'] | undefined>;
+	applyAgent(cr: AgentCR): Promise<void>;
+	deleteAgent(id: string): Promise<void>;
+	subscribe(handler: (event: ResourceEvent) => void): () => void;
+}
+
+export type { AgentResources };

+ 2 - 0
src/lib/server/resources/documents/index.ts

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

+ 72 - 0
src/lib/server/resources/documents/k8s.ts

@@ -0,0 +1,72 @@
+import type { K8sClient } from '$lib/server/k8s';
+import { K8sResource } from '$lib/server/resources/k8s';
+import type { DocumentCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+import type { DocumentResources } from './types';
+
+class _K8sDocumentResources extends K8sResource<DocumentCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'documents',
+			},
+			[
+				{
+					group: 'batch',
+					version: 'v1',
+					namespace,
+					resource: 'jobs',
+					labelSelector: 'locostack.com/kb',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'pods',
+					labelSelector: 'locostack.com/kb',
+				},
+			],
+		);
+	}
+}
+
+class K8sDocumentResources implements DocumentResources {
+	#documentResources: _K8sDocumentResources;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#documentResources = new _K8sDocumentResources(k8sClient, namespace);
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		return this.#documentResources.subscribe(handler);
+	}
+
+	async getDocuments(): Promise<DocumentCR[]> {
+		return this.#documentResources.getResources();
+	}
+
+	async getDocumentResources(docId: string): Promise<CoreResources> {
+		return this.#documentResources.getCoreResources(`doc-${docId}`);
+	}
+
+	async getDocumentStatus(docId: string): Promise<DocumentCR['status'] | undefined> {
+		return this.#documentResources.getResourceStatus<DocumentCR['status']>(docId);
+	}
+
+	async applyDocument(cr: DocumentCR): Promise<void> {
+		await this.#documentResources.apply(cr);
+	}
+
+	async deleteDocument(id: string): Promise<void> {
+		await this.#documentResources.remove(id);
+	}
+}
+
+export { K8sDocumentResources };

+ 14 - 0
src/lib/server/resources/documents/types.ts

@@ -0,0 +1,14 @@
+import type { DocumentCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+interface DocumentResources {
+	getDocuments(): Promise<DocumentCR[]>;
+	getDocumentResources(docId: string): Promise<CoreResources>;
+	getDocumentStatus(docId: string): Promise<DocumentCR['status'] | undefined>;
+	applyDocument(cr: DocumentCR): Promise<void>;
+	deleteDocument(id: string): Promise<void>;
+	subscribe(handler: (event: ResourceEvent) => void): () => void;
+}
+
+export type { DocumentResources };

+ 1 - 0
src/lib/server/resources/index.ts

@@ -0,0 +1 @@
+export { Resources } from './resources';

+ 158 - 0
src/lib/server/resources/k8s.ts

@@ -0,0 +1,158 @@
+import type { KubernetesObject, V1Deployment, V1Pod, V1Service } from '@kubernetes/client-node';
+
+import { wrapInfraError } from '$lib/server/errors';
+import type { K8sClient, WatchedResource } from '$lib/server/k8s';
+import { k8sLogger as log } from '$lib/server/logger';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+abstract class K8sResource<T extends KubernetesObject & { status?: unknown }> {
+	#k8sClient: K8sClient;
+	#namespace: string;
+	#resource: WatchedResource;
+	#childResources: WatchedResource[];
+	#initialized = false;
+	#initializationPromise?: Promise<void>;
+
+	#handlers = new Set<(event: ResourceEvent) => void>();
+	/** Stores JSON.stringify(cr.status) for change detection. */
+	#statusCache = new Map<string, string>();
+
+	constructor(
+		k8sClient: K8sClient,
+		namespace: string,
+		resource: WatchedResource,
+		childResources: WatchedResource[],
+	) {
+		this.#k8sClient = k8sClient;
+		this.#namespace = namespace;
+		this.#resource = resource;
+		this.#childResources = childResources;
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		this.#handlers.add(handler);
+		return () => this.#handlers.delete(handler);
+	}
+
+	#emit(event: ResourceEvent): void {
+		for (const h of this.#handlers) h(event);
+	}
+
+	#onResourceEvent(type: 'add' | 'update' | 'delete', cr: T): void {
+		const id = cr.metadata?.name;
+		if (!id) return;
+		if (type === 'delete') return;
+		const rawStatus = cr.status;
+		const serialized = JSON.stringify(rawStatus ?? null);
+		const prev = this.#statusCache.get(id);
+		const isKnown = this.#statusCache.has(id);
+		this.#statusCache.set(id, serialized);
+		if (isKnown && serialized !== prev) {
+			this.#emit({
+				group: this.#resource.group,
+				version: this.#resource.version,
+				resource: this.#resource.resource,
+				namespace: this.#namespace,
+				name: id,
+				status: rawStatus,
+			});
+		}
+	}
+
+	async init(): Promise<void> {
+		if (this.#initialized) return;
+		if (this.#initializationPromise === undefined) {
+			const watchResources: Promise<void>[] = [];
+			watchResources.push(
+				this.#k8sClient
+					.watchResources<T>(this.#resource, (type, cr) => this.#onResourceEvent(type, cr))
+					.then(() => {}),
+			);
+			for (const res of this.#childResources) {
+				watchResources.push(this.#k8sClient.watchResources(res).then(() => {}));
+			}
+			this.#initializationPromise = Promise.all(watchResources).then(() => {});
+		}
+		await this.#initializationPromise;
+		this.#initialized = true;
+	}
+
+	async getResources(): Promise<T[]> {
+		await this.init();
+		return this.#k8sClient.listResources<T>(
+			this.#resource.group,
+			this.#resource.version,
+			this.#resource.resource,
+			this.#namespace,
+		);
+	}
+
+	async getCoreResources(id: string): Promise<CoreResources> {
+		await this.init();
+		const deployments = this.#k8sClient
+			.listResources<V1Deployment>('apps', 'v1', 'deployments', this.#namespace)
+			.filter((d) => d.metadata?.labels?.['locostack.com/name'] === id);
+		const pods = this.#k8sClient
+			.listResources<V1Pod>('core', 'v1', 'pods', this.#namespace)
+			.filter((p) => p.metadata?.labels?.['locostack.com/name'] === id);
+		const services = this.#k8sClient
+			.listResources<V1Service>('core', 'v1', 'services', this.#namespace)
+			.filter((s) => s.metadata?.labels?.['locostack.com/name'] === id);
+		return { type: 'k8s', deployments, pods, services };
+	}
+
+	async getResourceStatus<TStatus>(id: string): Promise<TStatus | undefined> {
+		await this.init();
+		return this.#k8sClient.getResourceStatus<TStatus>(
+			this.#resource.group,
+			this.#resource.version,
+			this.#resource.resource,
+			id,
+			this.#namespace,
+		);
+	}
+
+	/** Server-side apply the given CR. Errors are logged and swallowed (best-effort). */
+	async apply(cr: T): Promise<void> {
+		try {
+			await this.#k8sClient.applyCustomResource(
+				this.#resource.group,
+				this.#resource.version,
+				this.#resource.resource,
+				this.#namespace,
+				cr,
+			);
+		} catch (err) {
+			log.warn('K8s apply failed (non-blocking)', {
+				resource: this.#resource.resource,
+				namespace: this.#namespace,
+				err,
+			});
+			throw wrapInfraError('k8s', 'apply', err);
+		}
+	}
+
+	/** Delete a CR by name. Errors are logged and swallowed (best-effort). */
+	async remove(id: string): Promise<void> {
+		try {
+			await this.#k8sClient.deleteCustomResource(
+				this.#resource.group,
+				this.#resource.version,
+				this.#resource.resource,
+				this.#namespace,
+				id,
+			);
+		} catch (err) {
+			log.warn('K8s remove failed (non-blocking)', {
+				resource: this.#resource.resource,
+				namespace: this.#namespace,
+				id,
+				err,
+			});
+			throw wrapInfraError('k8s', 'delete', err);
+		}
+	}
+}
+
+export { K8sResource };

+ 2 - 0
src/lib/server/resources/kbs/index.ts

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

+ 113 - 0
src/lib/server/resources/kbs/k8s.ts

@@ -0,0 +1,113 @@
+import type { K8sClient } from '$lib/server/k8s';
+import { K8sResource } from '$lib/server/resources/k8s';
+import type { ExternalKBCR, ManagedKBCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+import type { KBResources } from './types';
+
+class K8sExternalKBResources extends K8sResource<ExternalKBCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'externalmodels',
+			},
+			[],
+		);
+	}
+}
+
+class K8sManagedKBResources extends K8sResource<ManagedKBCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'managedknowledgebases',
+			},
+			[
+				{
+					group: 'apps',
+					version: 'v1',
+					namespace,
+					resource: 'deployments',
+					labelSelector: 'locostack.com/component=KnowledgeBase',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'pods',
+					labelSelector: 'locostack.com/component=KnowledgeBase',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'services',
+					labelSelector: 'locostack.com/component=KnowledgeBase',
+				},
+			],
+		);
+	}
+}
+
+class K8sKBResources implements KBResources {
+	#externalKBResources: K8sExternalKBResources;
+	#managedKBResources: K8sManagedKBResources;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#externalKBResources = new K8sExternalKBResources(k8sClient, namespace);
+		this.#managedKBResources = new K8sManagedKBResources(k8sClient, namespace);
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		return this.#externalKBResources.subscribe(handler);
+	}
+
+	async getExternalKBs(): Promise<ExternalKBCR[]> {
+		return this.#externalKBResources.getResources();
+	}
+
+	async getExternalKBStatus(kbId: string): Promise<ExternalKBCR['status'] | undefined> {
+		return this.#externalKBResources.getResourceStatus<ExternalKBCR['status']>(kbId);
+	}
+
+	async getManagedKBs(): Promise<ManagedKBCR[]> {
+		return this.#managedKBResources.getResources();
+	}
+
+	async getManagedKBResources(kbId: string): Promise<CoreResources> {
+		return this.#managedKBResources.getCoreResources(`kb-${kbId}`);
+	}
+
+	async getManagedKBStatus(kbId: string): Promise<ManagedKBCR['status'] | undefined> {
+		return this.#managedKBResources.getResourceStatus<ManagedKBCR['status']>(kbId);
+	}
+
+	async applyExternalKB(cr: ExternalKBCR): Promise<void> {
+		await this.#externalKBResources.apply(cr);
+	}
+
+	async applyManagedKB(cr: ManagedKBCR): Promise<void> {
+		await this.#managedKBResources.apply(cr);
+	}
+
+	async deleteExternalKB(id: string): Promise<void> {
+		await this.#externalKBResources.remove(id);
+	}
+
+	async deleteManagedKB(id: string): Promise<void> {
+		await this.#managedKBResources.remove(id);
+	}
+}
+
+export { K8sKBResources };

+ 18 - 0
src/lib/server/resources/kbs/types.ts

@@ -0,0 +1,18 @@
+import type { ExternalKBCR, ManagedKBCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+interface KBResources {
+	getExternalKBs(): Promise<ExternalKBCR[]>;
+	getExternalKBStatus(kbId: string): Promise<ExternalKBCR['status'] | undefined>;
+	getManagedKBs(): Promise<ManagedKBCR[]>;
+	getManagedKBResources(kbId: string): Promise<CoreResources>;
+	getManagedKBStatus(kbId: string): Promise<ManagedKBCR['status'] | undefined>;
+	applyExternalKB(cr: ExternalKBCR): Promise<void>;
+	applyManagedKB(cr: ManagedKBCR): Promise<void>;
+	deleteExternalKB(id: string): Promise<void>;
+	deleteManagedKB(id: string): Promise<void>;
+	subscribe(handler: (event: ResourceEvent) => void): () => void;
+}
+
+export type { KBResources };

+ 2 - 0
src/lib/server/resources/models/index.ts

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

+ 117 - 0
src/lib/server/resources/models/k8s.ts

@@ -0,0 +1,117 @@
+import type { K8sClient } from '$lib/server/k8s';
+import { K8sResource } from '$lib/server/resources/k8s';
+import type { ExternalModelCR, ManagedModelCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+import type { ModelResources } from './types';
+
+class K8sExternalModelResources extends K8sResource<ExternalModelCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'externalmodels',
+			},
+			[],
+		);
+	}
+}
+
+class K8sManagedModelResources extends K8sResource<ManagedModelCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'managedmodels',
+			},
+			[
+				{
+					group: 'apps',
+					version: 'v1',
+					namespace,
+					resource: 'deployments',
+					labelSelector: 'locostack.com/component=Model',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'pods',
+					labelSelector: 'locostack.com/component=Model',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'services',
+					labelSelector: 'locostack.com/component=Model',
+				},
+			],
+		);
+	}
+}
+
+class K8sModelResources implements ModelResources {
+	#externalModelResources: K8sExternalModelResources;
+	#managedModelResources: K8sManagedModelResources;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#externalModelResources = new K8sExternalModelResources(k8sClient, namespace);
+		this.#managedModelResources = new K8sManagedModelResources(k8sClient, namespace);
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		const unsubs = [
+			this.#externalModelResources.subscribe(handler),
+			this.#managedModelResources.subscribe(handler),
+		];
+		return () => unsubs.forEach((u) => u());
+	}
+
+	async getExternalModels(): Promise<ExternalModelCR[]> {
+		return this.#externalModelResources.getResources();
+	}
+
+	async getExternalModelStatus(modelId: string): Promise<ExternalModelCR['status'] | undefined> {
+		return this.#externalModelResources.getResourceStatus<ExternalModelCR['status']>(modelId);
+	}
+
+	async getManagedModels(): Promise<ManagedModelCR[]> {
+		return this.#managedModelResources.getResources();
+	}
+
+	async getManagedModelResources(modelId: string): Promise<CoreResources> {
+		return this.#managedModelResources.getCoreResources(`model-${modelId}`);
+	}
+
+	async getManagedModelStatus(modelId: string): Promise<ManagedModelCR['status'] | undefined> {
+		return this.#managedModelResources.getResourceStatus<ManagedModelCR['status']>(modelId);
+	}
+
+	async applyExternalModel(cr: ExternalModelCR): Promise<void> {
+		await this.#externalModelResources.apply(cr);
+	}
+
+	async applyManagedModel(cr: ManagedModelCR): Promise<void> {
+		await this.#managedModelResources.apply(cr);
+	}
+
+	async deleteExternalModel(id: string): Promise<void> {
+		await this.#externalModelResources.remove(id);
+	}
+
+	async deleteManagedModel(id: string): Promise<void> {
+		await this.#managedModelResources.remove(id);
+	}
+}
+
+export { K8sModelResources };

+ 18 - 0
src/lib/server/resources/models/types.ts

@@ -0,0 +1,18 @@
+import type { ExternalModelCR, ManagedModelCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+interface ModelResources {
+	getExternalModels(): Promise<ExternalModelCR[]>;
+	getExternalModelStatus(modelId: string): Promise<ExternalModelCR['status'] | undefined>;
+	getManagedModels(): Promise<ManagedModelCR[]>;
+	getManagedModelResources(modelId: string): Promise<CoreResources>;
+	getManagedModelStatus(modelId: string): Promise<ManagedModelCR['status'] | undefined>;
+	applyManagedModel(cr: ManagedModelCR): Promise<void>;
+	applyExternalModel(cr: ExternalModelCR): Promise<void>;
+	deleteManagedModel(id: string): Promise<void>;
+	deleteExternalModel(id: string): Promise<void>;
+	subscribe(handler: (event: ResourceEvent) => void): () => void;
+}
+
+export type { ModelResources };

+ 97 - 0
src/lib/server/resources/resources.ts

@@ -0,0 +1,97 @@
+import { NotFoundError } from '$lib/server/errors';
+import { K8sClient } from '$lib/server/k8s';
+import type { ResourceEvent } from '$lib/types/events';
+import type { Workspace } from '$lib/types/workspace';
+
+import { type AgentResources, K8sAgentResources } from './agents';
+import { type DocumentResources, K8sDocumentResources } from './documents';
+import { K8sKBResources, type KBResources } from './kbs';
+import { K8sModelResources, type ModelResources } from './models';
+import { K8sSecretResources, type SecretResources } from './secrets';
+import { K8sStackResources, type StackResources } from './stacks';
+import { K8sToolResources, type ToolResources } from './tools';
+
+type WorkspaceResources = {
+	models: ModelResources;
+	tools: ToolResources;
+	kbs: KBResources;
+	documents: DocumentResources;
+	agents: AgentResources;
+	stacks: StackResources;
+	secrets: SecretResources;
+	/** Subscribe to status-change and delete events for all resource types. */
+	subscribe: (handler: (event: ResourceEvent) => void) => () => void;
+};
+
+class Resources {
+	#workspaces: Map<
+		string,
+		{
+			workspace: Workspace;
+			k8sClient?: K8sClient;
+			resources: WorkspaceResources;
+		}
+	> = new Map();
+
+	constructor(workspaces: Workspace[]) {
+		for (const workspace of workspaces) {
+			if (workspace.deployment.type === 'k8s') {
+				const k8sClient = new K8sClient({
+					apiUrl: workspace.deployment.apiUrl,
+					token: workspace.deployment.token,
+					skipTlsVerify: workspace.deployment.skipTlsVerify ?? false,
+				});
+				const ns = workspace.deployment.namespace;
+				const models = new K8sModelResources(k8sClient, ns);
+				const tools = new K8sToolResources(k8sClient, ns);
+				const kbs = new K8sKBResources(k8sClient, ns);
+				const documents = new K8sDocumentResources(k8sClient, ns);
+				const agents = new K8sAgentResources(k8sClient, ns);
+				const stacks = new K8sStackResources(k8sClient, ns);
+				const secrets = new K8sSecretResources(k8sClient, ns);
+
+				const subscribe = (handler: (event: ResourceEvent) => void): (() => void) => {
+					const unsubs = [
+						models.subscribe(handler),
+						tools.subscribe(handler),
+						kbs.subscribe(handler),
+						documents.subscribe(handler),
+						agents.subscribe(handler),
+					];
+					return () => unsubs.forEach((u) => u());
+				};
+
+				const resources: WorkspaceResources = {
+					models,
+					tools,
+					kbs,
+					documents,
+					agents,
+					stacks,
+					secrets,
+					subscribe,
+				};
+				this.#workspaces.set(workspace.id, { workspace, k8sClient, resources });
+			}
+		}
+	}
+
+	async init(): Promise<void> {}
+
+	async shutdown(): Promise<void> {
+		await Promise.all(
+			Array.from(this.#workspaces.values())
+				.map((workspace) => workspace.k8sClient)
+				.filter((client): client is K8sClient => client !== undefined)
+				.map((client) => client.shutdown()),
+		);
+	}
+
+	getWorkspaceResources(workspaceId: string): WorkspaceResources {
+		const workspace = this.#workspaces.get(workspaceId);
+		if (!workspace) throw new NotFoundError(`Workspace <${workspaceId}> not found`);
+		return workspace?.resources;
+	}
+}
+
+export { Resources };

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

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

+ 58 - 0
src/lib/server/resources/secrets/k8s.ts

@@ -0,0 +1,58 @@
+import type { V1Secret } from '@kubernetes/client-node';
+
+import type { K8sClient } from '$lib/server/k8s';
+import { K8sResource } from '$lib/server/resources/k8s';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+import type { SecretResources } from '.';
+
+class K8sSecretResourceImpl extends K8sResource<V1Secret> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: '',
+				version: 'v1',
+				namespace,
+				resource: 'secrets',
+			},
+			[],
+		);
+	}
+}
+
+class K8sSecretResources implements SecretResources {
+	#secretResources: K8sSecretResourceImpl;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#secretResources = new K8sSecretResourceImpl(k8sClient, namespace);
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		return this.#secretResources.subscribe(handler);
+	}
+
+	async getSecrets(): Promise<V1Secret[]> {
+		return this.#secretResources.getResources();
+	}
+
+	async getSecretResources(secretId: string): Promise<CoreResources> {
+		return this.#secretResources.getCoreResources(secretId);
+	}
+
+	async getSecretStatus(secretId: string): Promise<undefined> {
+		return undefined;
+	}
+
+	async applySecret(cr: V1Secret): Promise<void> {
+		await this.#secretResources.apply(cr);
+	}
+
+	async deleteSecret(id: string): Promise<void> {
+		await this.#secretResources.remove(id);
+	}
+}
+
+export { K8sSecretResources };

+ 15 - 0
src/lib/server/resources/secrets/types.ts

@@ -0,0 +1,15 @@
+import type { V1Secret } from '@kubernetes/client-node';
+
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+interface SecretResources {
+	getSecrets(): Promise<V1Secret[]>;
+	getSecretResources(secretId: string): Promise<CoreResources>;
+	getSecretStatus(secretId: string): Promise<undefined>;
+	applySecret(cr: V1Secret): Promise<void>;
+	deleteSecret(id: string): Promise<void>;
+	subscribe(handler: (event: ResourceEvent) => void): () => void;
+}
+
+export type { SecretResources };

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

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

+ 57 - 0
src/lib/server/resources/stacks/k8s.ts

@@ -0,0 +1,57 @@
+import type { K8sClient } from '$lib/server/k8s';
+import { K8sResource } from '$lib/server/resources/k8s';
+import type { StackCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+import type { StackResources } from './types';
+
+class K8sStackResourceImpl extends K8sResource<StackCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'stacks',
+			},
+			[],
+		);
+	}
+}
+
+class K8sStackResources implements StackResources {
+	#stackResources: K8sStackResourceImpl;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#stackResources = new K8sStackResourceImpl(k8sClient, namespace);
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		return this.#stackResources.subscribe(handler);
+	}
+
+	async getStacks(): Promise<StackCR[]> {
+		return this.#stackResources.getResources();
+	}
+
+	async getStackResources(stackId: string): Promise<CoreResources> {
+		return this.#stackResources.getCoreResources(stackId);
+	}
+
+	async getStackStatus(stackId: string): Promise<StackCR['status'] | undefined> {
+		return this.#stackResources.getResourceStatus<StackCR['status']>(stackId);
+	}
+
+	async applyStack(cr: StackCR): Promise<void> {
+		await this.#stackResources.apply(cr);
+	}
+
+	async deleteStack(id: string): Promise<void> {
+		await this.#stackResources.remove(id);
+	}
+}
+
+export { K8sStackResources };

+ 14 - 0
src/lib/server/resources/stacks/types.ts

@@ -0,0 +1,14 @@
+import type { StackCR } from '$lib/types/custom-resources/stack';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+interface StackResources {
+	getStacks(): Promise<StackCR[]>;
+	getStackResources(stackId: string): Promise<CoreResources>;
+	getStackStatus(stackId: string): Promise<StackCR['status'] | undefined>;
+	applyStack(cr: StackCR): Promise<void>;
+	deleteStack(id: string): Promise<void>;
+	subscribe(handler: (event: ResourceEvent) => void): () => void;
+}
+
+export type { StackResources };

+ 2 - 0
src/lib/server/resources/tools/index.ts

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

+ 117 - 0
src/lib/server/resources/tools/k8s.ts

@@ -0,0 +1,117 @@
+import type { K8sClient } from '$lib/server/k8s';
+import { K8sResource } from '$lib/server/resources/k8s';
+import type { ExternalToolCR, ManagedToolCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+import type { ToolResources } from './types';
+
+class K8sExternalToolResources extends K8sResource<ExternalToolCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'externaltools',
+			},
+			[],
+		);
+	}
+}
+
+class K8sManagedToolResources extends K8sResource<ManagedToolCR> {
+	constructor(k8sClient: K8sClient, namespace: string) {
+		super(
+			k8sClient,
+			namespace,
+			{
+				group: 'locostack.com',
+				version: 'v1alpha1',
+				namespace,
+				resource: 'managedtools',
+			},
+			[
+				{
+					group: 'apps',
+					version: 'v1',
+					namespace,
+					resource: 'deployments',
+					labelSelector: 'locostack.com/component=Tool',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'pods',
+					labelSelector: 'locostack.com/component=Tool',
+				},
+				{
+					group: 'core',
+					version: 'v1',
+					namespace,
+					resource: 'services',
+					labelSelector: 'locostack.com/component=Tool',
+				},
+			],
+		);
+	}
+}
+
+class K8sToolResources implements ToolResources {
+	#externalToolResources: K8sExternalToolResources;
+	#managedToolResources: K8sManagedToolResources;
+
+	constructor(k8sClient: K8sClient, namespace: string) {
+		this.#externalToolResources = new K8sExternalToolResources(k8sClient, namespace);
+		this.#managedToolResources = new K8sManagedToolResources(k8sClient, namespace);
+	}
+
+	subscribe(handler: (event: ResourceEvent) => void): () => void {
+		const unsubs = [
+			this.#externalToolResources.subscribe(handler),
+			this.#managedToolResources.subscribe(handler),
+		];
+		return () => unsubs.forEach((u) => u());
+	}
+
+	async getExternalTools(): Promise<ExternalToolCR[]> {
+		return this.#externalToolResources.getResources();
+	}
+
+	async getExternalToolStatus(toolId: string): Promise<ExternalToolCR['status'] | undefined> {
+		return this.#externalToolResources.getResourceStatus<ExternalToolCR['status']>(toolId);
+	}
+
+	async getManagedTools(): Promise<ManagedToolCR[]> {
+		return this.#managedToolResources.getResources();
+	}
+
+	async getManagedToolResources(toolId: string): Promise<CoreResources> {
+		return this.#managedToolResources.getCoreResources(`tool-${toolId}`);
+	}
+
+	async getManagedToolStatus(toolId: string): Promise<ManagedToolCR['status'] | undefined> {
+		return this.#managedToolResources.getResourceStatus<ManagedToolCR['status']>(toolId);
+	}
+
+	async applyExternalTool(cr: ExternalToolCR): Promise<void> {
+		await this.#externalToolResources.apply(cr);
+	}
+
+	async applyManagedTool(cr: ManagedToolCR): Promise<void> {
+		await this.#managedToolResources.apply(cr);
+	}
+
+	async deleteExternalTool(id: string): Promise<void> {
+		await this.#externalToolResources.remove(id);
+	}
+
+	async deleteManagedTool(id: string): Promise<void> {
+		await this.#managedToolResources.remove(id);
+	}
+}
+
+export { K8sToolResources };

+ 18 - 0
src/lib/server/resources/tools/types.ts

@@ -0,0 +1,18 @@
+import type { ExternalToolCR, ManagedToolCR } from '$lib/types/custom-resources';
+import type { ResourceEvent } from '$lib/types/events';
+import type { CoreResources } from '$lib/types/workspace';
+
+interface ToolResources {
+	getManagedTools(): Promise<ManagedToolCR[]>;
+	getManagedToolResources(toolId: string): Promise<CoreResources>;
+	getManagedToolStatus(toolId: string): Promise<ManagedToolCR['status'] | undefined>;
+	getExternalTools(): Promise<ExternalToolCR[]>;
+	getExternalToolStatus(toolId: string): Promise<ExternalToolCR['status'] | undefined>;
+	applyManagedTool(cr: ManagedToolCR): Promise<void>;
+	applyExternalTool(cr: ExternalToolCR): Promise<void>;
+	deleteManagedTool(id: string): Promise<void>;
+	deleteExternalTool(id: string): Promise<void>;
+	subscribe(handler: (event: ResourceEvent) => void): () => void;
+}
+
+export type { ToolResources };

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

@@ -5,6 +5,7 @@ import { env } from '$env/dynamic/private';
 
 import { Auth } from './auth';
 import { Catalog } from './catalog';
+import { Resources } from './resources';
 import { WorkspaceManager } from './workspace';
 
 class Server {
@@ -14,6 +15,7 @@ class Server {
 	#ws!: WorkspaceManager;
 	#auth: Auth | undefined;
 	#catalog!: Catalog;
+	#resources!: Resources;
 
 	async init(): Promise<void> {
 		if (this.#initialized) return;
@@ -22,7 +24,7 @@ class Server {
 
 		await Promise.all([this.#createWorkspaceManager(), this.#createAuth()]);
 		const workspaces = this.#ws.list();
-		await Promise.all([this.#createCatalog(workspaces)]);
+		await Promise.all([this.#createCatalog(workspaces), this.#createResources(workspaces)]);
 
 		log.info('Server ready');
 	}
@@ -55,6 +57,12 @@ class Server {
 		this.#catalog = catalog;
 	}
 
+	async #createResources(workspaces: Workspace[]): Promise<void> {
+		const resources = new Resources(workspaces);
+		await resources.init();
+		this.#resources = resources;
+	}
+
 	get ws(): WorkspaceManager {
 		return this.#ws;
 	}
@@ -66,6 +74,10 @@ class Server {
 	get catalog(): Catalog {
 		return this.#catalog;
 	}
+
+	get resources(): Resources {
+		return this.#resources;
+	}
 }
 
 // Singleton shared across hooks and route handlers.

+ 10 - 0
src/lib/types/events.ts

@@ -0,0 +1,10 @@
+type ResourceEvent<T = unknown> = {
+	group: string;
+	version: string;
+	resource: string;
+	namespace: string;
+	name: string;
+	status: T;
+};
+
+export type { ResourceEvent };

+ 10 - 0
src/lib/types/workspace.ts

@@ -1,3 +1,5 @@
+import type { V1Deployment, V1Pod, V1Service } from '@kubernetes/client-node';
+
 type Workspace = {
 	id: string;
 	displayName: string;
@@ -69,8 +71,16 @@ type Changes = {
 	gitCommits: GitCommit[];
 };
 
+type CoreResources = {
+	type: 'k8s';
+	deployments: V1Deployment[];
+	pods: V1Pod[];
+	services: V1Service[];
+};
+
 export type {
 	Changes,
+	CoreResources,
 	GitCommit,
 	GitPullRequest,
 	PullRequestStatus,