import { randomUUID } from 'node:crypto'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { getDb } from '../db/client'; import { type NetworkZone, containerServices } from '../db/schema'; import { decryptSecret, encryptSecret } from '../secrets/encryption'; import { getOrCreateMasterKey } from '../secrets/master-key'; import type { ContainerService, TestResult } from '../types/infrastructure'; import { EncryptionEnvelopeSchema, parseJsonWithValidation } from '../validation/schemas'; /** * Zod schemas for provider-specific credentials * Validates external data from database storage (Rule 3.7) */ const ProxmoxCredentialsSchema = z.object({ api_url: z.string().url(), api_token_id: z.string().min(1), api_token_secret: z.string().min(1), }); const DigitalOceanCredentialsSchema = z.object({ api_token: z.string().min(1), }); export type ProxmoxCredentials = z.infer; export type DigitalOceanCredentials = z.infer; export type ServiceCredentials = ProxmoxCredentials | DigitalOceanCredentials; /** * Container service filters */ export interface ContainerServiceFilters { zones?: NetworkZone[]; } /** * Generate a service ID from a human-readable name * Converts to kebab-case, removes special characters */ function generateServiceId(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens } /** * Add a new container service */ export async function addContainerService( service: Omit< ContainerService, | 'id' | 'serviceId' | 'createdAt' | 'updatedAt' | 'apiCredentialsEncrypted' | 'verified' | 'verifiedAt' | 'verificationError' > & { apiCredentials: Record; }, ): Promise { const db = getDb(); const id = randomUUID(); const serviceId = generateServiceId(service.name); const now = new Date(); // Check if service ID already exists const existing = await db .select() .from(containerServices) .where(eq(containerServices.serviceId, serviceId)) .limit(1); if (existing.length > 0) { throw new Error(`Service ID '${serviceId}' already exists. Please choose a different name.`); } // Encrypt API credentials const masterKey = await getOrCreateMasterKey(); const encrypted = encryptSecret(JSON.stringify(service.apiCredentials), masterKey); const values = { id, serviceId, name: service.name, providerName: service.providerName, zones: service.zones, // Drizzle auto-stringifies with mode: 'json' apiCredentialsEncrypted: JSON.stringify(encrypted), providerConfig: service.providerConfig, // Drizzle auto-stringifies with mode: 'json' verified: false, verifiedAt: null, verificationError: null, createdAt: now, updatedAt: now, }; await db.insert(containerServices).values(values); return { id, serviceId, name: service.name, providerName: service.providerName, zones: service.zones, apiCredentialsEncrypted: JSON.stringify(encrypted), providerConfig: service.providerConfig, verified: false, verifiedAt: null, verificationError: null, createdAt: now, updatedAt: now, }; } /** * Get container service by ID */ export async function getContainerService(id: string): Promise { const db = getDb(); const result = await db .select() .from(containerServices) .where(eq(containerServices.id, id)) .limit(1); if (result.length === 0) { return null; } const row = result[0]; return { id: row.id, serviceId: row.serviceId, name: row.name, providerName: row.providerName as ContainerService['providerName'], zones: row.zones, // Drizzle auto-parses with mode: 'json' apiCredentialsEncrypted: row.apiCredentialsEncrypted, providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json' verified: Boolean(row.verified), verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null, verificationError: row.verificationError, createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt), }; } /** * Get container service by service ID (user-facing identifier) */ export async function getContainerServiceByServiceId( serviceId: string, ): Promise { const db = getDb(); const result = await db .select() .from(containerServices) .where(eq(containerServices.serviceId, serviceId)) .limit(1); if (result.length === 0) { return null; } const row = result[0]; return { id: row.id, serviceId: row.serviceId, name: row.name, providerName: row.providerName as ContainerService['providerName'], zones: row.zones, // Drizzle auto-parses with mode: 'json' apiCredentialsEncrypted: row.apiCredentialsEncrypted, providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json' verified: Boolean(row.verified), verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null, verificationError: row.verificationError, createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt), }; } /** * Get container service by name */ export async function getContainerServiceByName(name: string): Promise { const db = getDb(); const result = await db .select() .from(containerServices) .where(eq(containerServices.name, name)) .limit(1); if (result.length === 0) { return null; } const row = result[0]; return { id: row.id, serviceId: row.serviceId, name: row.name, providerName: row.providerName as ContainerService['providerName'], zones: row.zones, // Drizzle auto-parses with mode: 'json' apiCredentialsEncrypted: row.apiCredentialsEncrypted, providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json' verified: Boolean(row.verified), verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null, verificationError: row.verificationError, createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt), }; } /** * Get decrypted API credentials for a service * Returns validated, typed credentials based on provider (Rule 3.7) */ export async function getServiceCredentials(serviceId: string): Promise { const service = await getContainerService(serviceId); if (!service) { throw new Error(`Container service not found: ${serviceId}`); } const masterKey = await getOrCreateMasterKey(); const encrypted = parseJsonWithValidation( service.apiCredentialsEncrypted, EncryptionEnvelopeSchema, 'service credentials encryption envelope', ); const decrypted = decryptSecret(encrypted, masterKey); const parsed = JSON.parse(decrypted); // Validate based on provider type if (service.providerName === 'proxmox') { return ProxmoxCredentialsSchema.parse(parsed); } if (service.providerName === 'digitalocean') { return DigitalOceanCredentialsSchema.parse(parsed); } throw new Error( `Unsupported provider for credential validation: ${service.providerName}. Supported providers: proxmox, digitalocean`, ); } /** * Replace a container service's encrypted API credentials. * * Credentials identify the remote provider endpoint as well as the principal * used there, so changing either invalidates the previous verification result. * Callers should explicitly re-run service verification after this update. */ export async function updateServiceCredentials( id: string, credentials: ServiceCredentials, ): Promise { const service = await getContainerService(id); if (!service) { throw new Error(`Container service not found: ${id}`); } const validated = service.providerName === 'proxmox' ? ProxmoxCredentialsSchema.parse(credentials) : service.providerName === 'digitalocean' ? DigitalOceanCredentialsSchema.parse(credentials) : null; if (!validated) { throw new Error( `Unsupported provider for credential validation: ${service.providerName}. Supported providers: proxmox, digitalocean`, ); } const masterKey = await getOrCreateMasterKey(); const encrypted = encryptSecret(JSON.stringify(validated), masterKey); await getDb() .update(containerServices) .set({ apiCredentialsEncrypted: JSON.stringify(encrypted), verified: false, verifiedAt: null, verificationError: null, updatedAt: new Date(), }) .where(eq(containerServices.id, id)); } /** * List container services with optional filters */ export async function listContainerServices( filters?: ContainerServiceFilters, ): Promise { const db = getDb(); const results = await db.select().from(containerServices); let services = results.map((row) => ({ id: row.id, serviceId: row.serviceId, name: row.name, providerName: row.providerName as ContainerService['providerName'], zones: row.zones, // Drizzle auto-parses with mode: 'json' apiCredentialsEncrypted: row.apiCredentialsEncrypted, providerConfig: row.providerConfig, // Drizzle auto-parses with mode: 'json' verified: Boolean(row.verified), verifiedAt: row.verifiedAt ? new Date(row.verifiedAt) : null, verificationError: row.verificationError, createdAt: new Date(row.createdAt), updatedAt: new Date(row.updatedAt), })); // Apply zone filter if (filters?.zones && filters.zones.length > 0) { services = services.filter((service) => filters.zones?.some((zone) => service.zones.includes(zone)), ); } return services; } /** * Remove a container service */ /** * Update provider configuration for a container service */ export async function updateServiceProviderConfig( id: string, providerConfig: Record, ): Promise { const db = getDb(); await db .update(containerServices) .set({ providerConfig, updatedAt: new Date(), }) .where(eq(containerServices.id, id)); } export async function removeContainerService(id: string): Promise { const db = getDb(); await db.delete(containerServices).where(eq(containerServices.id, id)); } /** * Test connection to a container service * Validates credentials and connectivity for the provider */ export async function testConnection(service: ContainerService): Promise { const credentials = await getServiceCredentials(service.id); if (service.providerName === 'proxmox') { const { testProxmoxConnection } = await import('../api-clients/proxmox'); const proxmoxCreds = credentials as ProxmoxCredentials; // Cast providerConfig to expected shape const providerConfig = service.providerConfig as { default_target_node: string; lxc_template: string; storage: string; }; return testProxmoxConnection( { api_url: proxmoxCreds.api_url, api_token_id: proxmoxCreds.api_token_id, api_token_secret: proxmoxCreds.api_token_secret, }, providerConfig, ); } if (service.providerName === 'digitalocean') { const { testDigitalOceanConnection } = await import('../api-clients/digitalocean'); const doCreds = credentials as DigitalOceanCredentials; return testDigitalOceanConnection({ api_token: doCreds.api_token, }); } return { success: false, message: `Connection test not implemented for provider: ${service.providerName}`, }; } /** * Update verification status for a container service */ export async function updateVerificationStatus( serviceId: string, testResult: TestResult, ): Promise { const db = getDb(); const now = new Date(); await db .update(containerServices) .set({ verified: testResult.success, verifiedAt: testResult.success ? now : null, verificationError: testResult.success ? null : testResult.message || 'Connection test failed', updatedAt: now, }) .where(eq(containerServices.id, serviceId)); } /** * Verify a container service and update its status * Returns the updated service */ export async function verifyContainerService(serviceId: string): Promise<{ service: ContainerService; testResult: TestResult; }> { const service = await getContainerService(serviceId); if (!service) { throw new Error(`Container service not found: ${serviceId}`); } const testResult = await testConnection(service); await updateVerificationStatus(serviceId, testResult); // Fetch updated service const updatedService = await getContainerService(serviceId); if (!updatedService) { throw new Error(`Failed to fetch updated service: ${serviceId}`); } return { service: updatedService, testResult }; }