feat(coreflow): add deployment runtime operations
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
},
|
||||
"@git.zone/cli": {
|
||||
"schemaVersion": 2,
|
||||
"projectType": "service",
|
||||
"module": {
|
||||
"githost": "code.foss.global",
|
||||
@@ -46,6 +47,19 @@
|
||||
"Observability",
|
||||
"TypeScript"
|
||||
]
|
||||
},
|
||||
"release": {
|
||||
"targets": {
|
||||
"git": {
|
||||
"enabled": true,
|
||||
"remote": "origin"
|
||||
},
|
||||
"docker": {
|
||||
"enabled": true,
|
||||
"engine": "tsdocker",
|
||||
"patterns": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@git.zone/tsdoc": {
|
||||
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
### Features
|
||||
|
||||
- add deployment runtime operations and app catalog workload support
|
||||
- Adds Coreflow handlers for live deployment listing, restart, kill, and workspace execution
|
||||
- Supports external image references, app-provided environment values, and template variable resolution
|
||||
- Updates release configuration for deterministic Docker publishing with pnpm 11 build approvals
|
||||
|
||||
## 2024-12-29 - 1.1.0 - feat(.gitea/workflows)
|
||||
Add GitHub Actions workflows for Docker build and test
|
||||
|
||||
@@ -33,4 +42,4 @@ Update Docker base images to use code.foss.global instead of registry.gitlab.com
|
||||
## 2024-05-09 - 1.0.130 to 1.0.132 - Maintenance Release
|
||||
Regular updates and maintenance tasks.
|
||||
|
||||
- Fixed core functionality issues in versions 1.0.130 and 1.0.131.
|
||||
- Fixed core functionality issues in versions 1.0.130 and 1.0.131.
|
||||
|
||||
+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.5.0",
|
||||
"@serve.zone/interfaces": "^5.9.0",
|
||||
"@tsclass/tsclass": "^9.5.1",
|
||||
"@types/node": "25.6.1"
|
||||
},
|
||||
|
||||
Generated
+6
-6
@@ -72,8 +72,8 @@ importers:
|
||||
specifier: ^5.3.4
|
||||
version: 5.3.4(@push.rocks/smartserve@2.0.4)
|
||||
'@serve.zone/interfaces':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
specifier: ^5.9.0
|
||||
version: 5.9.0
|
||||
'@tsclass/tsclass':
|
||||
specifier: ^9.5.1
|
||||
version: 9.5.1
|
||||
@@ -1525,8 +1525,8 @@ packages:
|
||||
'@serve.zone/api@5.3.4':
|
||||
resolution: {integrity: sha512-3CqyeZkZPCJ4775UoNPKfknhTlAk6zmU/MVVSu6DoIAWgUaOuAlLUHlV45xIGtHmKAppsiYUoyoEhBLTZf9iMw==}
|
||||
|
||||
'@serve.zone/interfaces@5.5.0':
|
||||
resolution: {integrity: sha512-SZH4sKxBhfX+xF7zPFcHtyWdXMz7XINP5X9tqtLKPa3rJd5XkoeOFsbgDxWfeuBkCGJglvY2FI24oCPexy5acg==}
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
resolution: {integrity: sha512-XMXyTXTMcB8AX6zYOMO+Jt5bOv9ujyXj5miE6lrgyT8g+eJ/I6sUFqVNUKJ3LiMk/yFWsPln7HtZeZKDEhaCwQ==}
|
||||
|
||||
'@smithy/chunked-blob-reader-native@4.2.3':
|
||||
resolution: {integrity: sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==}
|
||||
@@ -6903,7 +6903,7 @@ snapshots:
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
'@push.rocks/smartrx': 3.0.10
|
||||
'@push.rocks/smartstream': 3.4.2
|
||||
'@serve.zone/interfaces': 5.5.0
|
||||
'@serve.zone/interfaces': 5.9.0
|
||||
'@tsclass/tsclass': 9.5.1
|
||||
transitivePeerDependencies:
|
||||
- '@nuxt/kit'
|
||||
@@ -6914,7 +6914,7 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vue
|
||||
|
||||
'@serve.zone/interfaces@5.5.0':
|
||||
'@serve.zone/interfaces@5.9.0':
|
||||
dependencies:
|
||||
'@api.global/typedrequest-interfaces': 3.0.19
|
||||
'@push.rocks/smartlog-interfaces': 3.0.2
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
allowBuilds:
|
||||
'@design.estate/dees-catalog': false
|
||||
esbuild: true
|
||||
mongodb-memory-server: false
|
||||
puppeteer: false
|
||||
sharp: false
|
||||
@@ -141,6 +141,53 @@ export class ClusterManager {
|
||||
return localDockerImage;
|
||||
}
|
||||
|
||||
private parseImageRef(imageRefArg: string) {
|
||||
const lastSlashIndex = imageRefArg.lastIndexOf('/');
|
||||
const lastColonIndex = imageRefArg.lastIndexOf(':');
|
||||
if (lastColonIndex > lastSlashIndex) {
|
||||
return {
|
||||
imageName: imageRefArg.slice(0, lastColonIndex),
|
||||
imageTag: imageRefArg.slice(lastColonIndex + 1) || 'latest',
|
||||
};
|
||||
}
|
||||
return {
|
||||
imageName: imageRefArg,
|
||||
imageTag: 'latest',
|
||||
};
|
||||
}
|
||||
|
||||
private async pullExternalImageRef(imageRefArg: string): Promise<plugins.docker.DockerImage> {
|
||||
const { imageName, imageTag } = this.parseImageRef(imageRefArg);
|
||||
const imageRef = `${imageName}:${imageTag}`;
|
||||
const response = await this.coreflowRef.dockerHost.request(
|
||||
'POST',
|
||||
`/images/create?fromImage=${encodeURIComponent(imageName)}&tag=${encodeURIComponent(imageTag)}`,
|
||||
);
|
||||
if (response.statusCode >= 300) {
|
||||
const existingImage = await this.coreflowRef.dockerHost.getImageByName(imageRef);
|
||||
if (existingImage) {
|
||||
logger.log('warn', `external image pull failed for ${imageRef}, using locally cached image`);
|
||||
return existingImage;
|
||||
}
|
||||
throw new Error(`Failed to pull external image ${imageRef}`);
|
||||
}
|
||||
const localDockerImage = await this.coreflowRef.dockerHost.getImageByName(imageRef);
|
||||
if (!localDockerImage) {
|
||||
throw new Error(`External image ${imageRef} not found after pull`);
|
||||
}
|
||||
return localDockerImage;
|
||||
}
|
||||
|
||||
private resolveEnvTemplates(envArg: Record<string, string>) {
|
||||
const resolved = { ...envArg };
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
resolved[key] = value.replace(/\$\{([A-Z0-9_]+)\}/g, (_matchArg, nameArg) => {
|
||||
return resolved[nameArg] ?? '';
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private getServiceVolumeConfigs(serviceArgFromCloudly: plugins.servezoneInterfaces.data.IService) {
|
||||
const serviceData = serviceArgFromCloudly.data as Omit<plugins.servezoneInterfaces.data.IService['data'], 'volumes'> & {
|
||||
volumes?: TServiceVolumeConfig[];
|
||||
@@ -239,6 +286,7 @@ export class ClusterManager {
|
||||
image: plugins.docker.DockerImage;
|
||||
network: plugins.docker.DockerNetwork;
|
||||
secret: plugins.docker.DockerSecret;
|
||||
env: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const image = argsArg.image as unknown as { RepoTags?: string[] };
|
||||
@@ -260,6 +308,7 @@ export class ClusterManager {
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: imageRef,
|
||||
Env: Object.entries(argsArg.env).map(([key, value]) => `${key}=${value}`),
|
||||
Labels: argsArg.labels,
|
||||
Secrets: [
|
||||
{
|
||||
@@ -628,6 +677,14 @@ export class ClusterManager {
|
||||
imageTag: serviceArgFromCloudly.data.imageVersion,
|
||||
});
|
||||
await localDockerImage.pullLatestImageFromRegistry();
|
||||
} else if (
|
||||
containerImageFromCloudly.data.location?.externalImageRef ||
|
||||
containerImageFromCloudly.data.location?.externalImageTag
|
||||
) {
|
||||
localDockerImage = await this.pullExternalImageRef(
|
||||
containerImageFromCloudly.data.location.externalImageRef ||
|
||||
containerImageFromCloudly.data.location.externalImageTag,
|
||||
);
|
||||
} else {
|
||||
throw new Error('Invalid image location');
|
||||
}
|
||||
@@ -649,10 +706,11 @@ export class ClusterManager {
|
||||
const platformEnvObject = await this.coreflowRef.platformManager.provisionBindingsForService(
|
||||
serviceArgFromCloudly,
|
||||
);
|
||||
const secretObject = {
|
||||
const secretObject = this.resolveEnvTemplates({
|
||||
...platformEnvObject,
|
||||
...(serviceArgFromCloudly.data.environment || {}),
|
||||
...(await secretBundle.getFlatKeyValueObjectForEnvironment()),
|
||||
};
|
||||
});
|
||||
const secretHash = this.hashSecretObject(secretObject);
|
||||
const volumeHash = this.getServiceVolumeHash(serviceArgFromCloudly);
|
||||
const deploymentLabels = this.getWorkloadServiceDeploymentLabels(
|
||||
@@ -710,6 +768,7 @@ export class ClusterManager {
|
||||
image: localDockerImage,
|
||||
network: webGatewayNetwork,
|
||||
secret: containerSecret,
|
||||
env: secretObject,
|
||||
labels: deploymentLabels,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ExternalGatewayConnector } from './coreflow.connector.externalgateway.j
|
||||
import { InternalServer } from './coreflow.classes.internalserver.js';
|
||||
import { PlatformManager } from './coreflow.classes.platformmanager.js';
|
||||
import { CoreflowBackupManager } from './coreflow.classes.backupmanager.js';
|
||||
import { CoreflowDeploymentRuntimeManager } from './coreflow.classes.deploymentruntime.js';
|
||||
|
||||
/**
|
||||
* the main Coreflow class
|
||||
@@ -24,6 +25,7 @@ export class Coreflow {
|
||||
public clusterManager: ClusterManager;
|
||||
public platformManager: PlatformManager;
|
||||
public backupManager: CoreflowBackupManager;
|
||||
public deploymentRuntimeManager: CoreflowDeploymentRuntimeManager;
|
||||
public taskManager: CoreflowTaskmanager;
|
||||
|
||||
constructor() {
|
||||
@@ -36,6 +38,7 @@ export class Coreflow {
|
||||
this.clusterManager = new ClusterManager(this);
|
||||
this.platformManager = new PlatformManager(this);
|
||||
this.backupManager = new CoreflowBackupManager(this);
|
||||
this.deploymentRuntimeManager = new CoreflowDeploymentRuntimeManager(this);
|
||||
this.taskManager = new CoreflowTaskmanager(this);
|
||||
}
|
||||
|
||||
@@ -72,6 +75,8 @@ export class Coreflow {
|
||||
console.log('platform manager started!');
|
||||
await this.backupManager.start();
|
||||
console.log('backup manager started!');
|
||||
await this.deploymentRuntimeManager.start();
|
||||
console.log('deployment runtime manager started!');
|
||||
await this.taskManager.start();
|
||||
console.log('task manager started!');
|
||||
}
|
||||
@@ -84,6 +89,7 @@ export class Coreflow {
|
||||
await this.clusterManager.stop();
|
||||
await this.platformManager.stop();
|
||||
await this.backupManager.stop();
|
||||
await this.deploymentRuntimeManager.stop();
|
||||
await this.taskManager.stop();
|
||||
await this.internalServer.stop();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import * as plugins from './coreflow.plugins.js';
|
||||
import type { Coreflow } from './coreflow.classes.coreflow.js';
|
||||
import type * as servezoneInterfaces from '@serve.zone/interfaces';
|
||||
|
||||
type TCoreflowDeploymentRequest =
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_GetServiceDeployments
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_RestartDeployment
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_KillDeployment
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadFile
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceWriteFile
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadDir
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceMkdir
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceRm
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExists
|
||||
| servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExec;
|
||||
|
||||
export class CoreflowDeploymentRuntimeManager {
|
||||
constructor(private coreflowRef: Coreflow) {}
|
||||
|
||||
public async start() {
|
||||
const router = this.coreflowRef.cloudlyConnector.cloudlyApiClient.typedrouter as any;
|
||||
const addHandler = <TRequest extends TCoreflowDeploymentRequest>(
|
||||
methodArg: TRequest['method'],
|
||||
handlerArg: (dataArg: TRequest['request']) => Promise<TRequest['response']>,
|
||||
) => {
|
||||
router.addTypedHandler(new plugins.typedrequest.TypedHandler<any>(methodArg, handlerArg as any) as any);
|
||||
};
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_GetServiceDeployments>('coreflowGetServiceDeployments', async (dataArg) => {
|
||||
return {
|
||||
deployments: await this.getServiceDeployments(dataArg.service),
|
||||
};
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_RestartDeployment>('coreflowRestartDeployment', async (dataArg) => {
|
||||
return await this.restartDeployment(dataArg.deploymentId);
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_KillDeployment>('coreflowKillDeployment', async (dataArg) => {
|
||||
return await this.killDeployment(dataArg.deploymentId);
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadFile>('coreflowDeploymentWorkspaceReadFile', async (dataArg) => {
|
||||
const result = await this.execInDeployment(dataArg.deploymentId, ['cat', dataArg.path]);
|
||||
if (!result.found) return { found: false };
|
||||
if (result.exitCode !== 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError(result.stderr || 'Failed to read file');
|
||||
}
|
||||
return { found: true, content: result.stdout };
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceWriteFile>('coreflowDeploymentWorkspaceWriteFile', async (dataArg) => {
|
||||
const contentArg = this.shellQuote(String(dataArg.content || ''));
|
||||
const pathArg = this.shellQuote(dataArg.path);
|
||||
const result = await this.execInDeployment(dataArg.deploymentId, [
|
||||
'sh',
|
||||
'-c',
|
||||
`printf '%s' ${contentArg} > ${pathArg}`,
|
||||
]);
|
||||
if (!result.found) return { found: false };
|
||||
if (result.exitCode !== 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError(result.stderr || 'Failed to write file');
|
||||
}
|
||||
return { found: true };
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadDir>('coreflowDeploymentWorkspaceReadDir', async (dataArg) => {
|
||||
const result = await this.execInDeployment(dataArg.deploymentId, ['ls', '-1', '-F', dataArg.path]);
|
||||
if (!result.found) return { found: false };
|
||||
if (result.exitCode !== 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError(result.stderr || 'Failed to read directory');
|
||||
}
|
||||
const basePath = String(dataArg.path || '/').endsWith('/') ? dataArg.path : `${dataArg.path}/`;
|
||||
const entries: servezoneInterfaces.requests.deployment.IDeploymentWorkspaceFileEntry[] = (result.stdout || '')
|
||||
.split('\n')
|
||||
.filter((lineArg) => lineArg.trim())
|
||||
.map((lineArg) => {
|
||||
const isDirectory = lineArg.endsWith('/');
|
||||
const name = isDirectory ? lineArg.slice(0, -1) : lineArg.replace(/[*@=|]$/, '');
|
||||
return {
|
||||
type: isDirectory ? 'directory' as const : 'file' as const,
|
||||
name,
|
||||
path: `${basePath}${name}`,
|
||||
};
|
||||
});
|
||||
return { found: true, entries };
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceMkdir>('coreflowDeploymentWorkspaceMkdir', async (dataArg) => {
|
||||
const result = await this.execInDeployment(dataArg.deploymentId, ['mkdir', '-p', dataArg.path]);
|
||||
if (!result.found) return { found: false };
|
||||
if (result.exitCode !== 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError(result.stderr || 'Failed to create directory');
|
||||
}
|
||||
return { found: true };
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceRm>('coreflowDeploymentWorkspaceRm', async (dataArg) => {
|
||||
const result = await this.execInDeployment(dataArg.deploymentId, [
|
||||
'rm',
|
||||
dataArg.recursive ? '-rf' : '-f',
|
||||
dataArg.path,
|
||||
]);
|
||||
if (!result.found) return { found: false };
|
||||
if (result.exitCode !== 0) {
|
||||
throw new plugins.typedrequest.TypedResponseError(result.stderr || 'Failed to remove path');
|
||||
}
|
||||
return { found: true };
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExists>('coreflowDeploymentWorkspaceExists', async (dataArg) => {
|
||||
const result = await this.execInDeployment(dataArg.deploymentId, ['test', '-e', dataArg.path]);
|
||||
if (!result.found) return { found: false };
|
||||
return { found: true, exists: result.exitCode === 0 };
|
||||
});
|
||||
|
||||
addHandler<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExec>('coreflowDeploymentWorkspaceExec', async (dataArg) => {
|
||||
const command = [dataArg.command, ...(dataArg.args || [])];
|
||||
return await this.execInDeployment(dataArg.deploymentId, command);
|
||||
});
|
||||
}
|
||||
|
||||
public async stop() {}
|
||||
|
||||
private async getServiceDeployments(
|
||||
serviceArg: plugins.servezoneInterfaces.data.IService,
|
||||
): Promise<plugins.servezoneInterfaces.data.IDeployment[]> {
|
||||
const dockerService = await this.getDockerServiceByNameOrNull(serviceArg.data.name);
|
||||
if (!dockerService) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const [tasks, nodeNames] = await Promise.all([
|
||||
this.listTasksForService(dockerService.ID),
|
||||
this.getNodeNameMap(),
|
||||
]);
|
||||
|
||||
const deployments: plugins.servezoneInterfaces.data.IDeployment[] = [];
|
||||
for (const task of tasks) {
|
||||
deployments.push(await this.taskToDeployment(task, serviceArg, dockerService.ID, nodeNames));
|
||||
}
|
||||
return deployments;
|
||||
}
|
||||
|
||||
private async restartDeployment(
|
||||
deploymentIdArg: string,
|
||||
): Promise<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_RestartDeployment['response']> {
|
||||
const resolved = await this.resolveContainerForDeployment(deploymentIdArg);
|
||||
if (!resolved) return { found: false };
|
||||
const response = await this.coreflowRef.dockerHost.request(
|
||||
'POST',
|
||||
`/containers/${resolved.container.Id}/restart`,
|
||||
);
|
||||
if (response.statusCode >= 300) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Failed to restart container: ${response.statusCode}`);
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
deployment: await this.taskToDeployment(resolved.task, resolved.service, resolved.dockerServiceId, await this.getNodeNameMap()),
|
||||
};
|
||||
}
|
||||
|
||||
private async killDeployment(
|
||||
deploymentIdArg: string,
|
||||
): Promise<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_KillDeployment['response']> {
|
||||
const resolved = await this.resolveContainerForDeployment(deploymentIdArg);
|
||||
if (!resolved) return { found: false };
|
||||
const response = await this.coreflowRef.dockerHost.request(
|
||||
'POST',
|
||||
`/containers/${resolved.container.Id}/kill`,
|
||||
);
|
||||
if (response.statusCode >= 300) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Failed to kill container: ${response.statusCode}`);
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
deployment: await this.taskToDeployment(resolved.task, resolved.service, resolved.dockerServiceId, await this.getNodeNameMap()),
|
||||
};
|
||||
}
|
||||
|
||||
private async execInDeployment(
|
||||
deploymentIdArg: string,
|
||||
commandArg: string[],
|
||||
): Promise<servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExec['response']> {
|
||||
const resolved = await this.resolveContainerForDeployment(deploymentIdArg);
|
||||
if (!resolved) return { found: false };
|
||||
const exec = await resolved.container.exec(commandArg, {
|
||||
tty: true,
|
||||
attachStdin: false,
|
||||
attachStdout: true,
|
||||
attachStderr: true,
|
||||
});
|
||||
const stdout = await this.streamToString(exec.stream);
|
||||
const inspect = await exec.inspect();
|
||||
await exec.close();
|
||||
return {
|
||||
found: true,
|
||||
stdout,
|
||||
stderr: '',
|
||||
exitCode: inspect.ExitCode ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private shellQuote(valueArg: string) {
|
||||
return `'${valueArg.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
private async resolveContainerForDeployment(deploymentIdArg: string) {
|
||||
const containers = await this.coreflowRef.dockerHost.listContainers();
|
||||
const directContainer = containers.find((containerArg) => {
|
||||
return containerArg.Id === deploymentIdArg || containerArg.Id.startsWith(deploymentIdArg);
|
||||
});
|
||||
|
||||
const services = await this.coreflowRef.cloudlyConnector.cloudlyApiClient.services.getServices() as unknown as plugins.servezoneInterfaces.data.IService[];
|
||||
const tasks = await this.listTasks();
|
||||
|
||||
for (const service of services) {
|
||||
const dockerService = await this.getDockerServiceByNameOrNull(service.data.name);
|
||||
if (!dockerService) continue;
|
||||
const task = tasks.find((taskArg) => {
|
||||
return taskArg.ServiceID === dockerService.ID && (
|
||||
taskArg.ID === deploymentIdArg ||
|
||||
taskArg.ID?.startsWith(deploymentIdArg) ||
|
||||
taskArg.Status?.ContainerStatus?.ContainerID === deploymentIdArg ||
|
||||
taskArg.Status?.ContainerStatus?.ContainerID?.startsWith(deploymentIdArg)
|
||||
);
|
||||
});
|
||||
const containerId = task?.Status?.ContainerStatus?.ContainerID;
|
||||
const container = containerId
|
||||
? containers.find((containerArg) => containerArg.Id === containerId || containerArg.Id.startsWith(containerId))
|
||||
: directContainer;
|
||||
if (task && container) {
|
||||
return { service, task, dockerServiceId: dockerService.ID, container };
|
||||
}
|
||||
}
|
||||
|
||||
if (directContainer) {
|
||||
const service = services.find((serviceArg) => {
|
||||
return directContainer.Labels?.['com.docker.swarm.service.name'] === serviceArg.data.name;
|
||||
});
|
||||
if (service) {
|
||||
const dockerService = await this.getDockerServiceByNameOrNull(service.data.name);
|
||||
if (dockerService) {
|
||||
const task = tasks.find((taskArg) => taskArg.Status?.ContainerStatus?.ContainerID === directContainer.Id) || {
|
||||
ID: directContainer.Id,
|
||||
ServiceID: dockerService.ID,
|
||||
NodeID: '',
|
||||
DesiredState: directContainer.State,
|
||||
Status: {
|
||||
State: directContainer.State,
|
||||
ContainerStatus: { ContainerID: directContainer.Id },
|
||||
},
|
||||
CreatedAt: new Date((directContainer.Created || Date.now() / 1000) * 1000).toISOString(),
|
||||
UpdatedAt: new Date().toISOString(),
|
||||
};
|
||||
return { service, task, dockerServiceId: dockerService.ID, container: directContainer };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async taskToDeployment(
|
||||
taskArg: any,
|
||||
serviceArg: plugins.servezoneInterfaces.data.IService,
|
||||
dockerServiceIdArg: string,
|
||||
nodeNamesArg: Map<string, string>,
|
||||
): Promise<plugins.servezoneInterfaces.data.IDeployment> {
|
||||
const containerId = taskArg.Status?.ContainerStatus?.ContainerID;
|
||||
let resourceUsage: plugins.servezoneInterfaces.data.IDeployment['resourceUsage'];
|
||||
if (containerId) {
|
||||
const container = await this.coreflowRef.dockerHost.getContainerById(containerId).catch(() => undefined);
|
||||
if (container) {
|
||||
resourceUsage = await this.getContainerResourceUsage(container).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const deployedAt = Date.parse(taskArg.CreatedAt || '') || Date.now();
|
||||
const updatedAt = Date.parse(taskArg.UpdatedAt || '') || deployedAt;
|
||||
const status = this.mapTaskStatus(taskArg.Status?.State || taskArg.DesiredState);
|
||||
return {
|
||||
id: taskArg.ID,
|
||||
taskId: taskArg.ID,
|
||||
serviceId: serviceArg.id,
|
||||
serviceName: serviceArg.data.name,
|
||||
nodeId: taskArg.NodeID || '',
|
||||
nodeName: nodeNamesArg.get(taskArg.NodeID) || taskArg.NodeID || '',
|
||||
containerId,
|
||||
usedImageId: serviceArg.data.imageId,
|
||||
version: serviceArg.data.imageVersion || taskArg.Spec?.ContainerSpec?.Image || 'latest',
|
||||
deployedAt,
|
||||
updatedAt,
|
||||
deploymentLog: [taskArg.Status?.Message || `Docker task ${taskArg.ID}`],
|
||||
status,
|
||||
healthStatus: status === 'running' ? 'healthy' : status === 'failed' ? 'unhealthy' : 'unknown',
|
||||
resourceUsage,
|
||||
slot: taskArg.Slot,
|
||||
desiredState: taskArg.DesiredState,
|
||||
dockerServiceId: dockerServiceIdArg,
|
||||
};
|
||||
}
|
||||
|
||||
private async getContainerResourceUsage(containerArg: plugins.docker.DockerContainer) {
|
||||
const stats = await containerArg.stats({ stream: false });
|
||||
const cpuDelta = (stats.cpu_stats?.cpu_usage?.total_usage || 0) - (stats.precpu_stats?.cpu_usage?.total_usage || 0);
|
||||
const systemDelta = (stats.cpu_stats?.system_cpu_usage || 0) - (stats.precpu_stats?.system_cpu_usage || 0);
|
||||
const onlineCpus = stats.cpu_stats?.online_cpus || stats.cpu_stats?.cpu_usage?.percpu_usage?.length || 1;
|
||||
const cpuUsagePercent = systemDelta > 0 && cpuDelta > 0
|
||||
? (cpuDelta / systemDelta) * onlineCpus * 100
|
||||
: 0;
|
||||
return {
|
||||
cpuUsagePercent,
|
||||
memoryUsedMB: Math.round((stats.memory_stats?.usage || 0) / 1024 / 1024),
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
private mapTaskStatus(statusArg: string): plugins.servezoneInterfaces.data.IDeployment['status'] {
|
||||
switch (statusArg) {
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'new':
|
||||
case 'pending':
|
||||
case 'assigned':
|
||||
case 'accepted':
|
||||
case 'preparing':
|
||||
case 'ready':
|
||||
case 'starting':
|
||||
return 'starting';
|
||||
case 'shutdown':
|
||||
case 'complete':
|
||||
case 'remove':
|
||||
return 'stopped';
|
||||
case 'failed':
|
||||
case 'rejected':
|
||||
case 'orphaned':
|
||||
return 'failed';
|
||||
default:
|
||||
return 'scheduled';
|
||||
}
|
||||
}
|
||||
|
||||
private async getDockerServiceByNameOrNull(serviceNameArg: string) {
|
||||
try {
|
||||
return await this.coreflowRef.dockerHost.getServiceByName(serviceNameArg);
|
||||
} catch (error) {
|
||||
if ((error as Error).message === `Service not found: ${serviceNameArg}`) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async listTasksForService(dockerServiceIdArg: string) {
|
||||
const filters = encodeURIComponent(JSON.stringify({ service: [dockerServiceIdArg] }));
|
||||
const response = await this.coreflowRef.dockerHost.request('GET', `/tasks?filters=${filters}`);
|
||||
return Array.isArray(response.body) ? response.body : [];
|
||||
}
|
||||
|
||||
private async listTasks() {
|
||||
const response = await this.coreflowRef.dockerHost.request('GET', '/tasks');
|
||||
return Array.isArray(response.body) ? response.body : [];
|
||||
}
|
||||
|
||||
private async getNodeNameMap() {
|
||||
const nodeNames = new Map<string, string>();
|
||||
try {
|
||||
const response = await this.coreflowRef.dockerHost.request('GET', '/nodes');
|
||||
for (const node of response.body || []) {
|
||||
nodeNames.set(node.ID, node.Description?.Hostname || node.Spec?.Name || node.ID);
|
||||
}
|
||||
} catch {
|
||||
// Single-node Docker setups may not expose Swarm nodes before init.
|
||||
}
|
||||
return nodeNames;
|
||||
}
|
||||
|
||||
private async streamToString(streamArg: plugins.smartstream.stream.Duplex) {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let result = '';
|
||||
streamArg.on('data', (chunkArg) => {
|
||||
result += Buffer.isBuffer(chunkArg) ? chunkArg.toString('utf8') : String(chunkArg);
|
||||
});
|
||||
streamArg.on('end', () => resolve(result));
|
||||
streamArg.on('close', () => resolve(result));
|
||||
streamArg.on('error', reject);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user