2026-03-21 19:36:25 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-05-24 07:28:18 +00:00
|
|
|
IAppCatalogVolume,
|
|
|
|
|
IAppInstallOptions,
|
2026-03-21 19:36:25 +00:00
|
|
|
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';
|
2026-05-24 07:28:18 +00:00
|
|
|
import type { IService, IServiceVolume } from '../types.ts';
|
|
|
|
|
import { projectInfo } from '../info.ts';
|
2026-03-21 19:36:25 +00:00
|
|
|
|
|
|
|
|
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 {
|
2026-05-24 07:28:18 +00:00
|
|
|
const config = await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig;
|
|
|
|
|
this.validateAppVersionConfig(config, `${appId}@${version}`);
|
|
|
|
|
return config;
|
2026-03-21 19:36:25 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
throw new Error(`Failed to fetch config for ${appId}@${version}: ${getErrorMessage(error)}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 07:28:18 +00:00
|
|
|
async installApp(optionsArg: IAppInstallOptions): 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);
|
|
|
|
|
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.validatePublishedPorts(publishedPorts, `${optionsArg.appId}@${version}`);
|
|
|
|
|
|
|
|
|
|
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: version,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 19:36:25 +00:00
|
|
|
/**
|
|
|
|
|
* 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,
|
2026-05-24 07:28:18 +00:00
|
|
|
port: config.port,
|
|
|
|
|
volumes: this.normalizeVolumes(config.volumes),
|
|
|
|
|
publishedPorts: config.publishedPorts,
|
2026-03-21 19:36:25 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 07:28:18 +00:00
|
|
|
if (migrationResult.port) {
|
|
|
|
|
updates.port = migrationResult.port;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (migrationResult.volumes) {
|
|
|
|
|
updates.volumes = migrationResult.volumes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (migrationResult.publishedPorts) {
|
|
|
|
|
updates.publishedPorts = migrationResult.publishedPorts;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-21 19:36:25 +00:00
|
|
|
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();
|
|
|
|
|
}
|
2026-05-24 07:28:18 +00:00
|
|
|
|
|
|
|
|
public normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []): IServiceVolume[] {
|
|
|
|
|
return volumesArg.map((volumeArg, indexArg): IAppCatalogVolume => {
|
|
|
|
|
if (typeof volumeArg === 'string') {
|
|
|
|
|
return { mountPath: volumeArg };
|
|
|
|
|
}
|
|
|
|
|
return volumeArg;
|
|
|
|
|
}).map((volumeArg, indexArg) => {
|
|
|
|
|
this.validateVolume(volumeArg, `volume ${indexArg + 1}`);
|
|
|
|
|
return volumeArg;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public validateAppVersionConfig(configArg: IAppVersionConfig, labelArg = 'app config'): void {
|
|
|
|
|
if (!configArg || typeof configArg !== 'object') {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: config must be an object`);
|
|
|
|
|
}
|
|
|
|
|
if (!configArg.image || typeof configArg.image !== 'string') {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: image is required`);
|
|
|
|
|
}
|
|
|
|
|
if (configArg.image.endsWith(':latest')) {
|
|
|
|
|
logger.warn(`App template ${labelArg} uses a mutable ':latest' image tag`);
|
|
|
|
|
}
|
|
|
|
|
this.assertValidPort(configArg.port, `${labelArg} port`);
|
|
|
|
|
|
|
|
|
|
for (const envVar of configArg.envVars || []) {
|
|
|
|
|
if (!envVar.key || !/^[A-Z_][A-Z0-9_]*$/.test(envVar.key)) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: env var key '${envVar.key}' is not valid`);
|
|
|
|
|
}
|
|
|
|
|
if (envVar.value !== undefined && typeof envVar.value !== 'string') {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: env var '${envVar.key}' value must be a string`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.normalizeVolumes(configArg.volumes);
|
|
|
|
|
this.validatePublishedPorts(configArg.publishedPorts || [], labelArg);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validateInstallOptions(optionsArg: IAppInstallOptions): 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');
|
|
|
|
|
}
|
|
|
|
|
if (optionsArg.publishedPorts) {
|
|
|
|
|
this.validatePublishedPorts(optionsArg.publishedPorts, `install options for ${optionsArg.appId}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validateVolume(volumeArg: IAppCatalogVolume, labelArg: string): void {
|
|
|
|
|
if (!volumeArg.mountPath || !volumeArg.mountPath.startsWith('/')) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: mountPath must be an absolute path`);
|
|
|
|
|
}
|
|
|
|
|
if (volumeArg.mountPath.includes(':')) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: mountPath must not contain ':'`);
|
|
|
|
|
}
|
|
|
|
|
if ((volumeArg.source || volumeArg.name)?.includes(':')) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: source/name must not contain ':'`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validatePublishedPorts(
|
|
|
|
|
publishedPortsArg: IAppVersionConfig['publishedPorts'] = [],
|
|
|
|
|
labelArg: string,
|
|
|
|
|
): void {
|
|
|
|
|
const seenPublishedPorts = new Set<string>();
|
|
|
|
|
for (const portArg of publishedPortsArg) {
|
|
|
|
|
const protocol = portArg.protocol || 'tcp';
|
|
|
|
|
const targetStart = portArg.targetPort;
|
|
|
|
|
const targetEnd = portArg.targetPortEnd || targetStart;
|
|
|
|
|
const publishedStart = portArg.publishedPort || targetStart;
|
|
|
|
|
const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart));
|
|
|
|
|
const hostIp = portArg.hostIp || '0.0.0.0';
|
|
|
|
|
|
|
|
|
|
if (!['tcp', 'udp'].includes(protocol)) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: published port protocol '${protocol}' is not supported`);
|
|
|
|
|
}
|
|
|
|
|
this.assertValidPort(targetStart, `${labelArg} targetPort`);
|
|
|
|
|
this.assertValidPort(targetEnd, `${labelArg} targetPortEnd`);
|
|
|
|
|
this.assertValidPort(publishedStart, `${labelArg} publishedPort`);
|
|
|
|
|
this.assertValidPort(publishedEnd, `${labelArg} publishedPortEnd`);
|
|
|
|
|
if (targetEnd < targetStart || publishedEnd < publishedStart) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: published port ranges must be ascending`);
|
|
|
|
|
}
|
|
|
|
|
if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: target and published port ranges must have the same size`);
|
|
|
|
|
}
|
|
|
|
|
if ((targetEnd - targetStart) > 1000) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: published port ranges may not exceed 1001 ports`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (let offset = 0; offset <= targetEnd - targetStart; offset++) {
|
|
|
|
|
const publishedPort = publishedStart + offset;
|
|
|
|
|
const publishedKey = `${hostIp}/${protocol}/${publishedPort}`;
|
|
|
|
|
const wildcardKey = `0.0.0.0/${protocol}/${publishedPort}`;
|
|
|
|
|
const conflictsWithWildcard = hostIp === '0.0.0.0'
|
|
|
|
|
? Array.from(seenPublishedPorts).some((keyArg) => keyArg.endsWith(`/${protocol}/${publishedPort}`))
|
|
|
|
|
: seenPublishedPorts.has(wildcardKey);
|
|
|
|
|
if (seenPublishedPorts.has(publishedKey) || conflictsWithWildcard) {
|
|
|
|
|
throw new Error(`Invalid ${labelArg}: duplicate published port ${hostIp}:${publishedPort}/${protocol}`);
|
|
|
|
|
}
|
|
|
|
|
seenPublishedPorts.add(publishedKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: IAppVersionConfig,
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [key, value] of Object.entries(overridesArg)) {
|
|
|
|
|
envVars[key] = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: IAppVersionConfig): 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;
|
|
|
|
|
}
|
2026-03-21 19:36:25 +00:00
|
|
|
}
|