feat(appstore): publish resolver client

This commit is contained in:
2026-05-25 02:37:37 +00:00
parent 7412de8ee7
commit 2b53797380
14 changed files with 9028 additions and 12 deletions
+623
View File
@@ -0,0 +1,623 @@
import type * as interfaces from '@serve.zone/interfaces';
import type {
IAppStoreResolverOptions,
IParsedDockerImageReference,
IResolvedAppStoreApp,
TAppStoreApp,
TAppStoreDockerImageSource,
TAppStoreIndex,
TAppStoreRepoManifestSource,
TAppStoreResolvedSource,
TAppStoreVersionConfig,
TServezoneAppStoreManifest,
} from './types.js';
export class AppStoreResolver {
public readonly baseUrl: string;
private readonly fetchRef: typeof fetch;
private readonly resolveDockerDigests: boolean;
private readonly now: () => Date;
private appStoreCache: TAppStoreIndex | null = null;
private sourceAppCache = new Map<string, IResolvedAppStoreApp>();
constructor(optionsArg: IAppStoreResolverOptions = {}) {
this.baseUrl = optionsArg.baseUrl || 'https://code.foss.global/serve.zone/appstore/raw/branch/main';
this.fetchRef = optionsArg.fetch || fetch;
this.resolveDockerDigests = optionsArg.resolveDockerDigests ?? true;
this.now = optionsArg.now || (() => new Date());
}
public async getAppStoreIndex(): Promise<TAppStoreIndex> {
if (this.appStoreCache) {
return this.appStoreCache;
}
const appStore = await this.fetchAppStoreIndex();
this.appStoreCache = await this.resolveAppStore(appStore);
return this.appStoreCache;
}
public async getApps(): Promise<TAppStoreApp[]> {
return (await this.getAppStoreIndex()).apps;
}
public async getAppMeta(appIdArg: string): Promise<interfaces.appstore.IAppStoreAppMeta> {
const app = await this.getAppStoreApp(appIdArg);
if (app?.source?.type === 'repoManifest') {
return (await this.resolveRepoManifestSource(app.source, app)).appMeta;
}
if (app?.source?.type === 'dockerImage') {
return this.createAppMetaFromAppStoreApp(app);
}
return await this.fetchJson(`apps/${appIdArg}/app.json`) as interfaces.appstore.IAppStoreAppMeta;
}
public async getAppVersionConfig(
appIdArg: string,
versionArg: string,
): Promise<TAppStoreVersionConfig> {
const app = await this.getAppStoreApp(appIdArg);
if (app?.source?.type === 'repoManifest') {
const resolvedApp = await this.resolveRepoManifestSource(app.source, app);
const config = resolvedApp.configsByVersion.get(versionArg);
if (!config) {
throw new Error(`Version '${versionArg}' is not defined by the linked appstore manifest`);
}
this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`);
return config;
}
if (app?.source?.type === 'dockerImage' && app.runtime) {
const config: TAppStoreVersionConfig = { ...app.runtime };
await this.applyDockerImageSourceToConfig(app.source, config, versionArg);
this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`);
return config;
}
let config: TAppStoreVersionConfig;
try {
config = await this.fetchJson(`apps/${appIdArg}/versions/${versionArg}/config.json`) as TAppStoreVersionConfig;
} catch (error) {
if (app?.source?.type !== 'dockerImage') {
throw error;
}
const appMeta = await this.fetchJson(`apps/${appIdArg}/app.json`) as interfaces.appstore.IAppStoreAppMeta;
config = await this.fetchJson(`apps/${appIdArg}/versions/${appMeta.latestVersion}/config.json`) as TAppStoreVersionConfig;
}
if (app?.source?.type === 'dockerImage') {
await this.applyDockerImageSourceToConfig(app.source, config, versionArg);
}
this.validateAppStoreVersionConfig(config, `${appIdArg}@${versionArg}`);
return config;
}
public async resolveAppStore(appStoreArg: unknown): Promise<TAppStoreIndex> {
const appStore = parseAppStoreIndex(appStoreArg);
this.sourceAppCache.clear();
const apps: TAppStoreApp[] = [];
for (const app of appStore.apps) {
apps.push(await this.resolveAppStoreApp(app));
}
return {
...appStore,
apps,
resolvedAt: this.now().toISOString(),
};
}
public async resolveRepoManifestSource(
sourceArg: TAppStoreRepoManifestSource,
appArg?: TAppStoreApp,
): Promise<IResolvedAppStoreApp> {
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 createSha256Hex(manifestText);
const manifest = parseServezoneAppStoreManifest(JSON.parse(manifestText));
const resolvedApp = await this.resolveServezoneAppStoreManifest(manifest, {
type: 'repoManifest',
url: sourceArg.url,
ref: sourceArg.ref,
manifestHash,
resolvedAt: this.now().toISOString(),
});
if (appArg) {
resolvedApp.appStoreApp = {
...resolvedApp.appStoreApp,
...withoutUndefined(appArg),
latestVersion: resolvedApp.appStoreApp.latestVersion,
versions: resolvedApp.appStoreApp.versions,
source: appArg.source,
tags: appArg.tags || resolvedApp.appStoreApp.tags,
resolvedSource: resolvedApp.appStoreApp.resolvedSource,
};
resolvedApp.appMeta = {
...resolvedApp.appMeta,
id: resolvedApp.appStoreApp.id,
name: resolvedApp.appStoreApp.name,
description: resolvedApp.appStoreApp.description,
category: resolvedApp.appStoreApp.category,
iconName: resolvedApp.appStoreApp.iconName,
latestVersion: resolvedApp.appStoreApp.latestVersion,
versions: resolvedApp.appStoreApp.versions || resolvedApp.appMeta.versions,
tags: resolvedApp.appStoreApp.tags,
source: appArg.source,
resolvedSource: resolvedApp.appStoreApp.resolvedSource,
};
}
this.sourceAppCache.set(cacheKey, resolvedApp);
return resolvedApp;
}
public async resolveDockerImageSource(
sourceArg: TAppStoreDockerImageSource,
): Promise<TAppStoreResolvedSource> {
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: this.now().toISOString(),
};
}
public async resolveDockerImageDigest(imageArg: string): Promise<string | null> {
const parsedImage = parseDockerImageReference(imageArg);
if (parsedImage.digest) {
return parsedImage.digest;
}
return await this.fetchDockerManifestDigest(parsedImage);
}
public validateAppStoreVersionConfig(
configArg: TAppStoreVersionConfig,
labelArg = 'appstore 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`);
}
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);
}
public normalizeVolumes(
volumesArg: TAppStoreVersionConfig['volumes'] = [],
): interfaces.appstore.IAppStoreVolume[] {
return volumesArg.map((volumeArg): interfaces.appstore.IAppStoreVolume => {
if (typeof volumeArg === 'string') {
return { mountPath: volumeArg };
}
return volumeArg;
}).map((volumeArg, indexArg) => {
this.validateVolume(volumeArg, `volume ${indexArg + 1}`);
return volumeArg;
});
}
private async fetchAppStoreIndex(): Promise<TAppStoreIndex> {
try {
return await this.fetchJson('appstore.resolved.json') as TAppStoreIndex;
} catch {
return await this.fetchJson('appstore.json') as TAppStoreIndex;
}
}
private async resolveAppStoreApp(appArg: TAppStoreApp): Promise<TAppStoreApp> {
if (appArg.source?.type === 'repoManifest') {
const resolvedApp = await this.resolveRepoManifestSource(appArg.source, appArg);
return {
...resolvedApp.appStoreApp,
...withoutUndefined(appArg),
latestVersion: resolvedApp.appStoreApp.latestVersion,
versions: resolvedApp.appStoreApp.versions,
source: appArg.source,
tags: appArg.tags || resolvedApp.appStoreApp.tags,
resolvedSource: resolvedApp.appStoreApp.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 = createAppStoreVersionForDockerSource(
appArg.source,
appArg.latestVersion,
resolvedSource?.imageDigest,
);
return {
...appArg,
runtime: config,
latestVersion,
versions: uniqueStrings([...(appArg.versions || []), latestVersion]),
upgradeStrategy: appArg.source.tracking === 'digest' ? 'dockerDigest' : appArg.upgradeStrategy,
resolvedSource,
};
}
return appArg;
}
private async resolveServezoneAppStoreManifest(
manifestArg: TServezoneAppStoreManifest,
resolvedSourceArg: TAppStoreResolvedSource,
): Promise<IResolvedAppStoreApp> {
const configsByVersion = new Map<string, TAppStoreVersionConfig>();
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: TAppStoreVersionConfig = {
...versionConfig,
source: versionConfig.source || manifestArg.source,
resolvedSource: resolvedSourceArg,
};
await this.resolveConfigSource(config, sourceVersion);
const resolvedVersion = config.appStoreVersion || sourceVersion;
config.appStoreVersion = resolvedVersion;
this.validateAppStoreVersionConfig(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: TAppStoreVersionConfig = {
...manifestArg.runtime,
source: manifestArg.runtime.source || manifestArg.source,
resolvedSource: resolvedSourceArg,
};
await this.resolveConfigSource(config, sourceVersion);
const resolvedVersion = config.appStoreVersion || sourceVersion;
config.appStoreVersion = resolvedVersion;
this.validateAppStoreVersionConfig(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('Appstore 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 = uniqueStrings(versions);
const appStoreApp: TAppStoreApp = {
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: getUpgradeStrategyForConfig(configsByVersion.get(latestVersion)),
resolvedSource: resolvedSourceArg,
};
const appMeta: interfaces.appstore.IAppStoreAppMeta = {
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 { appStoreApp, appMeta, configsByVersion };
}
private async resolveConfigSource(configArg: TAppStoreVersionConfig, versionArg: string): Promise<void> {
if (configArg.source?.type === 'dockerImage') {
await this.applyDockerImageSourceToConfig(configArg.source, configArg, versionArg);
}
}
private async applyDockerImageSourceToConfig(
sourceArg: TAppStoreDockerImageSource,
configArg: TAppStoreVersionConfig,
versionArg: string,
): Promise<TAppStoreVersionConfig> {
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.appStoreVersion = createAppStoreVersionForDockerSource(
sourceArg,
versionArg,
resolvedSource.imageDigest,
);
return configArg;
}
private createAppMetaFromAppStoreApp(appArg: TAppStoreApp): interfaces.appstore.IAppStoreAppMeta {
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 getAppStoreApp(appIdArg: string): Promise<TAppStoreApp | undefined> {
const appStore = await this.getAppStoreIndex();
return appStore.apps.find((appArg) => appArg.id === appIdArg);
}
private async fetchJson(pathArg: string): Promise<unknown> {
const url = `${this.baseUrl}/${pathArg}`;
const response = await this.fetchRef(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
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();
}
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 validateVolume(volumeArg: interfaces.appstore.IAppStoreVolume, 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: TAppStoreVersionConfig['publishedPorts'] = [],
labelArg: string,
): void {
const seenPublishedPorts = new Set<string>();
for (const portArg of publishedPortsArg) {
const protocol = portArg.protocol || 'tcp';
const targetStart = portArg.targetPort;
const targetEnd = portArg.targetPortEnd || targetStart;
const publishedStart = portArg.publishedPort || targetStart;
const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart));
const hostIp = portArg.hostIp || '0.0.0.0';
if (!['tcp', 'udp'].includes(protocol)) {
throw new Error(`Invalid ${labelArg}: published port protocol '${protocol}' is not supported`);
}
this.assertValidPort(targetStart, `${labelArg} targetPort`);
this.assertValidPort(targetEnd, `${labelArg} targetPortEnd`);
this.assertValidPort(publishedStart, `${labelArg} publishedPort`);
this.assertValidPort(publishedEnd, `${labelArg} publishedPortEnd`);
if (targetEnd < targetStart || publishedEnd < publishedStart) {
throw new Error(`Invalid ${labelArg}: published port ranges must be ascending`);
}
if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) {
throw new Error(`Invalid ${labelArg}: target and published port ranges must have the same size`);
}
if ((targetEnd - targetStart) > 1000) {
throw new Error(`Invalid ${labelArg}: published port ranges may not exceed 1001 ports`);
}
for (let offset = 0; offset <= targetEnd - targetStart; offset++) {
const publishedPort = publishedStart + offset;
const publishedKey = `${hostIp}/${protocol}/${publishedPort}`;
const wildcardKey = `0.0.0.0/${protocol}/${publishedPort}`;
const conflictsWithWildcard = hostIp === '0.0.0.0'
? Array.from(seenPublishedPorts).some((keyArg) => keyArg.endsWith(`/${protocol}/${publishedPort}`))
: seenPublishedPorts.has(wildcardKey);
if (seenPublishedPorts.has(publishedKey) || conflictsWithWildcard) {
throw new Error(`Invalid ${labelArg}: duplicate published port ${hostIp}:${publishedPort}/${protocol}`);
}
seenPublishedPorts.add(publishedKey);
}
}
}
private assertValidPort(portArg: number, labelArg: string): void {
if (!Number.isInteger(portArg) || portArg < 1 || portArg > 65535) {
throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer port between 1 and 65535.`);
}
}
}
export function parseAppStoreIndex(inputArg: unknown): TAppStoreIndex {
const appStore = inputArg as TAppStoreIndex;
if (!appStore || typeof appStore !== 'object' || !Array.isArray(appStore.apps)) {
throw new Error('Invalid appstore format');
}
return appStore;
}
export function parseServezoneAppStoreManifest(inputArg: unknown): TServezoneAppStoreManifest {
const manifest = inputArg as TServezoneAppStoreManifest;
if (!manifest || typeof manifest !== 'object') {
throw new Error('Appstore manifest must be an object');
}
if (manifest.schemaVersion !== 1) {
throw new Error(`Unsupported appstore manifest schemaVersion '${manifest.schemaVersion}'`);
}
if (!manifest.app?.id || !manifest.app?.name) {
throw new Error('Appstore manifest app.id and app.name are required');
}
return manifest;
}
export function parseDockerImageReference(imageArg: string): IParsedDockerImageReference {
const [imageWithoutDigest, digest] = imageArg.split('@');
const imageParts = imageWithoutDigest.split('/');
const firstPart = imageParts[0];
const hasExplicitRegistry = imageParts.length > 1
&& (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 };
}
export function createAppStoreVersionForDockerSource(
sourceArg: TAppStoreDockerImageSource,
fallbackVersionArg: string,
digestArg?: string,
): string {
if (sourceArg.tracking !== 'digest' || !digestArg) {
return fallbackVersionArg;
}
const parsedImage = parseDockerImageReference(sourceArg.image);
return `${parsedImage.tag}@${digestArg}`;
}
async function 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('');
}
function uniqueStrings(valuesArg: string[]): string[] {
return Array.from(new Set(valuesArg.filter(Boolean)));
}
function withoutUndefined<T extends object>(objectArg: T): Partial<T> {
return Object.fromEntries(
Object.entries(objectArg).filter(([, valueArg]) => valueArg !== undefined),
) as Partial<T>;
}
function getUpgradeStrategyForConfig(
configArg?: TAppStoreVersionConfig,
): interfaces.appstore.IAppStoreApp['upgradeStrategy'] {
if (configArg?.upgradeStrategy) return configArg.upgradeStrategy;
if (configArg?.source?.type === 'dockerImage' && configArg.source.tracking === 'digest') return 'dockerDigest';
return undefined;
}