Compare commits

...

4 Commits

Author SHA1 Message Date
jkunz be53f179ab v1.31.0
Release / build-and-release (push) Successful in 2m29s
2026-05-25 01:40:38 +00:00
jkunz db52934f35 feat(appstore): resolve repo manifests and docker digest-tracked images 2026-05-25 01:39:59 +00:00
jkunz d29257dcf7 v1.30.2 2026-05-24 21:23:33 +00:00
jkunz 3b2b806165 fix(smartproxy): clean up legacy reverse proxy naming for SmartProxy 2026-05-24 21:20:46 +00:00
16 changed files with 727 additions and 27 deletions
+21
View File
@@ -3,6 +3,27 @@
## Pending
## 2026-05-25 - 1.31.0
### 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
- reduce remaining reverse proxy wording to required legacy SmartProxy cleanup and migration identifiers
- clean up legacy reverse proxy naming for SmartProxy (smartproxy)
- Update legacy reverse proxy service naming and logs used during SmartProxy startup cleanup.
- Clarify migration and documentation wording for the legacy reverse proxy to SmartProxy transition.
- Bump @serve.zone/catalog to ^2.12.6 and add pnpm workspace build dependency settings.
## 2026-05-24 - 1.30.1
### Fixes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.30.1",
"version": "1.31.0",
"exports": "./mod.ts",
"tasks": {
"test": "deno test --allow-all test/",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@serve.zone/onebox",
"version": "1.30.1",
"version": "1.31.0",
"description": "Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers",
"main": "mod.ts",
"type": "module",
@@ -58,7 +58,7 @@
"@api.global/typedsocket": "^4.1.3",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-element": "^2.2.4",
"@serve.zone/catalog": "^2.12.5"
"@serve.zone/catalog": "^2.12.6"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.10.4",
+5 -5
View File
@@ -21,8 +21,8 @@ importers:
specifier: ^2.2.4
version: 2.2.4
'@serve.zone/catalog':
specifier: ^2.12.5
version: 2.12.5(@tiptap/pm@2.27.2)
specifier: ^2.12.6
version: 2.12.6(@tiptap/pm@2.27.2)
devDependencies:
'@git.zone/tsbundle':
specifier: ^2.10.4
@@ -977,8 +977,8 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@serve.zone/catalog@2.12.5':
resolution: {integrity: sha512-0AgHnxonJ7xyYdA02s4tN9/aZG8yBYml4sAA7AUt9fYpRtKYMuZXUcUOS3Rz/FvUu1PrKe7QLtex9VK5IqZDPw==}
'@serve.zone/catalog@2.12.6':
resolution: {integrity: sha512-FjieZNCHTCHufMre8OSP8bFP9L4DPL9yNtd7UMwD1yQ8wublgAq6eWrx6Tfb+3k8Hyof33BBt4rbFyrvIEBk+A==}
'@tempfix/lenis@1.3.20':
resolution: {integrity: sha512-ypeB0FuHLHOCQXW4d0RQ69txPJJH+1CHcpsZIUdcv2t1vR0IVyQr2vHihtde9UOXhjzqEnUphWon/UcJNsa0YA==}
@@ -3572,7 +3572,7 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@serve.zone/catalog@2.12.5(@tiptap/pm@2.27.2)':
'@serve.zone/catalog@2.12.6(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.81.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.6
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: true
ignoredBuiltDependencies:
- '@design.estate/dees-catalog'
+1 -1
View File
@@ -46,7 +46,7 @@ ts/database/
## Current Migration Version: 15
Migration 15 renames the core reverse proxy platform service from `caddy` to `smartproxy`.
Migration 15 renames the legacy core reverse proxy platform service type to `smartproxy`.
## Reverse Proxy (April 2026 - SmartProxy Docker Service)
+90
View File
@@ -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();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.30.1',
version: '1.31.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}
+93
View File
@@ -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
View File
@@ -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`);
+1
View File
@@ -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
+6 -4
View File
@@ -10,7 +10,7 @@ import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
const SMARTPROXY_SERVICE_NAME = 'onebox-smartproxy';
const LEGACY_CADDY_SERVICE_NAME = 'onebox-caddy';
const LEGACY_REVERSE_PROXY_SERVICE_NAME = 'onebox-caddy';
const SMARTPROXY_IMAGE = 'code.foss.global/host.today/ht-docker-smartproxy:latest';
const SMARTPROXY_ADMIN_CONTAINER_PORT = 3000;
const SMARTPROXY_HTTP_CONTAINER_PORT = 80;
@@ -102,10 +102,12 @@ export class SmartProxyManager {
logger.info('Starting SmartProxy Docker service...');
const legacyService = await this.getExistingService(LEGACY_CADDY_SERVICE_NAME);
const legacyService = await this.getExistingService(LEGACY_REVERSE_PROXY_SERVICE_NAME);
if (legacyService) {
logger.info('Legacy Caddy service exists, removing it before SmartProxy startup...');
await this.removeService(LEGACY_CADDY_SERVICE_NAME);
logger.info(
`Legacy reverse proxy service ${LEGACY_REVERSE_PROXY_SERVICE_NAME} exists, removing it before SmartProxy startup...`,
);
await this.removeService(LEGACY_REVERSE_PROXY_SERVICE_NAME);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
@@ -3,7 +3,7 @@ import type { TQueryFunction } from '../types.ts';
export class Migration015SmartProxyPlatformService extends BaseMigration {
readonly version = 15;
readonly description = 'Rename Caddy platform service to SmartProxy';
readonly description = 'Rename legacy reverse proxy platform service to SmartProxy';
up(query: TQueryFunction): void {
query(
+1
View File
@@ -333,6 +333,7 @@ export interface IServiceDeployOptions {
useOneboxRegistry?: boolean;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
// Platform service requirements
enableMongoDB?: boolean;
enableS3?: boolean;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.30.1',
version: '1.31.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}