feat(appstore): resolve repo manifests and docker digest-tracked images
This commit is contained in:
@@ -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<string, string>;
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
export interface IServezoneCatalogVersion extends IAppVersionConfig {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface IServezoneCatalogManifest {
|
||||
schemaVersion: number;
|
||||
app: IServezoneCatalogAppInfo;
|
||||
latestVersion?: string;
|
||||
channel?: string;
|
||||
channels?: Record<string, string>;
|
||||
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<string, string>;
|
||||
image?: string;
|
||||
imageDigest?: string;
|
||||
port?: number;
|
||||
volumes?: IAppCatalogVolume[];
|
||||
publishedPorts?: IAppCatalogPublishedPort[];
|
||||
|
||||
+498
-10
@@ -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<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 = '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<void> {
|
||||
@@ -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<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)}`);
|
||||
@@ -93,7 +131,37 @@ export class AppStoreManager {
|
||||
*/
|
||||
async getAppVersionConfig(appId: string, version: string): Promise<IAppVersionConfig> {
|
||||
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<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 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<string> {
|
||||
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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -333,6 +333,7 @@ export interface IServiceDeployOptions {
|
||||
useOneboxRegistry?: boolean;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
imageDigest?: string;
|
||||
// Platform service requirements
|
||||
enableMongoDB?: boolean;
|
||||
enableS3?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user