/** * 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 { 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 { 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 { const catalog = await this.getCatalog(); return catalog.apps; } /** * Fetch app metadata (versions list, etc.) */ async getAppMeta(appId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 = { 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 { 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 { const url = `${this.repoBaseUrl}/${path}`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return response.text(); } }