/** * Redis Platform Service Provider */ import { BasePlatformServiceProvider } from './base.ts'; import type { IService, IPlatformResource, IPlatformServiceConfig, IProvisionedResource, IEnvVarMapping, TPlatformServiceType, TPlatformResourceType, } from '../../../types.ts'; import { logger } from '../../../logging.ts'; import { getErrorMessage } from '../../../utils/error.ts'; import { credentialEncryption } from '../../encryption.ts'; import type { Onebox } from '../../onebox.ts'; export class RedisProvider extends BasePlatformServiceProvider { readonly type: TPlatformServiceType = 'redis'; readonly displayName = 'Redis'; readonly resourceTypes: TPlatformResourceType[] = ['cache']; constructor(oneboxRef: Onebox) { super(oneboxRef); } getDefaultConfig(): IPlatformServiceConfig { return { image: 'redis:7-alpine', port: 6379, volumes: ['/var/lib/onebox/redis:/data'], environment: {}, }; } getEnvVarMappings(): IEnvVarMapping[] { return [ { envVar: 'REDIS_HOST', credentialPath: 'host' }, { envVar: 'REDIS_PORT', credentialPath: 'port' }, { envVar: 'REDIS_PASSWORD', credentialPath: 'password' }, { envVar: 'REDIS_DB', credentialPath: 'db' }, { envVar: 'REDIS_URL', credentialPath: 'connectionString' }, ]; } async deployContainer(): Promise { const config = this.getDefaultConfig(); const containerName = this.getContainerName(); const dataDir = '/var/lib/onebox/redis'; logger.info(`Deploying Redis platform service as ${containerName}...`); // Check if we have existing data and stored credentials const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); let adminCredentials: { username: string; password: string }; let dataExists = false; // Check if data directory has existing Redis data try { const stat = await Deno.stat(`${dataDir}/dump.rdb`); dataExists = stat.isFile; logger.info(`Redis data directory exists with dump.rdb file`); } catch { // Also check for appendonly file try { const stat = await Deno.stat(`${dataDir}/appendonly.aof`); dataExists = stat.isFile; logger.info(`Redis data directory exists with appendonly.aof file`); } catch { dataExists = false; } } if (dataExists && platformService?.adminCredentialsEncrypted) { // Reuse existing credentials from database logger.info('Reusing existing Redis credentials (data directory already initialized)'); adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); } else { // Generate new credentials for fresh deployment logger.info('Generating new Redis admin credentials'); adminCredentials = { username: 'default', password: credentialEncryption.generatePassword(32), }; // If data exists but we don't have credentials, we need to wipe the data if (dataExists) { logger.warn('Redis data exists but no credentials in database - wiping data directory'); try { await Deno.remove(dataDir, { recursive: true }); } catch (e) { logger.error(`Failed to wipe Redis data directory: ${getErrorMessage(e)}`); throw new Error('Cannot deploy Redis: data directory exists without credentials'); } } } // Ensure data directory exists try { await Deno.mkdir(dataDir, { recursive: true }); } catch (e) { // Directory might already exist if (!(e instanceof Deno.errors.AlreadyExists)) { logger.warn(`Could not create Redis data directory: ${getErrorMessage(e)}`); } } // Redis uses command args for password, not env vars const containerId = await this.oneboxRef.docker.createPlatformContainer({ name: containerName, image: config.image, port: config.port, env: [], volumes: config.volumes, network: this.getNetworkName(), command: ['redis-server', '--requirepass', adminCredentials.password, '--appendonly', 'yes'], }); // Store encrypted admin credentials (only update if new or changed) const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); if (platformService) { this.oneboxRef.database.updatePlatformService(platformService.id!, { containerId, adminCredentialsEncrypted: encryptedCreds, status: 'starting', }); } logger.success(`Redis container created: ${containerId}`); return containerId; } async stopContainer(containerId: string): Promise { logger.info(`Stopping Redis container ${containerId}...`); await this.oneboxRef.docker.stopContainer(containerId); logger.success('Redis container stopped'); } async healthCheck(): Promise { try { logger.info('Redis health check: starting...'); const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); if (!platformService) { logger.info('Redis health check: platform service not found in database'); return false; } if (!platformService.adminCredentialsEncrypted) { logger.info('Redis health check: no admin credentials stored'); return false; } if (!platformService.containerId) { logger.info('Redis health check: no container ID in database record'); return false; } logger.info(`Redis health check: using container ID ${platformService.containerId.substring(0, 12)}...`); const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); // Use docker exec to run health check inside the container const result = await this.oneboxRef.docker.execInContainer( platformService.containerId, ['redis-cli', '-a', adminCreds.password, 'ping'] ); if (result.exitCode === 0 && result.stdout.includes('PONG')) { logger.info('Redis health check: success'); return true; } else { logger.info(`Redis health check failed: exit code ${result.exitCode}, stdout: ${result.stdout.substring(0, 200)}`); return false; } } catch (error) { logger.info(`Redis health check exception: ${getErrorMessage(error)}`); return false; } } async provisionResource(userService: IService): Promise { const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); if (!platformService || !platformService.adminCredentialsEncrypted) { throw new Error('Redis platform service not found or not configured'); } const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const containerName = this.getContainerName(); // Determine the next available DB index (1-15, reserving 0 for admin) const existingResources = this.oneboxRef.database.getPlatformResourcesByPlatformService(platformService.id!); const usedIndexes = new Set(); for (const resource of existingResources) { try { const creds = await credentialEncryption.decrypt(resource.credentialsEncrypted); if (creds.db) { usedIndexes.add(parseInt(creds.db, 10)); } } catch { // Skip resources with corrupt credentials } } let dbIndex = -1; for (let i = 1; i <= 15; i++) { if (!usedIndexes.has(i)) { dbIndex = i; break; } } if (dbIndex === -1) { throw new Error('No available Redis database indexes (max 15 services per Redis instance)'); } const resourceName = this.generateResourceName(userService.name); logger.info(`Provisioning Redis database index ${dbIndex} for service '${userService.name}'...`); // No server-side creation needed - Redis DB indexes exist implicitly // Just verify connectivity if (platformService.containerId) { const result = await this.oneboxRef.docker.execInContainer( platformService.containerId, ['redis-cli', '-a', adminCreds.password, '-n', String(dbIndex), 'ping'] ); if (result.exitCode !== 0 || !result.stdout.includes('PONG')) { throw new Error(`Failed to verify Redis database ${dbIndex}: exit code ${result.exitCode}`); } } logger.success(`Redis database index ${dbIndex} provisioned for service '${userService.name}'`); // Build the credentials and env vars const credentials: Record = { host: containerName, port: '6379', password: adminCreds.password, db: String(dbIndex), connectionString: `redis://:${adminCreds.password}@${containerName}:6379/${dbIndex}`, }; // Map credentials to env vars const envVars: Record = {}; for (const mapping of this.getEnvVarMappings()) { if (credentials[mapping.credentialPath]) { envVars[mapping.envVar] = credentials[mapping.credentialPath]; } } return { type: 'cache', name: resourceName, credentials, envVars, }; } async deprovisionResource(resource: IPlatformResource, credentials: Record): Promise { const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) { throw new Error('Redis platform service not found or not configured'); } const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); const dbIndex = credentials.db || '0'; logger.info(`Deprovisioning Redis database index ${dbIndex} for resource '${resource.resourceName}'...`); // Flush the specific database const result = await this.oneboxRef.docker.execInContainer( platformService.containerId, ['redis-cli', '-a', adminCreds.password, '-n', dbIndex, 'FLUSHDB'] ); if (result.exitCode !== 0) { logger.warn(`Redis deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`); } logger.success(`Redis database index ${dbIndex} flushed for resource '${resource.resourceName}'`); } }