feat(clustermanager): support managed Docker Swarm published ports

This commit is contained in:
2026-05-26 11:13:37 +00:00
parent 7b03e1cf96
commit 8dc008d23d
6 changed files with 220 additions and 7 deletions
+6
View File
@@ -2,6 +2,12 @@
## Pending
### Features
- support managed Docker Swarm published ports (clustermanager)
- Expand and validate published port ranges before creating workload services
- Add deployment labels for published port configuration hashes and App Store template versions
- Update @serve.zone/interfaces to ^6.0.1
## 2026-05-23 - 1.2.0
+1 -1
View File
@@ -83,7 +83,7 @@
"@push.rocks/smartstring": "^4.1.1",
"@push.rocks/taskbuffer": "^8.0.2",
"@serve.zone/api": "^5.3.4",
"@serve.zone/interfaces": "^5.9.0",
"@serve.zone/interfaces": "^6.0.1",
"@tsclass/tsclass": "^9.5.1",
"@types/node": "25.6.1"
},
+11 -2
View File
@@ -72,8 +72,8 @@ importers:
specifier: ^5.3.4
version: 5.3.4(@push.rocks/smartserve@2.0.4)
'@serve.zone/interfaces':
specifier: ^5.9.0
version: 5.9.0
specifier: ^6.0.1
version: 6.0.1
'@tsclass/tsclass':
specifier: ^9.5.1
version: 9.5.1
@@ -1528,6 +1528,9 @@ packages:
'@serve.zone/interfaces@5.9.0':
resolution: {integrity: sha512-XMXyTXTMcB8AX6zYOMO+Jt5bOv9ujyXj5miE6lrgyT8g+eJ/I6sUFqVNUKJ3LiMk/yFWsPln7HtZeZKDEhaCwQ==}
'@serve.zone/interfaces@6.0.1':
resolution: {integrity: sha512-ZeLi0Bge8qRMoZMN5/xQ/8VRI4ep9ImitpZtNuLmeNHu0pGICcBGQE4g1aMmi+E3JynKOAphH4dnVmRULZV/RA==}
'@smithy/chunked-blob-reader-native@4.2.3':
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
engines: {node: '>=18.0.0'}
@@ -6920,6 +6923,12 @@ snapshots:
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@serve.zone/interfaces@6.0.1':
dependencies:
'@api.global/typedrequest-interfaces': 3.0.19
'@push.rocks/smartlog-interfaces': 3.0.2
'@tsclass/tsclass': 9.5.1
'@smithy/chunked-blob-reader-native@4.2.3':
dependencies:
'@smithy/util-base64': 4.3.2
+3
View File
@@ -1,3 +1,6 @@
minimumReleaseAgeExclude:
- '@serve.zone/interfaces'
allowBuilds:
'@design.estate/dees-catalog': false
esbuild: true
+87
View File
@@ -0,0 +1,87 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ClusterManager } from '../ts/coreflow.classes.clustermanager.js';
const createManager = () => new ClusterManager({} as any) as any;
const createService = (publishedPortsArg?: any[]) => ({
id: 'Service:test',
data: {
name: 'test-service',
imageId: 'Image:test',
imageVersion: 'v1.0.0',
environment: {},
secretBundleId: 'Secret:test',
serviceCategory: 'workload',
deploymentStrategy: 'limited-replicas',
scaleFactor: 1,
balancingStrategy: 'round-robin',
ports: { web: 8080 },
publishedPorts: publishedPortsArg,
domains: [],
deploymentIds: [],
},
});
tap.test('should expand Docker Swarm published port ranges', async () => {
const manager = createManager();
const ports = manager.getServicePublishedPorts(createService([
{
targetPort: 9000,
targetPortEnd: 9002,
publishedPort: 19000,
publishedPortEnd: 19002,
protocol: 'udp',
},
]));
expect(ports).toEqual([
{ Protocol: 'udp', PublishedPort: 19000, TargetPort: 9000, PublishMode: 'ingress' },
{ Protocol: 'udp', PublishedPort: 19001, TargetPort: 9001, PublishMode: 'ingress' },
{ Protocol: 'udp', PublishedPort: 19002, TargetPort: 9002, PublishMode: 'ingress' },
]);
});
tap.test('should reject invalid published port configs before Docker receives them', async () => {
const manager = createManager();
expect(() => manager.getServicePublishedPorts(createService([
{ targetPort: 9002, targetPortEnd: 9000, publishedPort: 19000 },
]))).toThrow();
expect(() => manager.getServicePublishedPorts(createService([
{ targetPort: 9000, targetPortEnd: 9001, publishedPort: 19000, publishedPortEnd: 19002 },
]))).toThrow();
expect(() => manager.getServicePublishedPorts(createService([
{ targetPort: 9000, publishedPort: 19000 },
{ targetPort: 9001, publishedPort: 19000 },
]))).toThrow();
expect(() => manager.getServicePublishedPorts(createService([
{ targetPort: 9000, publishedPort: 19000, hostIp: '127.0.0.1' },
]))).toThrow();
});
tap.test('should label explicitly managed empty published port configs', async () => {
const manager = createManager();
const labels = manager.getWorkloadServiceDeploymentLabels(
createService([]),
{ data: { versions: [] } },
);
expect(labels['serve.zone.publishedPortsHash']).toBeDefined();
expect(labels['serve.zone.appTemplateId']).toBeUndefined();
});
tap.test('should include App Store version labels only for App Store services', async () => {
const manager = createManager();
const service = createService([]) as any;
service.data.appTemplateId = 'gitea';
service.data.appTemplateVersion = '1.2.3';
const labels = manager.getWorkloadServiceDeploymentLabels(
service,
{ data: { versions: [] } },
);
expect(labels['serve.zone.appTemplateId']).toEqual('gitea');
expect(labels['serve.zone.appTemplateVersion']).toEqual('1.2.3');
});
export default tap.start();
+112 -4
View File
@@ -16,6 +16,15 @@ type TServiceVolumeConfig = {
options?: Record<string, string>;
};
type TServicePublishedPortConfig = {
targetPort: number;
targetPortEnd?: number;
publishedPort?: number;
publishedPortEnd?: number;
protocol?: 'tcp' | 'udp';
hostIp?: string;
};
export class ClusterManager {
public coreflowRef: Coreflow;
public configSubscription?: plugins.smartrx.rxjs.Subscription;
@@ -77,15 +86,29 @@ export class ClusterManager {
? containerImageFromCloudly.data.lastPushEvent.digest
: ''
);
const serviceData = serviceArgFromCloudly.data as plugins.servezoneInterfaces.data.IService['data'] & {
appTemplateId?: string;
appTemplateVersion?: string;
publishedPorts?: TServicePublishedPortConfig[];
};
const hasPublishedPortsConfig = Array.isArray(serviceData.publishedPorts);
const publishedPortsHash = hasPublishedPortsConfig
? this.hashStableValue(this.normalizeServicePublishedPorts(serviceData.publishedPorts || [], serviceData.name))
: '';
return {
'serve.zone.serviceId': serviceArgFromCloudly.id,
'serve.zone.imageId': serviceArgFromCloudly.data.imageId || '',
'serve.zone.imageVersion': desiredImageVersion,
...(serviceData.appTemplateId ? {
'serve.zone.appTemplateId': serviceData.appTemplateId,
'serve.zone.appTemplateVersion': serviceData.appTemplateVersion || '',
} : {}),
'serve.zone.registryImageUrl': serviceArgFromCloudly.data.registryTarget?.imageUrl || '',
'serve.zone.registryDigest': desiredRegistryDigest || '',
'serve.zone.secretHash': secretHashArg,
...(volumeHashArg ? { 'serve.zone.volumeHash': volumeHashArg } : {}),
...(hasPublishedPortsConfig ? { 'serve.zone.publishedPortsHash': publishedPortsHash } : {}),
};
}
@@ -295,7 +318,7 @@ export class ClusterManager {
throw new Error(`Docker image for ${argsArg.service.data.name} has no tag`);
}
const ports: Array<{ Protocol: string; PublishedPort: number; TargetPort: number }> = [];
const ports = this.getServicePublishedPorts(argsArg.service);
const resources = argsArg.service.data.resources as (plugins.servezoneInterfaces.data.IService['data']['resources'] & {
memorySizeMB?: number;
}) | undefined;
@@ -366,6 +389,90 @@ export class ClusterManager {
return this.getDockerServiceByName(argsArg.service.data.name);
}
private getServicePublishedPorts(serviceArg: plugins.servezoneInterfaces.data.IService) {
const serviceData = serviceArg.data as plugins.servezoneInterfaces.data.IService['data'] & {
publishedPorts?: TServicePublishedPortConfig[];
};
const ports: Array<{
Protocol: string;
PublishedPort: number;
TargetPort: number;
PublishMode: 'ingress';
}> = [];
for (const portArg of this.normalizeServicePublishedPorts(serviceData.publishedPorts || [], serviceData.name)) {
const targetStart = portArg.targetPort;
const targetEnd = portArg.targetPortEnd || targetStart;
const publishedStart = portArg.publishedPort || targetStart;
const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart));
const protocol = portArg.protocol || 'tcp';
for (let offset = 0; offset <= publishedEnd - publishedStart; offset++) {
ports.push({
Protocol: protocol,
PublishedPort: publishedStart + offset,
TargetPort: targetStart + offset,
PublishMode: 'ingress',
});
}
}
return ports;
}
private normalizeServicePublishedPorts(
publishedPortsArg: TServicePublishedPortConfig[] = [],
serviceNameArg = 'service',
): TServicePublishedPortConfig[] {
const seenPublishedPorts = new Set<string>();
const normalizedPorts = publishedPortsArg.map((portArg) => {
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 description = `${serviceNameArg} ${publishedStart}-${publishedEnd}/${protocol} -> ${targetStart}-${targetEnd}/${protocol}`;
if (portArg.hostIp && portArg.hostIp !== '0.0.0.0') {
throw new Error(`Docker Swarm published ports do not support hostIp for ${description}`);
}
for (const [label, value] of Object.entries({ targetStart, targetEnd, publishedStart, publishedEnd })) {
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new Error(`Invalid ${label} ${value} in published port config for ${description}`);
}
}
if (targetEnd < targetStart) {
throw new Error(`targetPortEnd must be >= targetPort in published port config for ${description}`);
}
if (publishedEnd < publishedStart) {
throw new Error(`publishedPortEnd must be >= publishedPort in published port config for ${description}`);
}
if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) {
throw new Error(`Published and target port ranges must have the same length for ${description}`);
}
for (let offset = 0; offset <= publishedEnd - publishedStart; offset++) {
const publishedKey = `${protocol}:${publishedStart + offset}`;
if (seenPublishedPorts.has(publishedKey)) {
throw new Error(`Duplicate published port ${publishedStart + offset}/${protocol} in ${serviceNameArg}`);
}
seenPublishedPorts.add(publishedKey);
}
return {
...portArg,
targetPort: targetStart,
targetPortEnd: targetEnd === targetStart ? undefined : targetEnd,
publishedPort: publishedStart,
publishedPortEnd: publishedEnd === publishedStart ? undefined : publishedEnd,
protocol,
};
});
return normalizedPorts.sort((a, b) => {
return (a.protocol || 'tcp').localeCompare(b.protocol || 'tcp')
|| a.publishedPort! - b.publishedPort!
|| a.targetPort - b.targetPort;
});
}
private async createCorestoreGlobalService(
corestoreImageArg: plugins.docker.DockerImage,
networksArg: Array<plugins.docker.DockerNetwork>,
@@ -733,9 +840,10 @@ export class ClusterManager {
const cloudlyDeploymentLabelsChanged = Object.entries(deploymentLabels).some(([key, value]) => {
return existingLabels[key] !== value;
});
const dockerImageNeedsUpdate = serviceArgFromCloudly.data.registryTarget
? false
: await containerService.needsUpdate();
let dockerImageNeedsUpdate = false;
if (!cloudlyDeploymentLabelsChanged && !serviceArgFromCloudly.data.registryTarget) {
dockerImageNeedsUpdate = await containerService.needsUpdate();
}
if (cloudlyDeploymentLabelsChanged || dockerImageNeedsUpdate) {
logger.log('info', `service ${serviceArgFromCloudly.data.name} desired state changed, recreating`);