feat: mount corestore volumes
This commit is contained in:
@@ -134,11 +134,29 @@ After connection, Coreflow authenticates with `JUMPCODE` and requests a stateful
|
|||||||
Coreflow depends on these Cloudly-side resources being present and valid:
|
Coreflow depends on these Cloudly-side resources being present and valid:
|
||||||
|
|
||||||
- Cluster configuration for the authenticated identity.
|
- Cluster configuration for the authenticated identity.
|
||||||
- Service records with image, resource, domain, port, and secret bundle references.
|
- Service records with image, volume, resource, domain, port, and secret bundle references.
|
||||||
- Image records pointing either to internal Cloudly image storage or an external registry.
|
- Image records pointing either to internal Cloudly image storage or an external registry.
|
||||||
- Secret bundles that can be flattened into environment key/value data.
|
- Secret bundles that can be flattened into environment key/value data.
|
||||||
- SSL certificates for all routed domains.
|
- SSL certificates for all routed domains.
|
||||||
|
|
||||||
|
## Corestore Volumes
|
||||||
|
|
||||||
|
Coreflow deploys `corestore` as a global base service and bind mounts `/run/docker/plugins` so Docker can discover the `corestore` VolumeDriver socket on each node.
|
||||||
|
|
||||||
|
Workload services can declare first-class volumes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
volumes: [
|
||||||
|
{
|
||||||
|
mountPath: '/data',
|
||||||
|
driver: 'corestore',
|
||||||
|
backup: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
If `name` is omitted, Coreflow derives a stable Docker volume name from the service id and mount path. During service creation it sends a Docker volume mount with `DriverConfig.Name = 'corestore'`, plus service metadata as driver options and volume labels.
|
||||||
|
|
||||||
## Coretraffic Integration
|
## Coretraffic Integration
|
||||||
|
|
||||||
Coreflow starts an internal SmartServe/TypedSocket server on port `3000`. Coretraffic is expected to connect to that server and tag its connection as `coretraffic`.
|
Coreflow starts an internal SmartServe/TypedSocket server on port `3000`. Coretraffic is expected to connect to that server and tag its connection as `coretraffic`.
|
||||||
@@ -190,7 +208,7 @@ Project layout:
|
|||||||
- Reconciliation removes and recreates services when the Docker service reports that it needs an update.
|
- Reconciliation removes and recreates services when the Docker service reports that it needs an update.
|
||||||
- Workload services must be attached to `sznwebgateway` for routing to be generated.
|
- Workload services must be attached to `sznwebgateway` for routing to be generated.
|
||||||
- The current routing logic uses the first available container IP for a service.
|
- The current routing logic uses the first available container IP for a service.
|
||||||
- `PlatformManager` currently provides lifecycle hooks but does not reconcile platform services yet.
|
- `PlatformManager` provisions `database` and `objectstorage` bindings through corestore.
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,18 @@ import { Coreflow } from './coreflow.classes.coreflow.js';
|
|||||||
import type { IExternalGatewayConfig } from './coreflow.connector.externalgateway.js';
|
import type { IExternalGatewayConfig } from './coreflow.connector.externalgateway.js';
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
|
type TServiceVolumeConfig = {
|
||||||
|
name?: string;
|
||||||
|
source?: string;
|
||||||
|
mountPath?: string;
|
||||||
|
target?: string;
|
||||||
|
containerFsPath?: string;
|
||||||
|
driver?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
backup?: boolean;
|
||||||
|
options?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
export class ClusterManager {
|
export class ClusterManager {
|
||||||
public coreflowRef: Coreflow;
|
public coreflowRef: Coreflow;
|
||||||
public configSubscription?: plugins.smartrx.rxjs.Subscription;
|
public configSubscription?: plugins.smartrx.rxjs.Subscription;
|
||||||
@@ -51,6 +63,7 @@ export class ClusterManager {
|
|||||||
serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService,
|
serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService,
|
||||||
containerImageFromCloudly: plugins.servezoneInterfaces.data.IImage,
|
containerImageFromCloudly: plugins.servezoneInterfaces.data.IImage,
|
||||||
secretHashArg = '',
|
secretHashArg = '',
|
||||||
|
volumeHashArg = '',
|
||||||
) {
|
) {
|
||||||
const desiredImageVersion =
|
const desiredImageVersion =
|
||||||
serviceArgFromCloudly.data.imageVersion ||
|
serviceArgFromCloudly.data.imageVersion ||
|
||||||
@@ -72,6 +85,7 @@ export class ClusterManager {
|
|||||||
'serve.zone.registryImageUrl': serviceArgFromCloudly.data.registryTarget?.imageUrl || '',
|
'serve.zone.registryImageUrl': serviceArgFromCloudly.data.registryTarget?.imageUrl || '',
|
||||||
'serve.zone.registryDigest': desiredRegistryDigest || '',
|
'serve.zone.registryDigest': desiredRegistryDigest || '',
|
||||||
'serve.zone.secretHash': secretHashArg,
|
'serve.zone.secretHash': secretHashArg,
|
||||||
|
...(volumeHashArg ? { 'serve.zone.volumeHash': volumeHashArg } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +106,10 @@ export class ClusterManager {
|
|||||||
return crypto.createHash('sha256').update(this.stableStringify(secretObjectArg)).digest('hex');
|
return crypto.createHash('sha256').update(this.stableStringify(secretObjectArg)).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hashStableValue(valueArg: unknown) {
|
||||||
|
return crypto.createHash('sha256').update(this.stableStringify(valueArg)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
private async pullRegistryTargetImage(
|
private async pullRegistryTargetImage(
|
||||||
registryTargetArg: plugins.servezoneInterfaces.data.IRegistryTarget,
|
registryTargetArg: plugins.servezoneInterfaces.data.IRegistryTarget,
|
||||||
): Promise<plugins.docker.DockerImage> {
|
): Promise<plugins.docker.DockerImage> {
|
||||||
@@ -123,6 +141,182 @@ export class ClusterManager {
|
|||||||
return localDockerImage;
|
return localDockerImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getServiceVolumeConfigs(serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService) {
|
||||||
|
const serviceData = serviceArgFromCloudly.data as plugins.servezoneInterfaces.data.IService['data'] & {
|
||||||
|
volumes?: TServiceVolumeConfig[];
|
||||||
|
};
|
||||||
|
return (serviceData.volumes || []).filter((volumeArg) => {
|
||||||
|
return Boolean(volumeArg.mountPath || volumeArg.target || volumeArg.containerFsPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCoreStoreVolumeName(
|
||||||
|
serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService,
|
||||||
|
volumeArg: TServiceVolumeConfig,
|
||||||
|
) {
|
||||||
|
const requestedName = volumeArg.source || volumeArg.name;
|
||||||
|
if (requestedName) {
|
||||||
|
return this.getDockerSafeName(requestedName, 120);
|
||||||
|
}
|
||||||
|
const mountPath = volumeArg.mountPath || volumeArg.target || volumeArg.containerFsPath || 'data';
|
||||||
|
const serviceName = this.getDockerSafeName(serviceArgFromCloudly.data.name, 36);
|
||||||
|
const mountName = this.getDockerSafeName(mountPath.replace(/^\/+/, '').replace(/\/+$/g, ''), 28);
|
||||||
|
const hash = crypto.createHash('sha1').update(`${serviceArgFromCloudly.id}:${mountPath}`).digest('hex').slice(0, 12);
|
||||||
|
return this.getDockerSafeName(`sz-${serviceName}-${mountName}-${hash}`, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServiceDockerMounts(serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService) {
|
||||||
|
const mounts: Array<Record<string, unknown>> = [];
|
||||||
|
const resources = serviceArgFromCloudly.data.resources as (plugins.servezoneInterfaces.data.IService['data']['resources'] & {
|
||||||
|
volumeMounts?: Array<{ hostFsPath: string; containerFsPath: string }>;
|
||||||
|
}) | undefined;
|
||||||
|
|
||||||
|
for (const volumeMount of resources?.volumeMounts || []) {
|
||||||
|
mounts.push({
|
||||||
|
Target: volumeMount.containerFsPath,
|
||||||
|
Source: volumeMount.hostFsPath,
|
||||||
|
Consistency: 'default',
|
||||||
|
ReadOnly: false,
|
||||||
|
Type: 'bind',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const volume of this.getServiceVolumeConfigs(serviceArgFromCloudly)) {
|
||||||
|
const target = volume.mountPath || volume.target || volume.containerFsPath;
|
||||||
|
if (!target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const driver = volume.driver || 'corestore';
|
||||||
|
const source = this.getCoreStoreVolumeName(serviceArgFromCloudly, volume);
|
||||||
|
const backup = volume.backup !== false;
|
||||||
|
const driverOptions: Record<string, string> = {
|
||||||
|
...(volume.options || {}),
|
||||||
|
serviceId: serviceArgFromCloudly.id,
|
||||||
|
serviceName: serviceArgFromCloudly.data.name,
|
||||||
|
mountPath: target,
|
||||||
|
backup: String(backup),
|
||||||
|
};
|
||||||
|
|
||||||
|
mounts.push({
|
||||||
|
Target: target,
|
||||||
|
Source: source,
|
||||||
|
Type: 'volume',
|
||||||
|
ReadOnly: Boolean(volume.readOnly),
|
||||||
|
VolumeOptions: {
|
||||||
|
DriverConfig: {
|
||||||
|
Name: driver,
|
||||||
|
Options: driverOptions,
|
||||||
|
},
|
||||||
|
Labels: {
|
||||||
|
'serve.zone.serviceId': serviceArgFromCloudly.id,
|
||||||
|
'serve.zone.serviceName': serviceArgFromCloudly.data.name,
|
||||||
|
'serve.zone.mountPath': target,
|
||||||
|
'serve.zone.backup': String(backup),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return mounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getServiceVolumeHash(serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService) {
|
||||||
|
const volumeConfigs = this.getServiceVolumeConfigs(serviceArgFromCloudly);
|
||||||
|
if (volumeConfigs.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const volumeSpecs = volumeConfigs.map((volumeArg) => ({
|
||||||
|
...volumeArg,
|
||||||
|
source: this.getCoreStoreVolumeName(serviceArgFromCloudly, volumeArg),
|
||||||
|
driver: volumeArg.driver || 'corestore',
|
||||||
|
backup: volumeArg.backup !== false,
|
||||||
|
}));
|
||||||
|
return this.hashStableValue(volumeSpecs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createWorkloadDockerService(argsArg: {
|
||||||
|
service: plugins.servezoneInterfaces.data.IService;
|
||||||
|
image: plugins.docker.DockerImage;
|
||||||
|
network: plugins.docker.DockerNetwork;
|
||||||
|
secret: plugins.docker.DockerSecret;
|
||||||
|
labels: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
const image = argsArg.image as unknown as { RepoTags?: string[] };
|
||||||
|
const imageRef = image.RepoTags?.[0];
|
||||||
|
if (!imageRef) {
|
||||||
|
throw new Error(`Docker image for ${argsArg.service.data.name} has no tag`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ports: Array<{ Protocol: string; PublishedPort: number; TargetPort: number }> = [];
|
||||||
|
const resources = argsArg.service.data.resources as (plugins.servezoneInterfaces.data.IService['data']['resources'] & {
|
||||||
|
memorySizeMB?: number;
|
||||||
|
}) | undefined;
|
||||||
|
const memoryLimitMB = resources?.memorySizeMB || resources?.memorySizeLimitMB || 1000;
|
||||||
|
const replicas = Math.max(1, Number(argsArg.service.data.scaleFactor || 1));
|
||||||
|
|
||||||
|
const response = await this.coreflowRef.dockerHost.request('POST', '/services/create', {
|
||||||
|
Name: argsArg.service.data.name,
|
||||||
|
Labels: argsArg.labels,
|
||||||
|
TaskTemplate: {
|
||||||
|
ContainerSpec: {
|
||||||
|
Image: imageRef,
|
||||||
|
Labels: argsArg.labels,
|
||||||
|
Secrets: [
|
||||||
|
{
|
||||||
|
File: {
|
||||||
|
Name: 'secret.json',
|
||||||
|
UID: '33',
|
||||||
|
GID: '33',
|
||||||
|
Mode: 384,
|
||||||
|
},
|
||||||
|
SecretID: argsArg.secret.ID,
|
||||||
|
SecretName: argsArg.secret.Spec.Name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Mounts: this.getServiceDockerMounts(argsArg.service),
|
||||||
|
},
|
||||||
|
UpdateConfig: {
|
||||||
|
Parallelism: 0,
|
||||||
|
Delay: 0,
|
||||||
|
FailureAction: 'pause',
|
||||||
|
Monitor: 15000000000,
|
||||||
|
MaxFailureRatio: 0.15,
|
||||||
|
},
|
||||||
|
ForceUpdate: 1,
|
||||||
|
Resources: {
|
||||||
|
Limits: {
|
||||||
|
MemoryBytes: memoryLimitMB * 1000000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Networks: [
|
||||||
|
{
|
||||||
|
Target: argsArg.network.Name,
|
||||||
|
Aliases: [argsArg.service.data.name],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
LogDriver: {
|
||||||
|
Name: 'json-file',
|
||||||
|
Options: {
|
||||||
|
'max-file': '3',
|
||||||
|
'max-size': '10M',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Mode: {
|
||||||
|
Replicated: {
|
||||||
|
Replicas: replicas,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
EndpointSpec: {
|
||||||
|
Ports: ports,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (response.statusCode >= 300) {
|
||||||
|
throw new Error(`Failed to create workload service ${argsArg.service.data.name}: ${JSON.stringify(response.body)}`);
|
||||||
|
}
|
||||||
|
return this.getDockerServiceByName(argsArg.service.data.name);
|
||||||
|
}
|
||||||
|
|
||||||
private async createCorestoreGlobalService(
|
private async createCorestoreGlobalService(
|
||||||
corestoreImageArg: plugins.docker.DockerImage,
|
corestoreImageArg: plugins.docker.DockerImage,
|
||||||
networksArg: Array<plugins.docker.DockerNetwork>,
|
networksArg: Array<plugins.docker.DockerNetwork>,
|
||||||
@@ -135,6 +329,7 @@ export class ClusterManager {
|
|||||||
const corestoreEnv = [
|
const corestoreEnv = [
|
||||||
'CORESTORE_DATA_DIR=/data/corestore',
|
'CORESTORE_DATA_DIR=/data/corestore',
|
||||||
'CORESTORE_PUBLIC_HOST=corestore',
|
'CORESTORE_PUBLIC_HOST=corestore',
|
||||||
|
'CORESTORE_VOLUME_PLUGIN_SOCKET=/run/docker/plugins/corestore.sock',
|
||||||
...(process.env.CORESTORE_API_TOKEN
|
...(process.env.CORESTORE_API_TOKEN
|
||||||
? [`CORESTORE_API_TOKEN=${process.env.CORESTORE_API_TOKEN}`]
|
? [`CORESTORE_API_TOKEN=${process.env.CORESTORE_API_TOKEN}`]
|
||||||
: []),
|
: []),
|
||||||
@@ -144,14 +339,14 @@ export class ClusterManager {
|
|||||||
Labels: {
|
Labels: {
|
||||||
version: corestoreImage.Labels?.version || '',
|
version: corestoreImage.Labels?.version || '',
|
||||||
'serve.zone.serviceCategory': 'base',
|
'serve.zone.serviceCategory': 'base',
|
||||||
'serve.zone.provides': 'database,objectstorage',
|
'serve.zone.provides': 'database,objectstorage,volume',
|
||||||
},
|
},
|
||||||
TaskTemplate: {
|
TaskTemplate: {
|
||||||
ContainerSpec: {
|
ContainerSpec: {
|
||||||
Image: imageRef,
|
Image: imageRef,
|
||||||
Labels: {
|
Labels: {
|
||||||
'serve.zone.serviceCategory': 'base',
|
'serve.zone.serviceCategory': 'base',
|
||||||
'serve.zone.provides': 'database,objectstorage',
|
'serve.zone.provides': 'database,objectstorage,volume',
|
||||||
},
|
},
|
||||||
Env: corestoreEnv,
|
Env: corestoreEnv,
|
||||||
Mounts: [
|
Mounts: [
|
||||||
@@ -162,6 +357,13 @@ export class ClusterManager {
|
|||||||
ReadOnly: false,
|
ReadOnly: false,
|
||||||
Consistency: 'default',
|
Consistency: 'default',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Target: '/run/docker/plugins',
|
||||||
|
Source: '/run/docker/plugins',
|
||||||
|
Type: 'bind',
|
||||||
|
ReadOnly: false,
|
||||||
|
Consistency: 'default',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
Networks: networksArg.map((networkArg) => ({
|
Networks: networksArg.map((networkArg) => ({
|
||||||
@@ -452,10 +654,12 @@ export class ClusterManager {
|
|||||||
...(await secretBundle.getFlatKeyValueObjectForEnvironment()),
|
...(await secretBundle.getFlatKeyValueObjectForEnvironment()),
|
||||||
};
|
};
|
||||||
const secretHash = this.hashSecretObject(secretObject);
|
const secretHash = this.hashSecretObject(secretObject);
|
||||||
|
const volumeHash = this.getServiceVolumeHash(serviceArgFromCloudly);
|
||||||
const deploymentLabels = this.getWorkloadServiceDeploymentLabels(
|
const deploymentLabels = this.getWorkloadServiceDeploymentLabels(
|
||||||
serviceArgFromCloudly,
|
serviceArgFromCloudly,
|
||||||
containerImageFromCloudly,
|
containerImageFromCloudly,
|
||||||
secretHash,
|
secretHash,
|
||||||
|
volumeHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
// existing network to connect to
|
// existing network to connect to
|
||||||
@@ -501,16 +705,12 @@ export class ClusterManager {
|
|||||||
labels: {},
|
labels: {},
|
||||||
version: serviceArgFromCloudly.data.imageVersion,
|
version: serviceArgFromCloudly.data.imageVersion,
|
||||||
});
|
});
|
||||||
containerService = await this.coreflowRef.dockerHost.createService({
|
containerService = await this.createWorkloadDockerService({
|
||||||
name: serviceArgFromCloudly.data.name,
|
service: serviceArgFromCloudly,
|
||||||
image: localDockerImage,
|
image: localDockerImage,
|
||||||
networks: [webGatewayNetwork],
|
network: webGatewayNetwork,
|
||||||
secrets: [containerSecret],
|
secret: containerSecret,
|
||||||
ports: [],
|
|
||||||
labels: deploymentLabels,
|
labels: deploymentLabels,
|
||||||
resources: serviceArgFromCloudly.data.resources,
|
|
||||||
// TODO: introduce a clean name here, that is guaranteed to work with APIs.
|
|
||||||
networkAlias: serviceArgFromCloudly.data.name,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user