455 lines
16 KiB
TypeScript
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;
|
|
}
|
|
}
|