Files
onebox/ts/classes/appstore.ts
T

455 lines
16 KiB
TypeScript

/**
* 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<string, string>;
port: number;
};
fromVersion: string;
toVersion: string;
}
export interface IMigrationResult {
success: boolean;
envVars?: Record<string, string>;
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<void> {
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<IAppStoreIndex> {
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<IAppStoreApp[]> {
return (await this.getAppStore()).apps;
}
public async getAppMeta(appIdArg: string): Promise<IAppStoreAppMeta> {
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<IAppStoreVersionConfig> {
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<IService> {
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<IUpgradeableAppStoreService[]> {
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<boolean> {
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<IMigrationResult> {
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<IService> {
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<IService> = {
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<string> {
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<string, string>,
): Record<string, string> {
const envVars: Record<string, string> = {};
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<string, string>, 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;
}
}