Files
onebox/ts/classes/appstore.ts
T

1047 lines
38 KiB
TypeScript
Raw Normal View History

/**
* 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,
IAppCatalogVolume,
IAppInstallOptions,
IAppVersionConfig,
IMigrationContext,
IMigrationResult,
IUpgradeableService,
IAppCatalogDockerImageSource,
IAppCatalogRepoManifestSource,
IResolvedCatalogSource,
IServezoneCatalogManifest,
} from './appstore-types.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
import type { IService, IServiceVolume } from '../types.ts';
import { projectInfo } from '../info.ts';
export interface IAppStoreManagerOptions {
repoBaseUrl?: string;
fetch?: typeof fetch;
resolveDockerDigests?: boolean;
}
interface IResolvedSourceApp {
catalogApp: ICatalogApp;
appMeta: IAppMeta;
configsByVersion: Map<string, IAppVersionConfig>;
}
interface IParsedDockerImageReference {
registry: string;
repository: string;
tag: string;
digest?: string;
}
export class AppStoreManager {
private oneboxRef: Onebox;
private catalogCache: ICatalog | null = null;
private sourceAppCache = new Map<string, IResolvedSourceApp>();
private lastFetchTime = 0;
private readonly repoBaseUrl: string;
private readonly fetchRef: typeof fetch;
private readonly resolveDockerDigests: boolean;
private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes
constructor(oneboxRef: Onebox, optionsArg: IAppStoreManagerOptions = {}) {
this.oneboxRef = oneboxRef;
this.repoBaseUrl = optionsArg.repoBaseUrl || 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main';
this.fetchRef = optionsArg.fetch || fetch;
this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true;
}
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.fetchCatalog();
if (catalog && catalog.apps && Array.isArray(catalog.apps)) {
const resolvedCatalog = await this.resolveCatalog(catalog);
this.catalogCache = resolvedCatalog;
this.lastFetchTime = now;
return resolvedCatalog;
}
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 {
const catalogApp = await this.getCatalogApp(appId);
if (catalogApp?.source?.type === 'repoManifest') {
const resolvedApp = await this.resolveRepoManifestSource(catalogApp.source, catalogApp);
return resolvedApp.appMeta;
}
if (catalogApp?.source?.type === 'dockerImage') {
return this.createAppMetaFromCatalogApp(catalogApp);
}
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 {
const catalogApp = await this.getCatalogApp(appId);
if (catalogApp?.source?.type === 'repoManifest') {
const resolvedApp = await this.resolveRepoManifestSource(catalogApp.source, catalogApp);
const config = resolvedApp.configsByVersion.get(version);
if (!config) {
throw new Error(`Version '${version}' is not defined by the linked app manifest`);
}
this.validateAppVersionConfig(config, `${appId}@${version}`);
return config;
}
if (catalogApp?.source?.type === 'dockerImage' && catalogApp.runtime) {
const config: IAppVersionConfig = { ...catalogApp.runtime };
await this.applyDockerImageSourceToConfig(catalogApp.source, config, version);
this.validateAppVersionConfig(config, `${appId}@${version}`);
return config;
}
let config: IAppVersionConfig;
try {
config = await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig;
} catch (error) {
if (catalogApp?.source?.type !== 'dockerImage') {
throw error;
}
const appMeta = await this.fetchJson(`apps/${appId}/app.json`) as IAppMeta;
config = await this.fetchJson(`apps/${appId}/versions/${appMeta.latestVersion}/config.json`) as IAppVersionConfig;
}
if (catalogApp?.source?.type === 'dockerImage') {
await this.applyDockerImageSourceToConfig(catalogApp.source, config, version);
}
this.validateAppVersionConfig(config, `${appId}@${version}`);
return config;
} catch (error) {
throw new Error(`Failed to fetch config for ${appId}@${version}: ${getErrorMessage(error)}`);
}
}
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);
const catalogVersion = config.catalogVersion || 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: catalogVersion,
imageDigest: config.resolvedImageDigest,
});
}
/**
* 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,
imageDigest: config.resolvedImageDigest,
port: config.port,
volumes: this.normalizeVolumes(config.volumes),
publishedPorts: config.publishedPorts,
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.imageDigest !== undefined) {
updates.imageDigest = migrationResult.imageDigest;
}
if (migrationResult.port) {
updates.port = migrationResult.port;
}
if (migrationResult.volumes) {
updates.volumes = migrationResult.volumes;
}
if (migrationResult.publishedPorts) {
updates.publishedPorts = migrationResult.publishedPorts;
}
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)!;
}
private async fetchCatalog(): Promise<ICatalog> {
try {
return await this.fetchJson('catalog.resolved.json') as ICatalog;
} catch {
return await this.fetchJson('catalog.json') as ICatalog;
}
}
private async resolveCatalog(catalogArg: ICatalog): Promise<ICatalog> {
this.sourceAppCache.clear();
const apps: ICatalogApp[] = [];
for (const appArg of catalogArg.apps) {
try {
apps.push(await this.resolveCatalogApp(appArg));
} catch (error) {
logger.warn(`Failed to resolve catalog source for '${appArg.id}': ${getErrorMessage(error)}`);
apps.push(appArg);
}
}
return {
...catalogArg,
apps,
resolvedAt: new Date().toISOString(),
};
}
private async resolveCatalogApp(appArg: ICatalogApp): Promise<ICatalogApp> {
if (appArg.source?.type === 'repoManifest') {
const resolvedApp = await this.resolveRepoManifestSource(appArg.source, appArg);
return {
...resolvedApp.catalogApp,
...this.withoutUndefined(appArg),
latestVersion: resolvedApp.catalogApp.latestVersion,
versions: resolvedApp.catalogApp.versions,
source: appArg.source,
tags: appArg.tags || resolvedApp.catalogApp.tags,
resolvedSource: resolvedApp.catalogApp.resolvedSource,
};
}
if (appArg.source?.type === 'dockerImage') {
const config = appArg.runtime ? { ...appArg.runtime } : undefined;
const resolvedSource = config
? (await this.applyDockerImageSourceToConfig(appArg.source, config, appArg.latestVersion)).resolvedSource
: await this.resolveDockerImageSource(appArg.source);
const latestVersion = this.createCatalogVersionForDockerSource(
appArg.source,
appArg.latestVersion,
resolvedSource?.imageDigest,
);
return {
...appArg,
runtime: config,
latestVersion,
versions: this.uniqueStrings([...(appArg.versions || []), latestVersion]),
upgradeStrategy: appArg.source.tracking === 'digest' ? 'dockerDigest' : appArg.upgradeStrategy,
resolvedSource,
};
}
return appArg;
}
private async resolveRepoManifestSource(
sourceArg: IAppCatalogRepoManifestSource,
catalogAppArg?: ICatalogApp,
): Promise<IResolvedSourceApp> {
const cacheKey = `${sourceArg.url}#${sourceArg.ref || ''}`;
const cachedApp = this.sourceAppCache.get(cacheKey);
if (cachedApp) {
return cachedApp;
}
const manifestText = await this.fetchTextFromUrl(sourceArg.url);
const manifestHash = await this.createSha256Hex(manifestText);
const manifest = JSON.parse(manifestText) as IServezoneCatalogManifest;
const resolvedApp = await this.resolveServezoneCatalogManifest(manifest, {
type: 'repoManifest',
url: sourceArg.url,
ref: sourceArg.ref,
manifestHash,
resolvedAt: new Date().toISOString(),
});
if (catalogAppArg) {
resolvedApp.catalogApp = {
...resolvedApp.catalogApp,
...this.withoutUndefined(catalogAppArg),
latestVersion: resolvedApp.catalogApp.latestVersion,
versions: resolvedApp.catalogApp.versions,
source: catalogAppArg.source,
tags: catalogAppArg.tags || resolvedApp.catalogApp.tags,
resolvedSource: resolvedApp.catalogApp.resolvedSource,
};
resolvedApp.appMeta = {
...resolvedApp.appMeta,
id: resolvedApp.catalogApp.id,
name: resolvedApp.catalogApp.name,
description: resolvedApp.catalogApp.description,
category: resolvedApp.catalogApp.category,
iconName: resolvedApp.catalogApp.iconName,
latestVersion: resolvedApp.catalogApp.latestVersion,
versions: resolvedApp.catalogApp.versions || resolvedApp.appMeta.versions,
tags: resolvedApp.catalogApp.tags,
source: catalogAppArg.source,
resolvedSource: resolvedApp.catalogApp.resolvedSource,
};
}
this.sourceAppCache.set(cacheKey, resolvedApp);
return resolvedApp;
}
private async resolveServezoneCatalogManifest(
manifestArg: IServezoneCatalogManifest,
resolvedSourceArg: IResolvedCatalogSource,
): Promise<IResolvedSourceApp> {
if (!manifestArg || typeof manifestArg !== 'object') {
throw new Error('Manifest must be an object');
}
if (manifestArg.schemaVersion !== 1) {
throw new Error(`Unsupported manifest schemaVersion '${manifestArg.schemaVersion}'`);
}
if (!manifestArg.app?.id || !manifestArg.app?.name) {
throw new Error('Manifest app.id and app.name are required');
}
const configsByVersion = new Map<string, IAppVersionConfig>();
const versions: string[] = [];
const sourceVersionToResolvedVersion = new Map<string, string>();
for (const versionArg of manifestArg.versions || []) {
const sourceVersion = versionArg.version;
const { version: _version, ...versionConfig } = versionArg;
const config: IAppVersionConfig = {
...versionConfig,
source: versionConfig.source || manifestArg.source,
resolvedSource: resolvedSourceArg,
};
await this.resolveConfigSource(config, sourceVersion);
const resolvedVersion = config.catalogVersion || sourceVersion;
config.catalogVersion = resolvedVersion;
this.validateAppVersionConfig(config, `${manifestArg.app.id}@${resolvedVersion}`);
configsByVersion.set(resolvedVersion, config);
configsByVersion.set(sourceVersion, config);
versions.push(resolvedVersion);
sourceVersionToResolvedVersion.set(sourceVersion, resolvedVersion);
}
if (manifestArg.runtime) {
const sourceVersion = manifestArg.latestVersion || manifestArg.channel || 'latest';
const config: IAppVersionConfig = {
...manifestArg.runtime,
source: manifestArg.runtime.source || manifestArg.source,
resolvedSource: resolvedSourceArg,
};
await this.resolveConfigSource(config, sourceVersion);
const resolvedVersion = config.catalogVersion || sourceVersion;
config.catalogVersion = resolvedVersion;
this.validateAppVersionConfig(config, `${manifestArg.app.id}@${resolvedVersion}`);
configsByVersion.set(resolvedVersion, config);
configsByVersion.set(sourceVersion, config);
versions.push(resolvedVersion);
sourceVersionToResolvedVersion.set(sourceVersion, resolvedVersion);
}
if (configsByVersion.size === 0) {
throw new Error('Manifest must define at least one runtime config or version');
}
const selectedChannel = manifestArg.policy?.defaultChannel || manifestArg.channel || 'stable';
const channelVersion = manifestArg.channels?.[selectedChannel];
const declaredLatestVersion = manifestArg.latestVersion || channelVersion || versions[versions.length - 1];
const latestVersion = sourceVersionToResolvedVersion.get(declaredLatestVersion) || declaredLatestVersion;
const uniqueVersions = this.uniqueStrings(versions);
const catalogApp: ICatalogApp = {
id: manifestArg.app.id,
name: manifestArg.app.name,
description: manifestArg.app.description,
category: manifestArg.app.category,
iconName: manifestArg.app.iconName,
iconUrl: manifestArg.app.iconUrl,
latestVersion,
versions: uniqueVersions,
tags: manifestArg.app.tags,
channel: selectedChannel,
source: manifestArg.source,
upgradeStrategy: this.getUpgradeStrategyForConfig(configsByVersion.get(latestVersion)),
resolvedSource: resolvedSourceArg,
};
const appMeta: IAppMeta = {
id: manifestArg.app.id,
name: manifestArg.app.name,
description: manifestArg.app.description,
category: manifestArg.app.category,
iconName: manifestArg.app.iconName,
latestVersion,
versions: uniqueVersions,
maintainer: manifestArg.app.maintainer,
links: manifestArg.app.links,
tags: manifestArg.app.tags,
source: manifestArg.source,
resolvedSource: resolvedSourceArg,
};
return { catalogApp, appMeta, configsByVersion };
}
private async resolveConfigSource(configArg: IAppVersionConfig, versionArg: string): Promise<void> {
if (configArg.source?.type === 'dockerImage') {
await this.applyDockerImageSourceToConfig(configArg.source, configArg, versionArg);
}
}
private async applyDockerImageSourceToConfig(
sourceArg: IAppCatalogDockerImageSource,
configArg: IAppVersionConfig,
versionArg: string,
): Promise<IAppVersionConfig> {
configArg.image = sourceArg.image;
configArg.source = sourceArg;
const resolvedSource = await this.resolveDockerImageSource(sourceArg);
configArg.resolvedSource = resolvedSource;
configArg.resolvedImageDigest = resolvedSource.imageDigest;
configArg.upgradeStrategy = sourceArg.tracking === 'digest' ? 'dockerDigest' : configArg.upgradeStrategy;
configArg.catalogVersion = this.createCatalogVersionForDockerSource(
sourceArg,
versionArg,
resolvedSource.imageDigest,
);
return configArg;
}
private async resolveDockerImageSource(
sourceArg: IAppCatalogDockerImageSource,
): Promise<IResolvedCatalogSource> {
let imageDigest: string | undefined;
if (sourceArg.tracking === 'digest' && this.resolveDockerDigests) {
imageDigest = await this.resolveDockerImageDigest(sourceArg.image) || undefined;
}
return {
type: 'dockerImage',
image: sourceArg.image,
imageDigest,
resolvedAt: new Date().toISOString(),
};
}
private createAppMetaFromCatalogApp(appArg: ICatalogApp): IAppMeta {
return {
id: appArg.id,
name: appArg.name,
description: appArg.description,
category: appArg.category,
iconName: appArg.iconName,
latestVersion: appArg.latestVersion,
versions: appArg.versions || [appArg.latestVersion],
tags: appArg.tags,
source: appArg.source,
resolvedSource: appArg.resolvedSource,
};
}
private async getCatalogApp(appIdArg: string): Promise<ICatalogApp | undefined> {
const catalog = await this.getCatalog();
return catalog.apps.find((appArg) => appArg.id === appIdArg);
}
private getUpgradeStrategyForConfig(configArg?: IAppVersionConfig): ICatalogApp['upgradeStrategy'] {
if (configArg?.upgradeStrategy) return configArg.upgradeStrategy;
if (configArg?.source?.type === 'dockerImage' && configArg.source.tracking === 'digest') return 'dockerDigest';
return undefined;
}
private createCatalogVersionForDockerSource(
sourceArg: IAppCatalogDockerImageSource,
fallbackVersionArg: string,
digestArg?: string,
): string {
if (sourceArg.tracking !== 'digest' || !digestArg) {
return fallbackVersionArg;
}
const parsedImage = this.parseDockerImageReference(sourceArg.image);
return `${parsedImage.tag}@${digestArg}`;
}
private async resolveDockerImageDigest(imageArg: string): Promise<string | null> {
try {
const parsedImage = this.parseDockerImageReference(imageArg);
if (parsedImage.digest) {
return parsedImage.digest;
}
return await this.fetchDockerManifestDigest(parsedImage);
} catch (error) {
logger.warn(`Failed to resolve Docker image digest for '${imageArg}': ${getErrorMessage(error)}`);
return null;
}
}
private parseDockerImageReference(imageArg: string): IParsedDockerImageReference {
const [imageWithoutDigest, digest] = imageArg.split('@');
const imageParts = imageWithoutDigest.split('/');
const firstPart = imageParts[0];
const hasExplicitRegistry = firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost';
const registry = hasExplicitRegistry ? firstPart : 'registry-1.docker.io';
const repositoryParts = hasExplicitRegistry ? imageParts.slice(1) : imageParts;
let repositoryWithTag = repositoryParts.join('/');
if (!repositoryWithTag) {
throw new Error(`Invalid Docker image reference '${imageArg}'`);
}
if (!hasExplicitRegistry && !repositoryWithTag.includes('/')) {
repositoryWithTag = `library/${repositoryWithTag}`;
}
const lastSlashIndex = repositoryWithTag.lastIndexOf('/');
const lastColonIndex = repositoryWithTag.lastIndexOf(':');
const hasTag = lastColonIndex > lastSlashIndex;
const repository = hasTag ? repositoryWithTag.slice(0, lastColonIndex) : repositoryWithTag;
const tag = hasTag ? repositoryWithTag.slice(lastColonIndex + 1) : 'latest';
return { registry, repository, tag, digest };
}
private async fetchDockerManifestDigest(imageArg: IParsedDockerImageReference): Promise<string | null> {
const manifestUrl = `https://${imageArg.registry}/v2/${imageArg.repository}/manifests/${imageArg.tag}`;
const headers = new Headers({
Accept: [
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.index.v1+json',
].join(', '),
});
let response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
if (response.status === 401) {
const authHeader = response.headers.get('www-authenticate');
const token = authHeader ? await this.fetchDockerRegistryToken(authHeader, imageArg.repository) : null;
if (token) {
headers.set('Authorization', `Bearer ${token}`);
response = await this.fetchRef(manifestUrl, { method: 'HEAD', headers });
}
}
if (!response.ok || !response.headers.get('docker-content-digest')) {
response = await this.fetchRef(manifestUrl, { method: 'GET', headers });
}
if (!response.ok) {
throw new Error(`HTTP ${response.status} while resolving ${imageArg.repository}:${imageArg.tag}`);
}
return response.headers.get('docker-content-digest');
}
private async fetchDockerRegistryToken(authHeaderArg: string, repositoryArg: string): Promise<string | null> {
const match = authHeaderArg.match(/^Bearer\s+(.+)$/i);
if (!match) return null;
const authParams = new Map<string, string>();
for (const partArg of match[1].match(/(?:[^,\"]+|\"[^\"]*\")+/g) || []) {
const [key, rawValue] = partArg.split('=');
if (!key || rawValue === undefined) continue;
authParams.set(key.trim(), rawValue.trim().replace(/^\"|\"$/g, ''));
}
const realm = authParams.get('realm');
if (!realm) return null;
const tokenUrl = new URL(realm);
const service = authParams.get('service');
const scope = authParams.get('scope') || `repository:${repositoryArg}:pull`;
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
const response = await this.fetchRef(tokenUrl.toString());
if (!response.ok) return null;
const tokenResponse = await response.json() as { token?: string; access_token?: string };
return tokenResponse.token || tokenResponse.access_token || null;
}
private async createSha256Hex(inputArg: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(inputArg));
return Array.from(new Uint8Array(digest))
.map((byteArg) => byteArg.toString(16).padStart(2, '0'))
.join('');
}
private uniqueStrings(valuesArg: string[]): string[] {
return Array.from(new Set(valuesArg.filter(Boolean)));
}
private withoutUndefined<T extends object>(objectArg: T): Partial<T> {
return Object.fromEntries(
Object.entries(objectArg).filter(([, valueArg]) => valueArg !== undefined),
) as Partial<T>;
}
private async fetchTextFromUrl(urlArg: string): Promise<string> {
const response = await this.fetchRef(urlArg);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${urlArg}`);
}
return response.text();
}
/**
* Fetch JSON from the remote repo
*/
private async fetchJson(path: string): Promise<unknown> {
const url = `${this.repoBaseUrl}/${path}`;
const response = await this.fetchRef(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 this.fetchRef(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.text();
}
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') && !configArg.resolvedImageDigest) {
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;
}
}