/** * 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; } 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(); 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 { 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 { 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 { const catalog = await this.getCatalog(); return catalog.apps; } /** * Fetch app metadata (versions list, etc.) */ async getAppMeta(appId: string): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 = { 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 { 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 { 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 { 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 { 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 { 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(); const versions: string[] = []; const sourceVersionToResolvedVersion = new Map(); 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 { if (configArg.source?.type === 'dockerImage') { await this.applyDockerImageSourceToConfig(configArg.source, configArg, versionArg); } } private async applyDockerImageSourceToConfig( sourceArg: IAppCatalogDockerImageSource, configArg: IAppVersionConfig, versionArg: string, ): Promise { 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 { 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 { 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 { 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 { 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 { const match = authHeaderArg.match(/^Bearer\s+(.+)$/i); if (!match) return null; const authParams = new Map(); 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 { 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(objectArg: T): Partial { return Object.fromEntries( Object.entries(objectArg).filter(([, valueArg]) => valueArg !== undefined), ) as Partial; } private async fetchTextFromUrl(urlArg: string): Promise { 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 { 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 { 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(); 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, ): Record { const envVars: Record = {}; 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, 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; } }