feat(appstore): add service volumes and published ports

This commit is contained in:
2026-05-24 07:28:18 +00:00
parent e6ebac76b4
commit 5228eeaa23
26 changed files with 1790 additions and 348 deletions
+113
View File
@@ -0,0 +1,113 @@
import { assertEquals, assertThrows } from '@std/assert';
import { AppStoreManager } from '../ts/classes/appstore.ts';
import { OneboxDockerManager } from '../ts/classes/docker.ts';
import type { IAppVersionConfig } from '../ts/classes/appstore-types.ts';
import type { IService } from '../ts/types.ts';
const createAppStore = () => new AppStoreManager({} as any);
const baseConfig: IAppVersionConfig = {
image: 'example/app:1.0.0',
port: 3000,
envVars: [
{
key: 'APP_PORT',
value: '3000',
description: 'Application port',
required: true,
},
],
};
const baseService: IService = {
id: 1,
name: 'test-service',
image: 'example/app:1.0.0',
envVars: {},
port: 3000,
status: 'stopped',
createdAt: Date.now(),
updatedAt: Date.now(),
};
Deno.test('appstore normalizes and validates app template runtime fields', () => {
const appStore = createAppStore();
const normalizedVolumes = appStore.normalizeVolumes([
'/data/app',
{ mountPath: '/config', readOnly: true },
]);
assertEquals(normalizedVolumes, [
{ mountPath: '/data/app' },
{ mountPath: '/config', readOnly: true },
]);
appStore.validateAppVersionConfig({
...baseConfig,
volumes: normalizedVolumes,
publishedPorts: [
{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' },
{ targetPort: 20000, targetPortEnd: 20002, publishedPort: 20000, publishedPortEnd: 20002, protocol: 'udp' },
],
});
});
Deno.test('appstore rejects invalid template ports and volumes', () => {
const appStore = createAppStore();
assertThrows(
() => appStore.validateAppVersionConfig({ ...baseConfig, port: 70000 }),
Error,
'Invalid app config port',
);
assertThrows(
() => appStore.normalizeVolumes([{ mountPath: 'relative/path' }]),
Error,
'mountPath must be an absolute path',
);
assertThrows(
() => appStore.validateAppVersionConfig({
...baseConfig,
publishedPorts: [
{ targetPort: 3000, targetPortEnd: 3002, publishedPort: 3000, publishedPortEnd: 3001, protocol: 'tcp' },
],
}),
Error,
'ranges must have the same size',
);
});
Deno.test('docker service spec validation rejects unsafe volume and port declarations', () => {
const dockerManager = new OneboxDockerManager();
dockerManager.validateServiceSpec({
...baseService,
volumes: [{ mountPath: '/data/app' }],
publishedPorts: [{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' }],
});
assertThrows(
() => dockerManager.validateServiceSpec({
...baseService,
volumes: [{ mountPath: 'relative/path' }],
}),
Error,
'must be an absolute path',
);
assertThrows(
() => dockerManager.validateServiceSpec({
...baseService,
publishedPorts: [
{ targetPort: 3001, publishedPort: 3000, hostIp: '127.0.0.1', protocol: 'tcp' },
{ targetPort: 3000, publishedPort: 3000, protocol: 'tcp' },
],
}),
Error,
'Duplicate published port',
);
});
+81
View File
@@ -7,6 +7,7 @@ class FakeDatabase {
public settings = new Map<string, string>();
public secretSettings = new Map<string, string>();
public domains: IDomain[] = [];
public services: IService[] = [];
public certificates = new Map<string, ISslCertificate>();
private nextDomainId = 1;
@@ -42,6 +43,10 @@ class FakeDatabase {
return this.domains.filter((entry) => entry.dnsProvider === provider);
}
getAllServices(): IService[] {
return this.services;
}
getSSLCertificate(domain: string): ISslCertificate | null {
return this.certificates.get(domain) ?? null;
}
@@ -241,6 +246,82 @@ Deno.test('ExternalGatewayManager deletes service routes through dcrouter gatewa
assertEquals((capturedDeleteRequest.ownership as any).hostname, 'hello.example.com');
});
Deno.test('ExternalGatewayManager removes stale gateway routes during reconciliation', async () => {
const oneboxRef = makeOneboxRef();
oneboxRef.database.settings.set('serverIP', '203.0.113.10');
oneboxRef.database.services.push({
id: 1,
name: 'active',
image: 'nginx:latest',
envVars: {},
port: 3000,
domain: 'active.example.com',
status: 'running',
createdAt: 1,
updatedAt: 1,
});
const deletes: Record<string, unknown>[] = [];
const manager = new ExternalGatewayManager(oneboxRef as any);
(manager as any).fireDcRouterRequest = async (method: string, requestData: Record<string, unknown>) => {
if (method === 'getGatewayClientContext') {
return { context: { role: 'gatewayClient', gatewayClient: { type: 'onebox', id: 'onebox-token' } } };
}
if (method === 'syncGatewayClientRoute') {
if (requestData.delete) {
deletes.push(requestData);
return { success: true, action: 'deleted' };
}
return { success: true, action: 'updated', routeId: 'active-route' };
}
if (method === 'exportCertificate') {
return { success: false };
}
if (method === 'getGatewayClientDnsRecords') {
return {
records: [
{
id: 'active-record',
domainId: 'domain-1',
name: 'active',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'active',
hostname: 'active.example.com',
routeId: 'active-route',
},
{
id: 'stale-record',
domainId: 'domain-1',
name: 'stale',
type: 'A',
value: '203.0.113.10',
ttl: 300,
source: 'route',
status: 'active',
gatewayClientType: 'onebox',
gatewayClientId: 'onebox-token',
appId: 'stale',
hostname: 'stale.example.com',
routeId: 'stale-route',
},
],
};
}
throw new Error(`Unexpected method: ${method}`);
};
await manager.syncServiceRoutes();
assertEquals(deletes.length, 1);
assertEquals((deletes[0].ownership as any).hostname, 'stale.example.com');
});
Deno.test('ExternalGatewayManager imports exported dcrouter certificates into Onebox', async () => {
const oneboxRef = makeOneboxRef();
const manager = new ExternalGatewayManager(oneboxRef as any);