336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
}
|