From db52934f350c41403c88ee78ac62ae7b011732aa Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 25 May 2026 01:39:59 +0000 Subject: [PATCH] feat(appstore): resolve repo manifests and docker digest-tracked images --- changelog.md | 8 + test/appstore_runtime_test.ts | 90 ++++++ ts/classes/appstore-types.ts | 93 +++++++ ts/classes/appstore.ts | 508 +++++++++++++++++++++++++++++++++- ts/classes/services.ts | 1 + ts/types.ts | 1 + 6 files changed, 691 insertions(+), 10 deletions(-) diff --git a/changelog.md b/changelog.md index 50fded5..8dbe4a5 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,14 @@ ## Pending +### Features + +- resolve repo manifests and docker digest-tracked images (appstore) + - Add catalog source, resolved source, channel, runtime, upgrade strategy, and version metadata types for appstore manifests. + - Resolve catalog entries from repo manifests and pin digest-tracked Docker images using registry digests. + - Propagate resolved image digests into app version configs and service creation options. + - Add runtime coverage for repo manifest resolution and digest-tracked latest images. + ## 2026-05-24 - 1.30.2 ### Fixes diff --git a/test/appstore_runtime_test.ts b/test/appstore_runtime_test.ts index e8eb5e5..d7d1fc9 100644 --- a/test/appstore_runtime_test.ts +++ b/test/appstore_runtime_test.ts @@ -81,6 +81,96 @@ Deno.test('appstore rejects invalid template ports and volumes', () => { ); }); +Deno.test('appstore resolves repo manifests and docker digest-tracked latest images', async () => { + const catalogBaseUrl = 'https://catalog.example.test'; + const manifestUrl = 'https://code.example.test/cloudly/servezone.catalog.json'; + const digest = 'sha256:1234567890abcdef'; + + const fakeFetch: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : input.toString(); + const method = init?.method || 'GET'; + + if (url === `${catalogBaseUrl}/catalog.resolved.json`) { + return new Response('not found', { status: 404 }); + } + + if (url === `${catalogBaseUrl}/catalog.json`) { + return Response.json({ + schemaVersion: 1, + updatedAt: '2026-05-24T00:00:00Z', + apps: [ + { + id: 'cloudly', + name: 'Cloudly', + description: 'Central metadata can stay curated.', + category: 'Dev Tools', + latestVersion: '1.0.0', + source: { + type: 'repoManifest', + url: manifestUrl, + ref: 'main', + }, + }, + ], + }); + } + + if (url === manifestUrl) { + return Response.json({ + schemaVersion: 1, + app: { + id: 'cloudly', + name: 'Cloudly', + description: 'Manifest-owned app metadata.', + category: 'Dev Tools', + maintainer: 'serve.zone', + }, + latestVersion: 'latest', + source: { + type: 'dockerImage', + image: 'registry.example.test/serve.zone/cloudly:latest', + tracking: 'digest', + }, + runtime: { + image: 'registry.example.test/serve.zone/cloudly:latest', + port: 80, + }, + }); + } + + if ( + url === 'https://registry.example.test/v2/serve.zone/cloudly/manifests/latest' && + method === 'HEAD' + ) { + return new Response(null, { + status: 200, + headers: { 'docker-content-digest': digest }, + }); + } + + return new Response(`unexpected ${method} ${url}`, { status: 500 }); + }; + + const appStore = new AppStoreManager({} as any, { + repoBaseUrl: catalogBaseUrl, + fetch: fakeFetch, + }); + + const catalog = await appStore.getCatalog(); + assertEquals(catalog.apps[0].latestVersion, `latest@${digest}`); + assertEquals(catalog.apps[0].resolvedSource?.manifestHash?.length, 64); + assertEquals(catalog.apps[0].upgradeStrategy, 'dockerDigest'); + + const appMeta = await appStore.getAppMeta('cloudly'); + assertEquals(appMeta.latestVersion, `latest@${digest}`); + assertEquals(appMeta.versions, [`latest@${digest}`]); + + const config = await appStore.getAppVersionConfig('cloudly', appMeta.latestVersion); + assertEquals(config.image, 'registry.example.test/serve.zone/cloudly:latest'); + assertEquals(config.catalogVersion, `latest@${digest}`); + assertEquals(config.resolvedImageDigest, digest); +}); + Deno.test('docker service spec validation rejects unsafe volume and port declarations', () => { const dockerManager = new OneboxDockerManager(); diff --git a/ts/classes/appstore-types.ts b/ts/classes/appstore-types.ts index 5cf0373..cf04fe4 100644 --- a/ts/classes/appstore-types.ts +++ b/ts/classes/appstore-types.ts @@ -6,6 +6,42 @@ export interface ICatalog { schemaVersion: number; updatedAt: string; apps: ICatalogApp[]; + resolvedAt?: string; +} + +export type TAppCatalogSourceType = 'inline' | 'repoManifest' | 'dockerImage'; +export type TAppCatalogTrackingMode = 'tag' | 'digest'; +export type TAppUpgradeStrategy = 'semver' | 'branch' | 'dockerDigest'; + +export interface IAppCatalogInlineSource { + type: 'inline'; +} + +export interface IAppCatalogRepoManifestSource { + type: 'repoManifest'; + url: string; + ref?: string; +} + +export interface IAppCatalogDockerImageSource { + type: 'dockerImage'; + image: string; + tracking?: TAppCatalogTrackingMode; +} + +export type TAppCatalogSource = + | IAppCatalogInlineSource + | IAppCatalogRepoManifestSource + | IAppCatalogDockerImageSource; + +export interface IResolvedCatalogSource { + type: TAppCatalogSourceType; + url?: string; + ref?: string; + image?: string; + manifestHash?: string; + imageDigest?: string; + resolvedAt: string; } export interface ICatalogApp { @@ -16,7 +52,13 @@ export interface ICatalogApp { iconName?: string; iconUrl?: string; latestVersion: string; + versions?: string[]; tags?: string[]; + source?: TAppCatalogSource; + runtime?: IAppVersionConfig; + channel?: string; + upgradeStrategy?: TAppUpgradeStrategy; + resolvedSource?: IResolvedCatalogSource; } export interface IAppCatalogVolume { @@ -50,6 +92,9 @@ export interface IAppMeta { versions: string[]; maintainer?: string; links?: Record; + tags?: string[]; + source?: TAppCatalogSource; + resolvedSource?: IResolvedCatalogSource; } export interface IAppVersionConfig { @@ -66,6 +111,53 @@ export interface IAppVersionConfig { mariadb?: boolean; }; minOneboxVersion?: string; + catalogVersion?: string; + upgradeStrategy?: TAppUpgradeStrategy; + source?: TAppCatalogSource; + resolvedSource?: IResolvedCatalogSource; + resolvedImageDigest?: string; + changelog?: string; + breaking?: boolean; + requiresManualReview?: boolean; + migrationRequired?: boolean; + backupBeforeUpgrade?: boolean; + requiresFeatures?: string[]; + healthCheck?: { + path?: string; + port?: number; + expectedStatus?: number; + }; +} + +export interface IServezoneCatalogAppInfo { + id: string; + name: string; + description: string; + category: string; + iconName?: string; + iconUrl?: string; + tags?: string[]; + maintainer?: string; + links?: Record; +} + +export interface IServezoneCatalogVersion extends IAppVersionConfig { + version: string; +} + +export interface IServezoneCatalogManifest { + schemaVersion: number; + app: IServezoneCatalogAppInfo; + latestVersion?: string; + channel?: string; + channels?: Record; + source?: TAppCatalogSource; + runtime?: IAppVersionConfig; + versions?: IServezoneCatalogVersion[]; + policy?: { + allowMutableImage?: boolean; + defaultChannel?: string; + }; } export interface IAppInstallOptions { @@ -94,6 +186,7 @@ export interface IMigrationResult { success: boolean; envVars?: Record; image?: string; + imageDigest?: string; port?: number; volumes?: IAppCatalogVolume[]; publishedPorts?: IAppCatalogPublishedPort[]; diff --git a/ts/classes/appstore.ts b/ts/classes/appstore.ts index 6408be4..9c9b910 100644 --- a/ts/classes/appstore.ts +++ b/ts/classes/appstore.ts @@ -14,6 +14,10 @@ import type { IMigrationContext, IMigrationResult, IUpgradeableService, + IAppCatalogDockerImageSource, + IAppCatalogRepoManifestSource, + IResolvedCatalogSource, + IServezoneCatalogManifest, } from './appstore-types.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; @@ -21,15 +25,40 @@ 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 = 'https://code.foss.global/serve.zone/appstore-apptemplates/raw/branch/main'; + private readonly repoBaseUrl: string; + private readonly fetchRef: typeof fetch; + private readonly resolveDockerDigests: boolean; private readonly cacheTtlMs = 5 * 60 * 1000; // 5 minutes - constructor(oneboxRef: Onebox) { + 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 { @@ -52,11 +81,12 @@ export class AppStoreManager { } try { - const catalog = await this.fetchJson('catalog.json') as ICatalog; + const catalog = await this.fetchCatalog(); if (catalog && catalog.apps && Array.isArray(catalog.apps)) { - this.catalogCache = catalog; + const resolvedCatalog = await this.resolveCatalog(catalog); + this.catalogCache = resolvedCatalog; this.lastFetchTime = now; - return catalog; + return resolvedCatalog; } throw new Error('Invalid catalog format'); } catch (error) { @@ -82,6 +112,14 @@ export class AppStoreManager { */ 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)}`); @@ -93,7 +131,37 @@ export class AppStoreManager { */ async getAppVersionConfig(appId: string, version: string): Promise { try { - const config = await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig; + 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) { @@ -106,6 +174,7 @@ export class AppStoreManager { 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'); @@ -133,7 +202,8 @@ export class AppStoreManager { enableRedis: Boolean(config.platformRequirements?.redis), enableMariaDB: Boolean(config.platformRequirements?.mariadb), appTemplateId: optionsArg.appId, - appTemplateVersion: version, + appTemplateVersion: catalogVersion, + imageDigest: config.resolvedImageDigest, }); } @@ -206,6 +276,7 @@ export class AppStoreManager { return { success: true, image: config.image, + imageDigest: config.resolvedImageDigest, port: config.port, volumes: this.normalizeVolumes(config.volumes), publishedPorts: config.publishedPorts, @@ -309,6 +380,10 @@ export class AppStoreManager { updates.image = migrationResult.image; } + if (migrationResult.imageDigest !== undefined) { + updates.imageDigest = migrationResult.imageDigest; + } + if (migrationResult.port) { updates.port = migrationResult.port; } @@ -365,12 +440,425 @@ export class AppStoreManager { 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 fetch(url); + const response = await this.fetchRef(url); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } @@ -382,7 +870,7 @@ export class AppStoreManager { */ private async fetchText(path: string): Promise { const url = `${this.repoBaseUrl}/${path}`; - const response = await fetch(url); + const response = await this.fetchRef(url); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } @@ -408,7 +896,7 @@ export class AppStoreManager { if (!configArg.image || typeof configArg.image !== 'string') { throw new Error(`Invalid ${labelArg}: image is required`); } - if (configArg.image.endsWith(':latest')) { + if (configArg.image.endsWith(':latest') && !configArg.resolvedImageDigest) { logger.warn(`App template ${labelArg} uses a mutable ':latest' image tag`); } this.assertValidPort(configArg.port, `${labelArg} port`); diff --git a/ts/classes/services.ts b/ts/classes/services.ts index 8e09731..1dcb014 100644 --- a/ts/classes/services.ts +++ b/ts/classes/services.ts @@ -107,6 +107,7 @@ export class OneboxServicesManager { registryRepository: options.useOneboxRegistry ? options.name : undefined, registryImageTag: options.registryImageTag || 'latest', autoUpdateOnPush: options.autoUpdateOnPush, + imageDigest: options.imageDigest, // Platform requirements platformRequirements, // App Store template tracking diff --git a/ts/types.ts b/ts/types.ts index d99bb41..e913129 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -333,6 +333,7 @@ export interface IServiceDeployOptions { useOneboxRegistry?: boolean; registryImageTag?: string; autoUpdateOnPush?: boolean; + imageDigest?: string; // Platform service requirements enableMongoDB?: boolean; enableS3?: boolean;