feat(appstore): add remote app store templates with service upgrades and Redis/MariaDB platform support
This commit is contained in:
73
ts/classes/appstore-types.ts
Normal file
73
ts/classes/appstore-types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* App Store type definitions
|
||||
*/
|
||||
|
||||
export interface ICatalog {
|
||||
schemaVersion: number;
|
||||
updatedAt: string;
|
||||
apps: ICatalogApp[];
|
||||
}
|
||||
|
||||
export interface ICatalogApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
iconName?: string;
|
||||
iconUrl?: string;
|
||||
latestVersion: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface IAppMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
iconName?: string;
|
||||
latestVersion: string;
|
||||
versions: string[];
|
||||
maintainer?: string;
|
||||
links?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface IAppVersionConfig {
|
||||
image: string;
|
||||
port: number;
|
||||
envVars?: Array<{ key: string; value: string; description: string; required?: boolean }>;
|
||||
volumes?: string[];
|
||||
platformRequirements?: {
|
||||
mongodb?: boolean;
|
||||
s3?: boolean;
|
||||
clickhouse?: boolean;
|
||||
redis?: boolean;
|
||||
mariadb?: boolean;
|
||||
};
|
||||
minOneboxVersion?: string;
|
||||
}
|
||||
|
||||
export interface IMigrationContext {
|
||||
service: {
|
||||
name: string;
|
||||
image: string;
|
||||
envVars: Record<string, string>;
|
||||
port: number;
|
||||
};
|
||||
fromVersion: string;
|
||||
toVersion: string;
|
||||
}
|
||||
|
||||
export interface IMigrationResult {
|
||||
success: boolean;
|
||||
envVars?: Record<string, string>;
|
||||
image?: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface IUpgradeableService {
|
||||
serviceName: string;
|
||||
appTemplateId: string;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
hasMigration: boolean;
|
||||
}
|
||||
335
ts/classes/appstore.ts
Normal file
335
ts/classes/appstore.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* App Store Manager
|
||||
* Fetches, caches, and serves app templates from the remote appstore-apptemplates repo.
|
||||
* The remote repo is the single source of truth — no fallback catalog.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ICatalog,
|
||||
ICatalogApp,
|
||||
IAppMeta,
|
||||
IAppVersionConfig,
|
||||
IMigrationContext,
|
||||
IMigrationResult,
|
||||
IUpgradeableService,
|
||||
} from './appstore-types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
import type { IService } from '../types.ts';
|
||||
|
||||
export class AppStoreManager {
|
||||
private oneboxRef: Onebox;
|
||||
private catalogCache: ICatalog | null = null;
|
||||
private lastFetchTime = 0;
|
||||
private readonly repoBaseUrl = 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
|
||||
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
await this.getCatalog();
|
||||
logger.info(`App Store initialized with ${this.catalogCache?.apps.length || 0} templates`);
|
||||
} catch (error) {
|
||||
logger.warn(`App Store initialization failed: ${getErrorMessage(error)}`);
|
||||
logger.warn('App Store will retry on next request');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the catalog (cached, refreshes after TTL)
|
||||
*/
|
||||
async getCatalog(): Promise<ICatalog> {
|
||||
const now = Date.now();
|
||||
if (this.catalogCache && (now - this.lastFetchTime) < this.cacheTtlMs) {
|
||||
return this.catalogCache;
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = await this.fetchJson('catalog.json') as ICatalog;
|
||||
if (catalog && catalog.apps && Array.isArray(catalog.apps)) {
|
||||
this.catalogCache = catalog;
|
||||
this.lastFetchTime = now;
|
||||
return catalog;
|
||||
}
|
||||
throw new Error('Invalid catalog format');
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to fetch remote catalog: ${getErrorMessage(error)}`);
|
||||
// Return cached if available, otherwise return empty catalog
|
||||
if (this.catalogCache) {
|
||||
return this.catalogCache;
|
||||
}
|
||||
return { schemaVersion: 1, updatedAt: '', apps: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the catalog apps list (convenience method for the API)
|
||||
*/
|
||||
async getApps(): Promise<ICatalogApp[]> {
|
||||
const catalog = await this.getCatalog();
|
||||
return catalog.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch app metadata (versions list, etc.)
|
||||
*/
|
||||
async getAppMeta(appId: string): Promise<IAppMeta> {
|
||||
try {
|
||||
return await this.fetchJson(`apps/${appId}/app.json`) as IAppMeta;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch metadata for app '${appId}': ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch full config for an app version
|
||||
*/
|
||||
async getAppVersionConfig(appId: string, version: string): Promise<IAppVersionConfig> {
|
||||
try {
|
||||
return await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch config for ${appId}@${version}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare deployed services against catalog to find those with available upgrades
|
||||
*/
|
||||
async getUpgradeableServices(): Promise<IUpgradeableService[]> {
|
||||
const catalog = await this.getCatalog();
|
||||
const services = this.oneboxRef.database.getAllServices();
|
||||
const upgradeable: IUpgradeableService[] = [];
|
||||
|
||||
for (const service of services) {
|
||||
if (!service.appTemplateId || !service.appTemplateVersion) continue;
|
||||
|
||||
const catalogApp = catalog.apps.find(a => a.id === service.appTemplateId);
|
||||
if (!catalogApp) continue;
|
||||
|
||||
if (catalogApp.latestVersion !== service.appTemplateVersion) {
|
||||
// Check if a migration script exists
|
||||
const hasMigration = await this.hasMigrationScript(
|
||||
service.appTemplateId,
|
||||
service.appTemplateVersion,
|
||||
catalogApp.latestVersion,
|
||||
);
|
||||
|
||||
upgradeable.push({
|
||||
serviceName: service.name,
|
||||
appTemplateId: service.appTemplateId,
|
||||
currentVersion: service.appTemplateVersion,
|
||||
latestVersion: catalogApp.latestVersion,
|
||||
hasMigration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return upgradeable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a migration script exists for a specific version transition
|
||||
*/
|
||||
async hasMigrationScript(appId: string, fromVersion: string, toVersion: string): Promise<boolean> {
|
||||
try {
|
||||
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`;
|
||||
await this.fetchText(scriptPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a migration in a sandboxed Deno child process
|
||||
*/
|
||||
async executeMigration(service: IService, fromVersion: string, toVersion: string): Promise<IMigrationResult> {
|
||||
const appId = service.appTemplateId;
|
||||
if (!appId) {
|
||||
throw new Error('Service has no appTemplateId');
|
||||
}
|
||||
|
||||
// Fetch the migration script
|
||||
const scriptPath = `apps/${appId}/versions/${toVersion}/migrate-from-${fromVersion}.ts`;
|
||||
let scriptContent: string;
|
||||
try {
|
||||
scriptContent = await this.fetchText(scriptPath);
|
||||
} catch {
|
||||
// No migration script — do a simple config-based upgrade
|
||||
logger.info(`No migration script for ${appId} ${fromVersion} -> ${toVersion}, using config-only upgrade`);
|
||||
const config = await this.getAppVersionConfig(appId, toVersion);
|
||||
return {
|
||||
success: true,
|
||||
image: config.image,
|
||||
envVars: undefined, // Keep existing env vars
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
const tempFile = `/tmp/onebox-migration-${crypto.randomUUID()}.ts`;
|
||||
await Deno.writeTextFile(tempFile, scriptContent);
|
||||
|
||||
try {
|
||||
// Prepare context
|
||||
const context: IMigrationContext = {
|
||||
service: {
|
||||
name: service.name,
|
||||
image: service.image,
|
||||
envVars: service.envVars,
|
||||
port: service.port,
|
||||
},
|
||||
fromVersion,
|
||||
toVersion,
|
||||
};
|
||||
|
||||
// Execute in sandboxed Deno child process
|
||||
const cmd = new Deno.Command('deno', {
|
||||
args: ['run', '--allow-env', '--allow-net=none', '--allow-read=none', '--allow-write=none', tempFile],
|
||||
stdin: 'piped',
|
||||
stdout: 'piped',
|
||||
stderr: 'piped',
|
||||
});
|
||||
|
||||
const child = cmd.spawn();
|
||||
|
||||
// Write context to stdin
|
||||
const writer = child.stdin.getWriter();
|
||||
await writer.write(new TextEncoder().encode(JSON.stringify(context)));
|
||||
await writer.close();
|
||||
|
||||
// Read result
|
||||
const output = await child.output();
|
||||
const exitCode = output.code;
|
||||
const stdout = new TextDecoder().decode(output.stdout);
|
||||
const stderr = new TextDecoder().decode(output.stderr);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
logger.error(`Migration script failed (exit ${exitCode}): ${stderr.substring(0, 500)}`);
|
||||
return {
|
||||
success: false,
|
||||
warnings: [`Migration script failed: ${stderr.substring(0, 200)}`],
|
||||
};
|
||||
}
|
||||
|
||||
// Parse result from stdout
|
||||
try {
|
||||
const result = JSON.parse(stdout) as IMigrationResult;
|
||||
result.success = true;
|
||||
return result;
|
||||
} catch {
|
||||
logger.error(`Failed to parse migration output: ${stdout.substring(0, 200)}`);
|
||||
return {
|
||||
success: false,
|
||||
warnings: ['Migration script produced invalid output'],
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
// Cleanup temp file
|
||||
try {
|
||||
await Deno.remove(tempFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an upgrade: update image, env vars, recreate container
|
||||
*/
|
||||
async applyUpgrade(
|
||||
serviceName: string,
|
||||
migrationResult: IMigrationResult,
|
||||
newVersion: string,
|
||||
): Promise<IService> {
|
||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||
if (!service) {
|
||||
throw new Error(`Service not found: ${serviceName}`);
|
||||
}
|
||||
|
||||
// Stop the existing container
|
||||
if (service.containerID && service.status === 'running') {
|
||||
await this.oneboxRef.services.stopService(serviceName);
|
||||
}
|
||||
|
||||
// Update service record
|
||||
const updates: Partial<IService> = {
|
||||
appTemplateVersion: newVersion,
|
||||
};
|
||||
|
||||
if (migrationResult.image) {
|
||||
updates.image = migrationResult.image;
|
||||
}
|
||||
|
||||
if (migrationResult.envVars) {
|
||||
// Merge: migration result provides base, user overrides preserved
|
||||
const mergedEnvVars = { ...migrationResult.envVars };
|
||||
// Keep any user-set env vars that aren't in the migration result
|
||||
for (const [key, value] of Object.entries(service.envVars)) {
|
||||
if (!(key in mergedEnvVars)) {
|
||||
mergedEnvVars[key] = value;
|
||||
}
|
||||
}
|
||||
updates.envVars = mergedEnvVars;
|
||||
}
|
||||
|
||||
this.oneboxRef.database.updateService(service.id!, updates);
|
||||
|
||||
// Pull new image if changed
|
||||
const newImage = migrationResult.image || service.image;
|
||||
if (migrationResult.image && migrationResult.image !== service.image) {
|
||||
await this.oneboxRef.docker.pullImage(newImage);
|
||||
}
|
||||
|
||||
// Recreate and start container
|
||||
const updatedService = this.oneboxRef.database.getServiceByName(serviceName)!;
|
||||
|
||||
// Remove old container
|
||||
if (service.containerID) {
|
||||
try {
|
||||
await this.oneboxRef.docker.removeContainer(service.containerID, true);
|
||||
} catch {
|
||||
// Container might already be gone
|
||||
}
|
||||
}
|
||||
|
||||
// Create new container
|
||||
const containerID = await this.oneboxRef.docker.createContainer(updatedService);
|
||||
this.oneboxRef.database.updateService(service.id!, { containerID, status: 'starting' });
|
||||
|
||||
// Start container
|
||||
await this.oneboxRef.docker.startContainer(containerID);
|
||||
this.oneboxRef.database.updateService(service.id!, { status: 'running' });
|
||||
|
||||
logger.success(`Service '${serviceName}' upgraded to template version ${newVersion}`);
|
||||
return this.oneboxRef.database.getServiceByName(serviceName)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from the remote repo
|
||||
*/
|
||||
private async fetchJson(path: string): Promise<unknown> {
|
||||
const url = `${this.repoBaseUrl}/${path}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch text from the remote repo
|
||||
*/
|
||||
private async fetchText(path: string): Promise<string> {
|
||||
const url = `${this.repoBaseUrl}/${path}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,12 @@ export class BackupManager {
|
||||
case 'clickhouse':
|
||||
await this.exportClickHouseDatabase(dataDir, resource, credentials);
|
||||
break;
|
||||
case 'mariadb':
|
||||
await this.exportMariaDBDatabase(dataDir, resource, credentials);
|
||||
break;
|
||||
case 'redis':
|
||||
await this.exportRedisData(dataDir, resource, credentials);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,6 +364,8 @@ export class BackupManager {
|
||||
enableMongoDB: serviceConfig.platformRequirements?.mongodb,
|
||||
enableS3: serviceConfig.platformRequirements?.s3,
|
||||
enableClickHouse: serviceConfig.platformRequirements?.clickhouse,
|
||||
enableRedis: serviceConfig.platformRequirements?.redis,
|
||||
enableMariaDB: serviceConfig.platformRequirements?.mariadb,
|
||||
};
|
||||
|
||||
service = await this.oneboxRef.services.deployService(deployOptions);
|
||||
@@ -789,6 +797,24 @@ export class BackupManager {
|
||||
);
|
||||
restoredCount++;
|
||||
break;
|
||||
case 'mariadb':
|
||||
await this.importMariaDBDatabase(
|
||||
dataDir,
|
||||
existing.resource,
|
||||
existing.credentials,
|
||||
backupResource.resourceName
|
||||
);
|
||||
restoredCount++;
|
||||
break;
|
||||
case 'redis':
|
||||
await this.importRedisData(
|
||||
dataDir,
|
||||
existing.resource,
|
||||
existing.credentials,
|
||||
backupResource.resourceName
|
||||
);
|
||||
restoredCount++;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
@@ -1003,6 +1029,230 @@ export class BackupManager {
|
||||
logger.success(`ClickHouse database imported: ${resource.resourceName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export MariaDB database
|
||||
*/
|
||||
private async exportMariaDBDatabase(
|
||||
dataDir: string,
|
||||
resource: IPlatformResource,
|
||||
credentials: Record<string, string>,
|
||||
): Promise<void> {
|
||||
logger.info(`Exporting MariaDB database: ${resource.resourceName}`);
|
||||
|
||||
const mariadbService = this.oneboxRef.database.getPlatformServiceByType('mariadb');
|
||||
if (!mariadbService || !mariadbService.containerId) {
|
||||
throw new Error('MariaDB service not running');
|
||||
}
|
||||
|
||||
const dbName = credentials.database || resource.resourceName;
|
||||
const user = credentials.username || 'root';
|
||||
const password = credentials.password || '';
|
||||
|
||||
if (!dbName) {
|
||||
throw new Error('MariaDB database name not found in credentials');
|
||||
}
|
||||
|
||||
// Use mariadb-dump via docker exec
|
||||
const result = await this.oneboxRef.docker.execInContainer(mariadbService.containerId, [
|
||||
'mariadb-dump',
|
||||
'-u', user,
|
||||
`-p${password}`,
|
||||
'--single-transaction',
|
||||
'--routines',
|
||||
'--triggers',
|
||||
dbName,
|
||||
]);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`MariaDB dump failed: ${result.stderr.substring(0, 500)}`);
|
||||
}
|
||||
|
||||
await Deno.writeTextFile(`${dataDir}/${resource.resourceName}.sql`, result.stdout);
|
||||
logger.success(`MariaDB database exported: ${resource.resourceName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import MariaDB database
|
||||
*/
|
||||
private async importMariaDBDatabase(
|
||||
dataDir: string,
|
||||
resource: IPlatformResource,
|
||||
credentials: Record<string, string>,
|
||||
backupResourceName: string,
|
||||
): Promise<void> {
|
||||
logger.info(`Importing MariaDB database: ${resource.resourceName}`);
|
||||
|
||||
const mariadbService = this.oneboxRef.database.getPlatformServiceByType('mariadb');
|
||||
if (!mariadbService || !mariadbService.containerId) {
|
||||
throw new Error('MariaDB service not running');
|
||||
}
|
||||
|
||||
const dbName = credentials.database || resource.resourceName;
|
||||
const user = credentials.username || 'root';
|
||||
const password = credentials.password || '';
|
||||
|
||||
if (!dbName) {
|
||||
throw new Error('MariaDB database name not found');
|
||||
}
|
||||
|
||||
// Read the SQL dump
|
||||
const sqlPath = `${dataDir}/${backupResourceName}.sql`;
|
||||
let sqlContent: string;
|
||||
try {
|
||||
sqlContent = await Deno.readTextFile(sqlPath);
|
||||
} catch {
|
||||
logger.warn(`MariaDB dump file not found: ${sqlPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sqlContent.trim()) {
|
||||
logger.warn(`MariaDB dump file is empty: ${sqlPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split into individual statements and execute
|
||||
const statements = sqlContent
|
||||
.split(';\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0 && !s.startsWith('--') && !s.startsWith('/*'));
|
||||
|
||||
for (const statement of statements) {
|
||||
const result = await this.oneboxRef.docker.execInContainer(mariadbService.containerId, [
|
||||
'mariadb',
|
||||
'-u', user,
|
||||
`-p${password}`,
|
||||
dbName,
|
||||
'-e', statement + ';',
|
||||
]);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.warn(`MariaDB statement failed: ${result.stderr.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`MariaDB database imported: ${resource.resourceName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Redis data
|
||||
*/
|
||||
private async exportRedisData(
|
||||
dataDir: string,
|
||||
resource: IPlatformResource,
|
||||
credentials: Record<string, string>,
|
||||
): Promise<void> {
|
||||
logger.info(`Exporting Redis data: ${resource.resourceName}`);
|
||||
|
||||
const redisService = this.oneboxRef.database.getPlatformServiceByType('redis');
|
||||
if (!redisService || !redisService.containerId) {
|
||||
throw new Error('Redis service not running');
|
||||
}
|
||||
|
||||
const password = credentials.password || '';
|
||||
const dbIndex = credentials.db || '0';
|
||||
|
||||
// Trigger a BGSAVE to ensure data is flushed
|
||||
await this.oneboxRef.docker.execInContainer(redisService.containerId, [
|
||||
'redis-cli', '-a', password, 'BGSAVE',
|
||||
]);
|
||||
|
||||
// Wait for save to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Get all keys in the specific database and export them
|
||||
const keysResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
|
||||
'redis-cli', '-a', password, '-n', dbIndex, 'KEYS', '*',
|
||||
]);
|
||||
|
||||
if (keysResult.exitCode !== 0) {
|
||||
throw new Error(`Redis KEYS failed: ${keysResult.stderr.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const keys = keysResult.stdout.trim().split('\n').filter(k => k.length > 0);
|
||||
const exportData: Record<string, { type: string; value: string; ttl: number }> = {};
|
||||
|
||||
for (const key of keys) {
|
||||
// Get key type
|
||||
const typeResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
|
||||
'redis-cli', '-a', password, '-n', dbIndex, 'TYPE', key,
|
||||
]);
|
||||
const keyType = typeResult.stdout.trim();
|
||||
|
||||
// Get TTL
|
||||
const ttlResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
|
||||
'redis-cli', '-a', password, '-n', dbIndex, 'TTL', key,
|
||||
]);
|
||||
const ttl = parseInt(ttlResult.stdout.trim(), 10);
|
||||
|
||||
// DUMP the key (binary-safe serialization)
|
||||
const dumpResult = await this.oneboxRef.docker.execInContainer(redisService.containerId, [
|
||||
'redis-cli', '-a', password, '-n', dbIndex, '--no-auth-warning', 'DUMP', key,
|
||||
]);
|
||||
|
||||
exportData[key] = {
|
||||
type: keyType,
|
||||
value: dumpResult.stdout,
|
||||
ttl: ttl > 0 ? ttl : 0,
|
||||
};
|
||||
}
|
||||
|
||||
await Deno.writeTextFile(
|
||||
`${dataDir}/${resource.resourceName}.json`,
|
||||
JSON.stringify({ dbIndex, keys: exportData }, null, 2)
|
||||
);
|
||||
|
||||
logger.success(`Redis data exported: ${resource.resourceName} (${keys.length} keys)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import Redis data
|
||||
*/
|
||||
private async importRedisData(
|
||||
dataDir: string,
|
||||
resource: IPlatformResource,
|
||||
credentials: Record<string, string>,
|
||||
backupResourceName: string,
|
||||
): Promise<void> {
|
||||
logger.info(`Importing Redis data: ${resource.resourceName}`);
|
||||
|
||||
const redisService = this.oneboxRef.database.getPlatformServiceByType('redis');
|
||||
if (!redisService || !redisService.containerId) {
|
||||
throw new Error('Redis service not running');
|
||||
}
|
||||
|
||||
const password = credentials.password || '';
|
||||
const dbIndex = credentials.db || '0';
|
||||
|
||||
// Read the export file
|
||||
const jsonPath = `${dataDir}/${backupResourceName}.json`;
|
||||
let exportContent: string;
|
||||
try {
|
||||
exportContent = await Deno.readTextFile(jsonPath);
|
||||
} catch {
|
||||
logger.warn(`Redis export file not found: ${jsonPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const exportData = JSON.parse(exportContent);
|
||||
const keys = exportData.keys || {};
|
||||
let importedCount = 0;
|
||||
|
||||
for (const [key, data] of Object.entries(keys) as Array<[string, { type: string; value: string; ttl: number }]>) {
|
||||
// Use RESTORE to import the serialized key
|
||||
const args = ['redis-cli', '-a', password, '-n', dbIndex, 'RESTORE', key, String(data.ttl * 1000), data.value, 'REPLACE'];
|
||||
|
||||
const result = await this.oneboxRef.docker.execInContainer(redisService.containerId, args);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.warn(`Redis RESTORE failed for key '${key}': ${result.stderr.substring(0, 200)}`);
|
||||
} else {
|
||||
importedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`Redis data imported: ${resource.resourceName} (${importedCount} keys)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create tar archive from directory
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CloudflareDomainSync } from './cloudflare-sync.ts';
|
||||
import { CertRequirementManager } from './cert-requirement-manager.ts';
|
||||
import { RegistryManager } from './registry.ts';
|
||||
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||
import { AppStoreManager } from './appstore.ts';
|
||||
import { CaddyLogReceiver } from './caddy-log-receiver.ts';
|
||||
import { BackupManager } from './backup-manager.ts';
|
||||
import { BackupScheduler } from './backup-scheduler.ts';
|
||||
@@ -40,6 +41,7 @@ export class Onebox {
|
||||
public certRequirementManager: CertRequirementManager;
|
||||
public registry: RegistryManager;
|
||||
public platformServices: PlatformServicesManager;
|
||||
public appStore: AppStoreManager;
|
||||
public caddyLogReceiver: CaddyLogReceiver;
|
||||
public backupManager: BackupManager;
|
||||
public backupScheduler: BackupScheduler;
|
||||
@@ -74,6 +76,9 @@ export class Onebox {
|
||||
// Initialize platform services manager
|
||||
this.platformServices = new PlatformServicesManager(this);
|
||||
|
||||
// Initialize App Store manager
|
||||
this.appStore = new AppStoreManager(this);
|
||||
|
||||
// Initialize Caddy log receiver
|
||||
this.caddyLogReceiver = new CaddyLogReceiver(9999);
|
||||
|
||||
@@ -173,6 +178,14 @@ export class Onebox {
|
||||
logger.warn(`Error: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// Initialize App Store (non-critical)
|
||||
try {
|
||||
await this.appStore.init();
|
||||
} catch (error) {
|
||||
logger.warn('App Store initialization failed - app templates will be unavailable until reconnected');
|
||||
logger.warn(`Error: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
// Login to all registries
|
||||
await this.registries.loginToAllRegistries();
|
||||
|
||||
|
||||
@@ -8,3 +8,6 @@ export type { IPlatformServiceProvider } from './providers/base.ts';
|
||||
export { BasePlatformServiceProvider } from './providers/base.ts';
|
||||
export { MongoDBProvider } from './providers/mongodb.ts';
|
||||
export { MinioProvider } from './providers/minio.ts';
|
||||
export { ClickHouseProvider } from './providers/clickhouse.ts';
|
||||
export { MariaDBProvider } from './providers/mariadb.ts';
|
||||
export { RedisProvider } from './providers/redis.ts';
|
||||
|
||||
@@ -16,6 +16,8 @@ import { MongoDBProvider } from './providers/mongodb.ts';
|
||||
import { MinioProvider } from './providers/minio.ts';
|
||||
import { CaddyProvider } from './providers/caddy.ts';
|
||||
import { ClickHouseProvider } from './providers/clickhouse.ts';
|
||||
import { MariaDBProvider } from './providers/mariadb.ts';
|
||||
import { RedisProvider } from './providers/redis.ts';
|
||||
import { logger } from '../../logging.ts';
|
||||
import { getErrorMessage } from '../../utils/error.ts';
|
||||
import { credentialEncryption } from '../encryption.ts';
|
||||
@@ -41,6 +43,8 @@ export class PlatformServicesManager {
|
||||
this.registerProvider(new MinioProvider(this.oneboxRef));
|
||||
this.registerProvider(new CaddyProvider(this.oneboxRef));
|
||||
this.registerProvider(new ClickHouseProvider(this.oneboxRef));
|
||||
this.registerProvider(new MariaDBProvider(this.oneboxRef));
|
||||
this.registerProvider(new RedisProvider(this.oneboxRef));
|
||||
|
||||
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
|
||||
}
|
||||
@@ -304,6 +308,60 @@ export class PlatformServicesManager {
|
||||
logger.success(`ClickHouse provisioned for service '${service.name}'`);
|
||||
}
|
||||
|
||||
// Provision Redis if requested
|
||||
if (requirements.redis) {
|
||||
logger.info(`Provisioning Redis for service '${service.name}'...`);
|
||||
|
||||
// Ensure Redis is running
|
||||
const redisService = await this.ensureRunning('redis');
|
||||
const provider = this.providers.get('redis')!;
|
||||
|
||||
// Provision cache resource
|
||||
const result = await provider.provisionResource(service);
|
||||
|
||||
// Store resource record
|
||||
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||
this.oneboxRef.database.createPlatformResource({
|
||||
platformServiceId: redisService.id!,
|
||||
serviceId: service.id!,
|
||||
resourceType: result.type,
|
||||
resourceName: result.name,
|
||||
credentialsEncrypted: encryptedCreds,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Merge env vars
|
||||
Object.assign(allEnvVars, result.envVars);
|
||||
logger.success(`Redis provisioned for service '${service.name}'`);
|
||||
}
|
||||
|
||||
// Provision MariaDB if requested
|
||||
if (requirements.mariadb) {
|
||||
logger.info(`Provisioning MariaDB for service '${service.name}'...`);
|
||||
|
||||
// Ensure MariaDB is running
|
||||
const mariadbService = await this.ensureRunning('mariadb');
|
||||
const provider = this.providers.get('mariadb')!;
|
||||
|
||||
// Provision database
|
||||
const result = await provider.provisionResource(service);
|
||||
|
||||
// Store resource record
|
||||
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||
this.oneboxRef.database.createPlatformResource({
|
||||
platformServiceId: mariadbService.id!,
|
||||
serviceId: service.id!,
|
||||
resourceType: result.type,
|
||||
resourceName: result.name,
|
||||
credentialsEncrypted: encryptedCreds,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Merge env vars
|
||||
Object.assign(allEnvVars, result.envVars);
|
||||
logger.success(`MariaDB provisioned for service '${service.name}'`);
|
||||
}
|
||||
|
||||
return allEnvVars;
|
||||
}
|
||||
|
||||
|
||||
279
ts/classes/platform-services/providers/mariadb.ts
Normal file
279
ts/classes/platform-services/providers/mariadb.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* MariaDB 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 MariaDBProvider extends BasePlatformServiceProvider {
|
||||
readonly type: TPlatformServiceType = 'mariadb';
|
||||
readonly displayName = 'MariaDB';
|
||||
readonly resourceTypes: TPlatformResourceType[] = ['database'];
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
super(oneboxRef);
|
||||
}
|
||||
|
||||
getDefaultConfig(): IPlatformServiceConfig {
|
||||
return {
|
||||
image: 'mariadb:11',
|
||||
port: 3306,
|
||||
volumes: ['/var/lib/onebox/mariadb:/var/lib/mysql'],
|
||||
environment: {
|
||||
MARIADB_ROOT_PASSWORD: '',
|
||||
// Password will be generated and stored encrypted
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getEnvVarMappings(): IEnvVarMapping[] {
|
||||
return [
|
||||
{ envVar: 'MARIADB_HOST', credentialPath: 'host' },
|
||||
{ envVar: 'MARIADB_PORT', credentialPath: 'port' },
|
||||
{ envVar: 'MARIADB_DATABASE', credentialPath: 'database' },
|
||||
{ envVar: 'MARIADB_USER', credentialPath: 'username' },
|
||||
{ envVar: 'MARIADB_PASSWORD', credentialPath: 'password' },
|
||||
{ envVar: 'MARIADB_URI', credentialPath: 'connectionString' },
|
||||
];
|
||||
}
|
||||
|
||||
async deployContainer(): Promise<string> {
|
||||
const config = this.getDefaultConfig();
|
||||
const containerName = this.getContainerName();
|
||||
const dataDir = '/var/lib/onebox/mariadb';
|
||||
|
||||
logger.info(`Deploying MariaDB 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 MariaDB data
|
||||
try {
|
||||
const stat = await Deno.stat(`${dataDir}/ibdata1`);
|
||||
dataExists = stat.isFile;
|
||||
logger.info(`MariaDB data directory exists with ibdata1 file`);
|
||||
} catch {
|
||||
// ibdata1 file doesn't exist, this is a fresh install
|
||||
dataExists = false;
|
||||
}
|
||||
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing MariaDB credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new MariaDB admin credentials');
|
||||
adminCredentials = {
|
||||
username: 'root',
|
||||
password: credentialEncryption.generatePassword(32),
|
||||
};
|
||||
|
||||
// If data exists but we don't have credentials, we need to wipe the data
|
||||
if (dataExists) {
|
||||
logger.warn('MariaDB data exists but no credentials in database - wiping data directory');
|
||||
try {
|
||||
await Deno.remove(dataDir, { recursive: true });
|
||||
} catch (e) {
|
||||
logger.error(`Failed to wipe MariaDB data directory: ${getErrorMessage(e)}`);
|
||||
throw new Error('Cannot deploy MariaDB: 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 MariaDB data directory: ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create container using Docker API
|
||||
const envVars = [
|
||||
`MARIADB_ROOT_PASSWORD=${adminCredentials.password}`,
|
||||
];
|
||||
|
||||
// Use Docker to create the container
|
||||
const containerId = await this.oneboxRef.docker.createPlatformContainer({
|
||||
name: containerName,
|
||||
image: config.image,
|
||||
port: config.port,
|
||||
env: envVars,
|
||||
volumes: config.volumes,
|
||||
network: this.getNetworkName(),
|
||||
});
|
||||
|
||||
// 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(`MariaDB container created: ${containerId}`);
|
||||
return containerId;
|
||||
}
|
||||
|
||||
async stopContainer(containerId: string): Promise<void> {
|
||||
logger.info(`Stopping MariaDB container ${containerId}...`);
|
||||
await this.oneboxRef.docker.stopContainer(containerId);
|
||||
logger.success('MariaDB container stopped');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
logger.info('MariaDB health check: starting...');
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService) {
|
||||
logger.info('MariaDB health check: platform service not found in database');
|
||||
return false;
|
||||
}
|
||||
if (!platformService.adminCredentialsEncrypted) {
|
||||
logger.info('MariaDB health check: no admin credentials stored');
|
||||
return false;
|
||||
}
|
||||
if (!platformService.containerId) {
|
||||
logger.info('MariaDB health check: no container ID in database record');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`MariaDB 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,
|
||||
['mariadb-admin', 'ping', '-u', 'root', `-p${adminCreds.password}`]
|
||||
);
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
logger.info('MariaDB health check: success');
|
||||
return true;
|
||||
} else {
|
||||
logger.info(`MariaDB health check failed: exit code ${result.exitCode}, stderr: ${result.stderr.substring(0, 200)}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info(`MariaDB health check exception: ${getErrorMessage(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||
throw new Error('MariaDB platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate resource names and credentials
|
||||
const dbName = this.generateResourceName(userService.name);
|
||||
const username = this.generateResourceName(userService.name);
|
||||
const password = credentialEncryption.generatePassword(32);
|
||||
|
||||
logger.info(`Provisioning MariaDB database '${dbName}' for service '${userService.name}'...`);
|
||||
|
||||
// Create database and user via mariadb inside the container
|
||||
const sql = [
|
||||
`CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`,
|
||||
`CREATE USER IF NOT EXISTS '${username}'@'%' IDENTIFIED BY '${password.replace(/'/g, "\\'")}';`,
|
||||
`GRANT ALL PRIVILEGES ON \`${dbName}\`.* TO '${username}'@'%';`,
|
||||
`FLUSH PRIVILEGES;`,
|
||||
].join(' ');
|
||||
|
||||
const result = await this.oneboxRef.docker.execInContainer(
|
||||
platformService.containerId,
|
||||
[
|
||||
'mariadb',
|
||||
'-u', 'root',
|
||||
`-p${adminCreds.password}`,
|
||||
'-e', sql,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Failed to provision MariaDB database: exit code ${result.exitCode}, output: ${result.stdout.substring(0, 200)} ${result.stderr.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
logger.success(`MariaDB database '${dbName}' provisioned with user '${username}'`);
|
||||
|
||||
// Build the credentials and env vars
|
||||
const credentials: Record<string, string> = {
|
||||
host: containerName,
|
||||
port: '3306',
|
||||
database: dbName,
|
||||
username,
|
||||
password,
|
||||
connectionString: `mysql://${username}:${password}@${containerName}:3306/${dbName}`,
|
||||
};
|
||||
|
||||
// Map credentials to env vars
|
||||
const envVars: Record<string, string> = {};
|
||||
for (const mapping of this.getEnvVarMappings()) {
|
||||
if (credentials[mapping.credentialPath]) {
|
||||
envVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'database',
|
||||
name: dbName,
|
||||
credentials,
|
||||
envVars,
|
||||
};
|
||||
}
|
||||
|
||||
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||
if (!platformService || !platformService.adminCredentialsEncrypted || !platformService.containerId) {
|
||||
throw new Error('MariaDB platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
|
||||
logger.info(`Deprovisioning MariaDB database '${resource.resourceName}'...`);
|
||||
|
||||
const sql = [
|
||||
`DROP USER IF EXISTS '${credentials.username}'@'%';`,
|
||||
`DROP DATABASE IF EXISTS \`${resource.resourceName}\`;`,
|
||||
].join(' ');
|
||||
|
||||
const result = await this.oneboxRef.docker.execInContainer(
|
||||
platformService.containerId,
|
||||
[
|
||||
'mariadb',
|
||||
'-u', 'root',
|
||||
`-p${adminCreds.password}`,
|
||||
'-e', sql,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.warn(`MariaDB deprovision returned exit code ${result.exitCode}: ${result.stderr.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
logger.success(`MariaDB database '${resource.resourceName}' dropped`);
|
||||
}
|
||||
}
|
||||
283
ts/classes/platform-services/providers/redis.ts
Normal file
283
ts/classes/platform-services/providers/redis.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 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<string> {
|
||||
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<void> {
|
||||
logger.info(`Stopping Redis container ${containerId}...`);
|
||||
await this.oneboxRef.docker.stopContainer(containerId);
|
||||
logger.success('Redis container stopped');
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
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<IProvisionedResource> {
|
||||
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<number>();
|
||||
|
||||
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<string, string> = {
|
||||
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<string, string> = {};
|
||||
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<string, string>): Promise<void> {
|
||||
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}'`);
|
||||
}
|
||||
}
|
||||
@@ -50,11 +50,13 @@ export class OneboxServicesManager {
|
||||
|
||||
// Build platform requirements
|
||||
const platformRequirements: IPlatformRequirements | undefined =
|
||||
(options.enableMongoDB || options.enableS3 || options.enableClickHouse)
|
||||
(options.enableMongoDB || options.enableS3 || options.enableClickHouse || options.enableRedis || options.enableMariaDB)
|
||||
? {
|
||||
mongodb: options.enableMongoDB,
|
||||
s3: options.enableS3,
|
||||
clickhouse: options.enableClickHouse,
|
||||
redis: options.enableRedis,
|
||||
mariadb: options.enableMariaDB,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -76,6 +78,9 @@ export class OneboxServicesManager {
|
||||
autoUpdateOnPush: options.autoUpdateOnPush,
|
||||
// Platform requirements
|
||||
platformRequirements,
|
||||
// App Store template tracking
|
||||
appTemplateId: options.appTemplateId,
|
||||
appTemplateVersion: options.appTemplateVersion,
|
||||
});
|
||||
|
||||
// Provision platform resources if needed
|
||||
|
||||
Reference in New Issue
Block a user