feat(appstore): add service volumes and published ports
This commit is contained in:
+225
-2
@@ -8,6 +8,8 @@ import type {
|
||||
ICatalog,
|
||||
ICatalogApp,
|
||||
IAppMeta,
|
||||
IAppCatalogVolume,
|
||||
IAppInstallOptions,
|
||||
IAppVersionConfig,
|
||||
IMigrationContext,
|
||||
IMigrationResult,
|
||||
@@ -16,7 +18,8 @@ import type {
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
import type { IService } from '../types.ts';
|
||||
import type { IService, IServiceVolume } from '../types.ts';
|
||||
import { projectInfo } from '../info.ts';
|
||||
|
||||
export class AppStoreManager {
|
||||
private oneboxRef: Onebox;
|
||||
@@ -90,12 +93,50 @@ export class AppStoreManager {
|
||||
*/
|
||||
async getAppVersionConfig(appId: string, version: string): Promise<IAppVersionConfig> {
|
||||
try {
|
||||
return await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig;
|
||||
const config = await this.fetchJson(`apps/${appId}/versions/${version}/config.json`) as IAppVersionConfig;
|
||||
this.validateAppVersionConfig(config, `${appId}@${version}`);
|
||||
return config;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch config for ${appId}@${version}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async installApp(optionsArg: IAppInstallOptions): Promise<IService> {
|
||||
this.validateInstallOptions(optionsArg);
|
||||
const appMeta = await this.getAppMeta(optionsArg.appId);
|
||||
const version = optionsArg.version || appMeta.latestVersion;
|
||||
const config = await this.getAppVersionConfig(optionsArg.appId, version);
|
||||
this.assertRuntimeCompatibility(config);
|
||||
const servicePort = optionsArg.port || config.port;
|
||||
this.assertValidPort(servicePort, 'install service port');
|
||||
const volumes = this.normalizeVolumes(config.volumes);
|
||||
const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || [];
|
||||
this.validatePublishedPorts(publishedPorts, `${optionsArg.appId}@${version}`);
|
||||
|
||||
const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {});
|
||||
if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) {
|
||||
throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}');
|
||||
}
|
||||
|
||||
return await this.oneboxRef.services.deployService({
|
||||
name: optionsArg.serviceName,
|
||||
image: config.image,
|
||||
port: servicePort,
|
||||
domain: optionsArg.domain,
|
||||
autoDNS: optionsArg.autoDNS,
|
||||
envVars,
|
||||
volumes,
|
||||
publishedPorts,
|
||||
enableMongoDB: Boolean(config.platformRequirements?.mongodb),
|
||||
enableS3: Boolean(config.platformRequirements?.s3),
|
||||
enableClickHouse: Boolean(config.platformRequirements?.clickhouse),
|
||||
enableRedis: Boolean(config.platformRequirements?.redis),
|
||||
enableMariaDB: Boolean(config.platformRequirements?.mariadb),
|
||||
appTemplateId: optionsArg.appId,
|
||||
appTemplateVersion: version,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare deployed services against catalog to find those with available upgrades
|
||||
*/
|
||||
@@ -165,6 +206,9 @@ export class AppStoreManager {
|
||||
return {
|
||||
success: true,
|
||||
image: config.image,
|
||||
port: config.port,
|
||||
volumes: this.normalizeVolumes(config.volumes),
|
||||
publishedPorts: config.publishedPorts,
|
||||
envVars: undefined, // Keep existing env vars
|
||||
warnings: [],
|
||||
};
|
||||
@@ -265,6 +309,18 @@ export class AppStoreManager {
|
||||
updates.image = migrationResult.image;
|
||||
}
|
||||
|
||||
if (migrationResult.port) {
|
||||
updates.port = migrationResult.port;
|
||||
}
|
||||
|
||||
if (migrationResult.volumes) {
|
||||
updates.volumes = migrationResult.volumes;
|
||||
}
|
||||
|
||||
if (migrationResult.publishedPorts) {
|
||||
updates.publishedPorts = migrationResult.publishedPorts;
|
||||
}
|
||||
|
||||
if (migrationResult.envVars) {
|
||||
// Merge: migration result provides base, user overrides preserved
|
||||
const mergedEnvVars = { ...migrationResult.envVars };
|
||||
@@ -332,4 +388,171 @@ export class AppStoreManager {
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
public normalizeVolumes(volumesArg: IAppVersionConfig['volumes'] = []): IServiceVolume[] {
|
||||
return volumesArg.map((volumeArg, indexArg): IAppCatalogVolume => {
|
||||
if (typeof volumeArg === 'string') {
|
||||
return { mountPath: volumeArg };
|
||||
}
|
||||
return volumeArg;
|
||||
}).map((volumeArg, indexArg) => {
|
||||
this.validateVolume(volumeArg, `volume ${indexArg + 1}`);
|
||||
return volumeArg;
|
||||
});
|
||||
}
|
||||
|
||||
public validateAppVersionConfig(configArg: IAppVersionConfig, labelArg = 'app config'): void {
|
||||
if (!configArg || typeof configArg !== 'object') {
|
||||
throw new Error(`Invalid ${labelArg}: config must be an object`);
|
||||
}
|
||||
if (!configArg.image || typeof configArg.image !== 'string') {
|
||||
throw new Error(`Invalid ${labelArg}: image is required`);
|
||||
}
|
||||
if (configArg.image.endsWith(':latest')) {
|
||||
logger.warn(`App template ${labelArg} uses a mutable ':latest' image tag`);
|
||||
}
|
||||
this.assertValidPort(configArg.port, `${labelArg} port`);
|
||||
|
||||
for (const envVar of configArg.envVars || []) {
|
||||
if (!envVar.key || !/^[A-Z_][A-Z0-9_]*$/.test(envVar.key)) {
|
||||
throw new Error(`Invalid ${labelArg}: env var key '${envVar.key}' is not valid`);
|
||||
}
|
||||
if (envVar.value !== undefined && typeof envVar.value !== 'string') {
|
||||
throw new Error(`Invalid ${labelArg}: env var '${envVar.key}' value must be a string`);
|
||||
}
|
||||
}
|
||||
|
||||
this.normalizeVolumes(configArg.volumes);
|
||||
this.validatePublishedPorts(configArg.publishedPorts || [], labelArg);
|
||||
}
|
||||
|
||||
private validateInstallOptions(optionsArg: IAppInstallOptions): void {
|
||||
if (!optionsArg.appId || !/^[a-z0-9][a-z0-9-]*$/.test(optionsArg.appId)) {
|
||||
throw new Error(`Invalid app id: ${optionsArg.appId}`);
|
||||
}
|
||||
if (!optionsArg.serviceName || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,119}$/.test(optionsArg.serviceName)) {
|
||||
throw new Error(`Invalid service name: ${optionsArg.serviceName}`);
|
||||
}
|
||||
if (optionsArg.port !== undefined) {
|
||||
this.assertValidPort(optionsArg.port, 'install service port');
|
||||
}
|
||||
if (optionsArg.publishedPorts) {
|
||||
this.validatePublishedPorts(optionsArg.publishedPorts, `install options for ${optionsArg.appId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateVolume(volumeArg: IAppCatalogVolume, labelArg: string): void {
|
||||
if (!volumeArg.mountPath || !volumeArg.mountPath.startsWith('/')) {
|
||||
throw new Error(`Invalid ${labelArg}: mountPath must be an absolute path`);
|
||||
}
|
||||
if (volumeArg.mountPath.includes(':')) {
|
||||
throw new Error(`Invalid ${labelArg}: mountPath must not contain ':'`);
|
||||
}
|
||||
if ((volumeArg.source || volumeArg.name)?.includes(':')) {
|
||||
throw new Error(`Invalid ${labelArg}: source/name must not contain ':'`);
|
||||
}
|
||||
}
|
||||
|
||||
private validatePublishedPorts(
|
||||
publishedPortsArg: IAppVersionConfig['publishedPorts'] = [],
|
||||
labelArg: string,
|
||||
): void {
|
||||
const seenPublishedPorts = new Set<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.`);
|
||||
}
|
||||
}
|
||||
|
||||
private getAppStoreEnvVars(
|
||||
configArg: IAppVersionConfig,
|
||||
overridesArg: Record<string, string>,
|
||||
): Record<string, string> {
|
||||
const envVars: Record<string, string> = {};
|
||||
const missingRequiredEnvVars: string[] = [];
|
||||
|
||||
for (const envVar of configArg.envVars || []) {
|
||||
const value = overridesArg[envVar.key] ?? envVar.value ?? '';
|
||||
if (envVar.required && !value) {
|
||||
missingRequiredEnvVars.push(envVar.key);
|
||||
}
|
||||
envVars[envVar.key] = value;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(overridesArg)) {
|
||||
envVars[key] = value;
|
||||
}
|
||||
|
||||
if (missingRequiredEnvVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
private requiresTemplateValue(envVarsArg: Record<string, string>, templateNameArg: string): boolean {
|
||||
return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`));
|
||||
}
|
||||
|
||||
private assertRuntimeCompatibility(configArg: IAppVersionConfig): void {
|
||||
if (!configArg.minOneboxVersion) return;
|
||||
if (this.compareVersions(projectInfo.version, configArg.minOneboxVersion) < 0) {
|
||||
throw new Error(
|
||||
`App requires Onebox >= ${configArg.minOneboxVersion}; current version is ${projectInfo.version}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private compareVersions(versionAArg: string, versionBArg: string): number {
|
||||
const normalize = (versionArg: string) => versionArg.replace(/^v/, '').split('.').map((partArg) => Number(partArg) || 0);
|
||||
const a = normalize(versionAArg);
|
||||
const b = normalize(versionBArg);
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
const diff = (a[i] || 0) - (b[i] || 0);
|
||||
if (diff !== 0) return diff > 0 ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user