/** * App Store Manager * Fetches, caches, and serves app templates from the remote App Store repo. */ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import type { Onebox } from './onebox.ts'; import type { IService, IServicePublishedPort, IServiceVolume } from '../types.ts'; import { projectInfo } from '../info.ts'; type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex; type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp; type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta; type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig; type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest & { autoDNS?: boolean; }; type IUpgradeableAppStoreService = plugins.servezoneInterfaces.appstore.IUpgradeableAppStoreService; export interface IAppStoreManagerOptions { baseUrl?: string; fetch?: typeof fetch; resolveDockerDigests?: boolean; } export interface IMigrationContext { service: { name: string; image: string; envVars: Record; port: number; }; fromVersion: string; toVersion: string; } export interface IMigrationResult { success: boolean; envVars?: Record; image?: string; imageDigest?: string; port?: number; volumes?: IServiceVolume[]; publishedPorts?: IServicePublishedPort[]; warnings: string[]; } export class AppStoreManager { private appStoreCache: IAppStoreIndex | null = null; private appStoreResolver: plugins.servezoneAppstore.AppStoreResolver; private lastFetchTime = 0; private readonly appStoreBaseUrl: string; private readonly fetchRef: typeof fetch; private readonly resolveDockerDigests: boolean; private readonly cacheTtlMs = 5 * 60 * 1000; constructor( private oneboxRef: Onebox, optionsArg: IAppStoreManagerOptions = {}, ) { this.appStoreBaseUrl = optionsArg.baseUrl || 'https://code.foss.global/serve.zone/appstore/raw/branch/main'; this.fetchRef = optionsArg.fetch || fetch; this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true; this.appStoreResolver = this.createAppStoreResolver(); } public async init(): Promise { try { await this.getAppStore(); logger.info(`App Store initialized with ${this.appStoreCache?.apps.length || 0} templates`); } catch (error) { logger.warn(`App Store initialization failed: ${getErrorMessage(error)}`); logger.warn('App Store will retry on next request'); } } public async getAppStore(): Promise { const now = Date.now(); if (this.appStoreCache && (now - this.lastFetchTime) < this.cacheTtlMs) { return this.appStoreCache; } try { const resolver = this.createAppStoreResolver(); const appStore = await resolver.getAppStoreIndex(); this.appStoreResolver = resolver; this.appStoreCache = appStore; this.lastFetchTime = now; return appStore; } catch (error) { logger.warn(`Failed to fetch remote App Store: ${getErrorMessage(error)}`); if (this.appStoreCache) { return this.appStoreCache; } return { schemaVersion: 1, updatedAt: '', apps: [] }; } } public async getApps(): Promise { return (await this.getAppStore()).apps; } public async getAppMeta(appIdArg: string): Promise { try { await this.getAppStore(); return await this.appStoreResolver.getAppMeta(appIdArg); } catch (error) { throw new Error(`Failed to fetch metadata for app '${appIdArg}': ${getErrorMessage(error)}`); } } public async getAppVersionConfig( appIdArg: string, versionArg?: string, ): Promise { try { const version = versionArg || (await this.getAppMeta(appIdArg)).latestVersion; await this.getAppStore(); return await this.appStoreResolver.getAppVersionConfig(appIdArg, version); } catch (error) { throw new Error(`Failed to fetch config for ${appIdArg}@${versionArg || 'latest'}: ${getErrorMessage(error)}`); } } public async installApp(optionsArg: IAppStoreInstallOptions): Promise { this.validateInstallOptions(optionsArg); const appMeta = await this.getAppMeta(optionsArg.appId); const version = optionsArg.version || appMeta.latestVersion; const config = await this.getAppVersionConfig(optionsArg.appId, version); const appStoreVersion = config.appStoreVersion || version; this.assertRuntimeCompatibility(config); const servicePort = optionsArg.port || config.port; this.assertValidPort(servicePort, 'install service port'); const volumes = this.normalizeVolumes(config.volumes); const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || []; this.validateAppVersionConfig( { ...config, port: servicePort, publishedPorts }, `${optionsArg.appId}@${version} install`, ); const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {}); if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) { throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}'); } return await this.oneboxRef.services.deployService({ name: optionsArg.serviceName, image: config.image, port: servicePort, domain: optionsArg.domain, autoDNS: optionsArg.autoDNS, envVars, volumes, publishedPorts, enableMongoDB: Boolean(config.platformRequirements?.mongodb), enableS3: Boolean(config.platformRequirements?.s3), enableClickHouse: Boolean(config.platformRequirements?.clickhouse), enableRedis: Boolean(config.platformRequirements?.redis), enableMariaDB: Boolean(config.platformRequirements?.mariadb), appTemplateId: optionsArg.appId, appTemplateVersion: appStoreVersion, imageDigest: config.resolvedImageDigest, }); } public async getUpgradeableAppStoreServices(): Promise { const appStore = await this.getAppStore(); const services = this.oneboxRef.database.getAllServices(); const upgradeable: IUpgradeableAppStoreService[] = []; for (const service of services) { if (!service.appTemplateId || !service.appTemplateVersion) continue; const appStoreApp = appStore.apps.find((appArg: IAppStoreApp) => appArg.id === service.appTemplateId); if (!appStoreApp || appStoreApp.latestVersion === service.appTemplateVersion) continue; upgradeable.push({ serviceName: service.name, appTemplateId: service.appTemplateId, currentVersion: service.appTemplateVersion, latestVersion: appStoreApp.latestVersion, hasMigration: await this.hasMigrationScript( service.appTemplateId, service.appTemplateVersion, appStoreApp.latestVersion, ), }); } return upgradeable; } public async hasMigrationScript( appIdArg: string, fromVersionArg: string, toVersionArg: string, ): Promise { try { await this.fetchText(`apps/${appIdArg}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`); return true; } catch { return false; } } public async executeMigration( serviceArg: IService, fromVersionArg: string, toVersionArg: string, ): Promise { const appId = serviceArg.appTemplateId; if (!appId) { throw new Error('Service has no appTemplateId'); } const scriptPath = `apps/${appId}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`; let scriptContent: string; try { scriptContent = await this.fetchText(scriptPath); } catch { logger.info(`No migration script for ${appId} ${fromVersionArg} -> ${toVersionArg}, using config-only upgrade`); const config = await this.getAppVersionConfig(appId, toVersionArg); return { success: true, image: config.image, imageDigest: config.resolvedImageDigest, port: config.port, volumes: this.normalizeVolumes(config.volumes), publishedPorts: config.publishedPorts, envVars: undefined, warnings: [], }; } const tempFile = `/tmp/onebox-migration-${crypto.randomUUID()}.ts`; await Deno.writeTextFile(tempFile, scriptContent); try { const context: IMigrationContext = { service: { name: serviceArg.name, image: serviceArg.image, envVars: serviceArg.envVars, port: serviceArg.port, }, fromVersion: fromVersionArg, toVersion: toVersionArg, }; 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(); const writer = child.stdin.getWriter(); await writer.write(new TextEncoder().encode(JSON.stringify(context))); await writer.close(); const output = await child.output(); const stdout = new TextDecoder().decode(output.stdout); const stderr = new TextDecoder().decode(output.stderr); if (output.code !== 0) { logger.error(`Migration script failed (exit ${output.code}): ${stderr.substring(0, 500)}`); return { success: false, warnings: [`Migration script failed: ${stderr.substring(0, 200)}`], }; } 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 { try { await Deno.remove(tempFile); } catch { // Ignore cleanup errors. } } } public async applyUpgrade( serviceNameArg: string, migrationResultArg: IMigrationResult, newVersionArg: string, ): Promise { const service = this.oneboxRef.database.getServiceByName(serviceNameArg); if (!service) { throw new Error(`Service not found: ${serviceNameArg}`); } if (service.containerID && service.status === 'running') { await this.oneboxRef.services.stopService(serviceNameArg); } const updates: Partial = { appTemplateVersion: newVersionArg, }; if (migrationResultArg.image) { updates.image = migrationResultArg.image; } if (migrationResultArg.imageDigest !== undefined) { updates.imageDigest = migrationResultArg.imageDigest; } if (migrationResultArg.port) { updates.port = migrationResultArg.port; } if (migrationResultArg.volumes) { updates.volumes = migrationResultArg.volumes; } if (migrationResultArg.publishedPorts) { updates.publishedPorts = migrationResultArg.publishedPorts; } if (migrationResultArg.envVars) { const mergedEnvVars = { ...migrationResultArg.envVars }; 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); const newImage = migrationResultArg.image || service.image; if (migrationResultArg.image && migrationResultArg.image !== service.image) { await this.oneboxRef.docker.pullImage(newImage); } const updatedService = this.oneboxRef.database.getServiceByName(serviceNameArg)!; if (service.containerID) { try { await this.oneboxRef.docker.removeContainer(service.containerID, true); } catch { // Container might already be gone. } } const containerID = await this.oneboxRef.docker.createContainer(updatedService); this.oneboxRef.database.updateService(service.id!, { containerID, status: 'starting' }); await this.oneboxRef.docker.startContainer(containerID); this.oneboxRef.database.updateService(service.id!, { status: 'running' }); logger.success(`Service '${serviceNameArg}' upgraded to App Store version ${newVersionArg}`); return this.oneboxRef.database.getServiceByName(serviceNameArg)!; } public normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []): IServiceVolume[] { return this.appStoreResolver.normalizeVolumes(volumesArg) as IServiceVolume[]; } public validateAppVersionConfig(configArg: IAppStoreVersionConfig, labelArg = 'app config'): void { this.appStoreResolver.validateAppStoreVersionConfig(configArg, labelArg); } private createAppStoreResolver(): plugins.servezoneAppstore.AppStoreResolver { return new plugins.servezoneAppstore.AppStoreResolver({ baseUrl: this.appStoreBaseUrl, fetch: this.fetchRef, resolveDockerDigests: this.resolveDockerDigests, }); } private async fetchText(pathArg: string): Promise { const url = `${this.appStoreBaseUrl}/${pathArg}`; const response = await this.fetchRef(url); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return response.text(); } private validateInstallOptions(optionsArg: IAppStoreInstallOptions): void { if (!optionsArg.appId || !/^[a-z0-9][a-z0-9-]*$/.test(optionsArg.appId)) { throw new Error(`Invalid app id: ${optionsArg.appId}`); } if (!optionsArg.serviceName || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,119}$/.test(optionsArg.serviceName)) { throw new Error(`Invalid service name: ${optionsArg.serviceName}`); } if (optionsArg.port !== undefined) { this.assertValidPort(optionsArg.port, 'install service port'); } } private assertValidPort(portArg: number, labelArg: string): void { if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) { throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`); } } private getAppStoreEnvVars( configArg: IAppStoreVersionConfig, overridesArg: Record, ): Record { const envVars: Record = {}; const missingRequiredEnvVars: string[] = []; for (const envVar of configArg.envVars || []) { const value = overridesArg[envVar.key] ?? envVar.value ?? ''; if (envVar.required && !value) { missingRequiredEnvVars.push(envVar.key); } envVars[envVar.key] = value; } Object.assign(envVars, overridesArg); if (missingRequiredEnvVars.length > 0) { throw new Error(`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`); } return envVars; } private requiresTemplateValue(envVarsArg: Record, templateNameArg: string): boolean { return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`)); } private assertRuntimeCompatibility(configArg: IAppStoreVersionConfig): void { if (!configArg.minOneboxVersion) return; if (this.compareVersions(projectInfo.version, configArg.minOneboxVersion) < 0) { throw new Error( `App requires Onebox >= ${configArg.minOneboxVersion}; current version is ${projectInfo.version}`, ); } } private compareVersions(versionAArg: string, versionBArg: string): number { const normalize = (versionArg: string) => versionArg.replace(/^v/, '').split('.').map((partArg) => Number(partArg) || 0); const a = normalize(versionAArg); const b = normalize(versionBArg); for (let i = 0; i < Math.max(a.length, b.length); i++) { const diff = (a[i] || 0) - (b[i] || 0); if (diff !== 0) return diff > 0 ? 1 : -1; } return 0; } }