feat(clustermanager): support managed Docker Swarm published ports
This commit is contained in:
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+11
-2
@@ -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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- '@serve.zone/interfaces'
|
||||
|
||||
allowBuilds:
|
||||
'@design.estate/dees-catalog': false
|
||||
esbuild: true
|
||||
|
||||
@@ -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();
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user