feat(appstore): add service volumes and published ports
This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user