diff --git a/.changeset/funny-peas-wave.md b/.changeset/funny-peas-wave.md new file mode 100644 index 0000000000..fc75a88615 --- /dev/null +++ b/.changeset/funny-peas-wave.md @@ -0,0 +1,7 @@ +--- +"@scow/scheduler-adapter-protos": patch +"@scow/config": patch +"@scow/ai": patch +--- + +AI 增加多机多卡分布式训练和对华为 GPU 的特殊处理 diff --git a/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx b/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx index 07240ca1d9..de587d440d 100644 --- a/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx +++ b/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx @@ -87,6 +87,7 @@ export const AppSessionsTable: React.FC = ({ cluster, status }) => { title: "作业ID", dataIndex: "jobId", width: "8%", + defaultSortOrder: "descend", sorter: (a, b) => compareNumber(a.jobId, b.jobId), }, { @@ -258,6 +259,7 @@ export const AppSessionsTable: React.FC = ({ cluster, status }) => { const filteredData = useMemo(() => { if (!data) { return []; } + return data.sessions.filter((x) => { if (query.appJobName) { return x.sessionId.toLowerCase().includes(query.appJobName.toLowerCase()); diff --git a/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx b/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx index d813c22c6f..58a9569a1e 100644 --- a/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx +++ b/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx @@ -29,7 +29,7 @@ import { ModelInterface, ModelVersionInterface } from "src/models/Model"; import { DatasetInterface } from "src/server/trpc/route/dataset/dataset"; import { DatasetVersionInterface } from "src/server/trpc/route/dataset/datasetVersion"; import { AppCustomAttribute, CreateAppInput } from "src/server/trpc/route/jobs/apps"; -import { TrainJobInput } from "src/server/trpc/route/jobs/jobs"; +import { FrameworkType, TrainJobInput } from "src/server/trpc/route/jobs/jobs"; import { formatSize } from "src/utils/format"; import { parseBooleanParam } from "src/utils/parse"; import { trpc } from "src/utils/trpc"; @@ -59,6 +59,7 @@ interface FixedFormFields { useCustomImage: boolean; image: { type: AccessibilityType, name: number }; remoteImageUrl: string | undefined; + framework: FrameworkType | undefined; startCommand?: string; showDataset: boolean; dataset: { type: AccessibilityType, name: number, version: number }; @@ -67,6 +68,7 @@ interface FixedFormFields { mountPoints: string[] | undefined; partition: string | undefined; coreCount: number; + nodeCount: number; gpuCount: number | undefined; account: string; maxTime: number; @@ -90,6 +92,8 @@ interface Partition { gpus: number; nodes: number; comment?: string; + gpuType?: string; + } export enum AccessibilityType { @@ -103,6 +107,7 @@ const genAppJobName = (appName: string): string => { }; const initialValues = { + nodeCount:1, coreCount: 1, gpuCount: 1, maxTime: 60, @@ -127,7 +132,16 @@ export const LaunchAppForm = (props: Props) => { const [currentPartitionInfo, setCurrentPartitionInfo] = useState(); - + const [frameworkOptions, setFrameworkOptions] = useState<{ value: FrameworkType, label: string }[]>([ + { + value: "tensorflow", + label: "TensorFlow", + }, + { + value: "pytorch", + label: "PyTorch", + }, + ]); const showAlgorithm = Form.useWatch("showAlgorithm", form); const showDataset = Form.useWatch("showDataset", form); @@ -223,8 +237,7 @@ export const LaunchAppForm = (props: Props) => { .map((x) => ({ label: `${x.name}:${x.tag}`, value: x.id })); }, [images]); - // 暂时写死为1 - const nodeCount = 1; + const nodeCount = Form.useWatch("nodeCount", form); const coreCount = Form.useWatch("coreCount", form); @@ -251,10 +264,26 @@ export const LaunchAppForm = (props: Props) => { } else { form.setFieldValue("coreCount", 1); } + + form.setFieldValue("framework", undefined); + setCurrentPartitionInfo(partitionInfo); }; + useEffect(() => { + // 特殊处理,如果是华为Ascend910,则增加MindSpore选项 + if (currentPartitionInfo?.gpuType === "huawei.com/Ascend910") { + setFrameworkOptions((prevOptions) => { + return prevOptions.find((item) => item.value === "mindspore") ? + prevOptions : [...prevOptions, { value: "mindspore", label: "MindSpore" }]; + }); + } else { + setFrameworkOptions((prevOptions) => { + return prevOptions.filter((item) => item.value !== "mindspore"); + }); + } + }, [currentPartitionInfo]); const customFormItems = useMemo(() => attributes.map((item, index) => { const rules: Rule[] = item.type === "NUMBER" ? [{ type: "integer" }, { required: item.required }] @@ -437,16 +466,19 @@ export const LaunchAppForm = (props: Props) => { appJobName: genAppJobName(appName ?? "trainJobs"), }); } else { - const { account, partition, gpuCount, coreCount, maxTime, mountPoints } = inputParams; + const { account, partition, gpuCount, coreCount, maxTime, mountPoints, nodeCount } = inputParams; const workingDir = "workingDirectory" in inputParams ? inputParams.workingDirectory : undefined; const customAttributes = "customAttributes" in inputParams ? inputParams.customAttributes : {}; const command = "command" in inputParams ? inputParams.command : undefined; + const framework = "framework" in inputParams ? inputParams.framework : undefined; form.setFieldsValue({ mountPoints, customFields: { ...customAttributes, workingDir, }, + nodeCount, + framework, account, partition, gpuCount, @@ -487,7 +519,7 @@ export const LaunchAppForm = (props: Props) => { }} onFinish={async () => { - const { appJobName, algorithm, dataset, image, remoteImageUrl, startCommand, model, + const { appJobName, algorithm, dataset, image, remoteImageUrl, framework, startCommand, model, mountPoints, account, partition, coreCount, gpuCount, maxTime, command, customFields } = await form.validateFields(); @@ -499,6 +531,7 @@ export const LaunchAppForm = (props: Props) => { algorithm: algorithm?.version, image: image?.name, remoteImageUrl, + framework, isDatasetPrivate, dataset: dataset?.version, isModelPrivate, @@ -514,6 +547,7 @@ export const LaunchAppForm = (props: Props) => { maxTime: maxTime, memory: memorySize, command: command || "", + gpuType: currentPartitionInfo!.gpuType, }); } else { let workingDirectory: string | undefined; @@ -551,6 +585,7 @@ export const LaunchAppForm = (props: Props) => { memory: memorySize, workingDirectory, customAttributes: customFormKeyValue.customFields, + gpuType: currentPartitionInfo!.gpuType, }); } } @@ -656,6 +691,20 @@ export const LaunchAppForm = (props: Props) => { > + {/* 分布式训练或者华为的卡训练,需要指定训练框架 */} + {(isTraining && (nodeCount > 1 || currentPartitionInfo?.gpuType === "huawei.com/Ascend910")) ? ( + <> + {/* 手动选择算法框架,下拉框只有 tensorflow, pytorch */} + + + + + ) : null} {(customImage && !isTraining) ? ( { onChange={handlePartitionChange} /> - {/* - */} + { currentPartitionInfo?.gpus ? ( { + const nodeCount = form.getFieldValue("nodeCount") || 0; + if (currentPartitionInfo + && currentPartitionInfo.gpus > 0 + && (nodeCount * value > currentPartitionInfo.gpus)) { + return Promise.reject(new Error("Total GPUs exceed the available GPUs in the partition")); + } + return Promise.resolve(); + }, }, ]} > ) : ( { + const nodeCount = form.getFieldValue("nodeCount") || 0; + if (currentPartitionInfo && (nodeCount * value > currentPartitionInfo.cores)) { + return Promise.reject(new Error("Total cores exceed the available cores in the partition")); + } + return Promise.resolve(); + }, + }, ]} > diff --git a/apps/ai/src/server/setup/jobShell.ts b/apps/ai/src/server/setup/jobShell.ts index dcd7b98fbf..db1bab8393 100644 --- a/apps/ai/src/server/setup/jobShell.ts +++ b/apps/ai/src/server/setup/jobShell.ts @@ -189,9 +189,10 @@ wss.on("connection", async (ws: AliveCheckedWebSocket, req) => { return; } - const { namespace, pod } = job; + const namespace = job.containerJobInfo?.namespace; + const podName = job.containerJobInfo?.podName; - if (!namespace || !pod) { + if (!namespace || !podName) { log("[shell] Namespace or pod not obtained, please check the adapter version"); ws.close(0, "Namespace or pod not obtained, please check the adapter version"); return; @@ -201,7 +202,7 @@ wss.on("connection", async (ws: AliveCheckedWebSocket, req) => { const kc = new k8sClient.KubeConfig(); kc.loadFromFile(join("/etc/scow", clusters[clusterId].k8s?.kubeconfig.path || "/kube/config")); const k8sWs = await new k8sClient.Exec(kc) - .exec(namespace, pod, "", ["/bin/sh"], stdoutStream, stderrStream, stdinStream, true); + .exec(namespace, podName, "", ["/bin/sh"], stdoutStream, stderrStream, stdinStream, true); log("Connected to shell"); diff --git a/apps/ai/src/server/trpc/route/config.ts b/apps/ai/src/server/trpc/route/config.ts index 4b0d2fc190..ae6aeaf023 100644 --- a/apps/ai/src/server/trpc/route/config.ts +++ b/apps/ai/src/server/trpc/route/config.ts @@ -127,7 +127,7 @@ export type NavLink = z.infer; export type UiConfig = z.infer; -const PartitionSchema = z.object({ +export const PartitionSchema = z.object({ name: z.string(), memMb: z.number(), cores: z.number(), @@ -135,6 +135,8 @@ const PartitionSchema = z.object({ nodes: z.number(), qos: z.array(z.string()), comment: z.string().optional(), + gpuType: z.string().optional(), + vramMb: z.number().optional(), }); const ClusterConfigSchema = z.object({ diff --git a/apps/ai/src/server/trpc/route/jobs/apps.ts b/apps/ai/src/server/trpc/route/jobs/apps.ts index 1bb2af6ee9..ae1c41422c 100644 --- a/apps/ai/src/server/trpc/route/jobs/apps.ts +++ b/apps/ai/src/server/trpc/route/jobs/apps.ts @@ -25,6 +25,7 @@ import { sftpRealPath, sftpWriteFile, } from "@scow/lib-ssh"; +import { getI18nConfigCurrentText } from "@scow/lib-web/build/utils/systemLanguage"; import { TRPCError } from "@trpc/server"; import fs from "fs"; import { join } from "path"; @@ -34,8 +35,8 @@ import { aiConfig } from "src/server/config/ai"; import { Image as ImageEntity, Source, Status } from "src/server/entities/Image"; import { callLog } from "src/server/setup/operationLog"; import { procedure } from "src/server/trpc/procedure/base"; -import { checkAppExist, checkCreateAppEntity, - fetchJobInputParams, getClusterAppConfigs, validateUniquePaths } from "src/server/utils/app"; +import { allApps, checkAppExist, checkCreateAppEntity, + fetchJobInputParams, getAllTags, getClusterAppConfigs, validateUniquePaths } from "src/server/utils/app"; import { getAdapterClient } from "src/server/utils/clusters"; import { clusterNotFound } from "src/server/utils/errors"; import { forkEntityManager } from "src/server/utils/getOrm"; @@ -56,6 +57,7 @@ import { parseIp } from "src/utils/parse"; import { BASE_PATH } from "src/utils/processEnv"; import { z } from "zod"; +import { clusters, PartitionSchema } from "../config"; import { booleanQueryParam } from "../utils"; const ImageSchema = z.object({ @@ -155,6 +157,12 @@ const AttributeTypeSchema = z.enum(["TEXT", "NUMBER", "SELECT"]); export type AttributeType = z.infer; +const ClusterConfig = z.object({ + schedulerName: z.string(), + clusterId: z.string(), + partitions: z.array(PartitionSchema), +}); + export const listAvailableApps = procedure .meta({ @@ -253,6 +261,7 @@ const CreateAppInputSchema = z.object({ maxTime: z.number(), workingDirectory: z.string().optional(), customAttributes: z.record(z.string(), z.union([z.number(), z.string(), z.undefined()])), + gpuType: z.string().optional(), }); export type CreateAppInput = z.infer; @@ -298,7 +307,7 @@ export const createAppSession = procedure const { clusterId, appId, appJobName, isAlgorithmPrivate, algorithm, image, startCommand, remoteImageUrl, isDatasetPrivate, dataset, isModelPrivate, model, mountPoints = [], account, partition, coreCount, nodeCount, gpuCount, memory, - maxTime, workingDirectory, customAttributes } = input; + maxTime, workingDirectory, customAttributes, gpuType } = input; const apps = getClusterAppConfigs(clusterId); const app = checkAppExist(apps, appId); @@ -483,6 +492,7 @@ export const createAppSession = procedure // 第五个参数为数据集版本地址 // 第六个参数为模型版本地址 // 第七个参数为多挂载点地址,以逗号分隔 + // 第八个参数为gpuType, 表示训练时硬件卡的类型,由getClusterConfig接口获取 extraOptions: [ JobType.APP, app.type, @@ -504,6 +514,7 @@ export const createAppSession = procedure : modelVersion.path : "", mountPoints.join(","), + gpuType || "", ], }).catch((e) => { const ex = e as ServiceError; @@ -657,9 +668,10 @@ export const saveImage = }); } - const { node, containerId } = job; + const nodeName = job.containerJobInfo?.nodeName; + const containerId = job.containerJobInfo?.containerId; - if (!node || !containerId) { + if (!nodeName || !containerId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Can not find node or containerId of this running job", @@ -669,14 +681,14 @@ export const saveImage = const formateContainerId = formatContainerId(clusterId, containerId); // 连接到该节点 - return await sshConnect(node, "root", logger, async (ssh) => { + return await sshConnect(nodeName, "root", logger, async (ssh) => { try { const harborImageUrl = createHarborImageUrl(imageName, imageTag, user.identityId); const localImageUrl = `${userId}/${imageName}:${imageTag}`; // commit镜像 await commitContainerImage({ - node, + node:nodeName, ssh, clusterId, logger, @@ -1047,3 +1059,119 @@ procedure }); +export const listApps = procedure + .meta({ + openapi: { + method: "GET", + path: "/apps/search", + tags: ["app"], + summary: "List all Apps By tags", + }, + }) + .input(z.object({ + tags: z.string().optional(), + })) + .output((z.object({ apps: z.array(appSchema) }))) + .query(async ({ input }) => { + + const { tags } = input; + + const apps = Object.entries(allApps)?.filter(([_, config]) => { + if (tags) { + return tags.split(",") + .some((tag) => + (config.tags?.includes(tag) || config.clusterSpecificConfigs?.some((x) => x.config.tags?.includes(tag))), + ); + } + return true; + }).map(([id, config]) => { + + const aggregateTags = config.clusterSpecificConfigs?.reduce((prev, curr) => { + curr.config.tags?.forEach((tag) => { + if (!prev.has(tag)) { + prev.add(tag); + } + }); + return prev; + }, new Set(config.tags)) || config.tags || []; + + return ({ + id, + name: config.name, + logoPath: config.logoPath, + tags: Array.from(aggregateTags), + appComment: getI18nConfigCurrentText(config.appComment, undefined), + }); + }); + + return { + apps, + }; + }); + +export const listTags = procedure + .meta({ + openapi: { + method: "GET", + path: "/apps/tags/search", + tags: ["app"], + summary: "List all App tags", + }, + }) + .input(z.void()) + .output(z.object({ tags: z.array(z.string()) })) + .query(async () => { + + const tags = getAllTags(allApps); + + return { + tags, + }; + }); + +export const listClusters = procedure + .meta({ + openapi: { + method: "GET", + path: "/apps/clusters/search", + tags: ["app"], + summary: "List All Clusters By AppID", + }, + }) + .input(z.object({ + appId: z.string(), + })) + .output(z.object({ clusterConfigs: z.array(ClusterConfig) })) + .query(async ({ input }) => { + const { appId } = input; + const allClusterIds = Object.keys(clusters); + const clusterIds: string[] = []; + // 获取集群ids + // common app + if (!allApps[appId].clusterSpecificConfigs) { + clusterIds.push(...allClusterIds); + } else { + allApps[appId].clusterSpecificConfigs?.map((config) => { + clusterIds.push(config.cluster); + }); + } + + const configs = await Promise.all(clusterIds + .map(async (clusterId) => { + const client = getAdapterClient(clusterId); + if (!client) { + throw new TRPCError({ + code: "NOT_FOUND", + message:`cluster ${clusterId} is not found`, + }); + } + return await asyncClientCall(client.config, "getClusterConfig", {}); + })); + + return { + clusterConfigs: configs.map((config,idx) => ({ ...config,clusterId:clusterIds[idx] })), + }; + + }) + ; + diff --git a/apps/ai/src/server/trpc/route/jobs/index.ts b/apps/ai/src/server/trpc/route/jobs/index.ts index 859f17d1c9..a4963a505b 100644 --- a/apps/ai/src/server/trpc/route/jobs/index.ts +++ b/apps/ai/src/server/trpc/route/jobs/index.ts @@ -18,8 +18,11 @@ import { createAppSession, getAppMetadata, getCreateAppParams, + listApps, listAppSessions, listAvailableApps, + listClusters, + listTags, saveImage, } from "./apps"; import { cancelJob, getSubmitTrainParams, trainJob } from "./jobs"; @@ -32,6 +35,9 @@ export const jobsRouter = router({ checkAppConnectivity, getCreateAppParams, connectToApp, + listApps, + listTags, + listClusters, cancelJob, saveImage, trainJob, diff --git a/apps/ai/src/server/trpc/route/jobs/jobs.ts b/apps/ai/src/server/trpc/route/jobs/jobs.ts index 0c11092ecb..feb5cea0c2 100644 --- a/apps/ai/src/server/trpc/route/jobs/jobs.ts +++ b/apps/ai/src/server/trpc/route/jobs/jobs.ts @@ -37,6 +37,18 @@ import { z } from "zod"; const SESSION_METADATA_NAME = "session.json"; + + +// 分布式训练框架 +export const Framework = z.union([ + z.literal("tensorflow"), + z.literal("pytorch"), + z.literal("mindspore"), +]); + +export type FrameworkType = z.infer; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const ImageSchema = z.object({ name: z.string(), @@ -61,6 +73,7 @@ const TrainJobInputSchema = z.object({ algorithm: z.number().optional(), image: z.number().optional(), remoteImageUrl: z.string().optional(), + framework: Framework.optional(), isDatasetPrivate: z.boolean().optional(), dataset: z.number().optional(), isModelPrivate: z.boolean().optional(), @@ -74,6 +87,7 @@ const TrainJobInputSchema = z.object({ memory: z.number().optional(), maxTime: z.number(), command: z.string(), + gpuType: z.string().optional(), }); export type TrainJobInput = z.infer; @@ -118,9 +132,9 @@ procedure }) .mutation( async ({ input, ctx: { user } }) => { - const { clusterId, trainJobName, isAlgorithmPrivate, algorithm, image, remoteImageUrl, + const { clusterId, trainJobName, isAlgorithmPrivate, algorithm, image, framework, remoteImageUrl, isDatasetPrivate, dataset, isModelPrivate, model, mountPoints = [], account, partition, - coreCount, nodeCount, gpuCount, memory, maxTime, command } = input; + coreCount, nodeCount, gpuCount, memory, maxTime, command, gpuType } = input; const userId = user.identityId; const host = getClusterLoginNode(clusterId); @@ -146,6 +160,7 @@ procedure const homeDir = await getUserHomedir(ssh, userId, logger); + mountPoints.forEach((mountPoint) => { if (mountPoint && !isParentOrSameFolder(homeDir, mountPoint)) { throw new TRPCError({ @@ -195,6 +210,8 @@ procedure // 第五个参数为数据集版本地址 // 第六个参数为模型版本地址 // 第七个参数为多挂载点地址,以逗号分隔 + // 第八个参数为gpuType, 表示训练时硬件卡的类型,由getClusterConfig接口获取 + // 第九个参数告知适配器 该镜像对应的AI训练框架 如 tensorflow, pytorch 等 extraOptions: [ JobType.TRAIN, "", @@ -215,6 +232,10 @@ procedure : modelVersion.path : "", mountPoints.join(","), + gpuType || "", + // 如果是单机训练,则训练框架为空,表明为普通训练,华为的卡单机训练也要传框架 + // 如果nodeCount不为1但同时选定镜像又没有框架标签,该接口会报错 + (nodeCount === 1 && !gpuType?.startsWith("huawei.com")) ? "" : framework || "", ], }).catch((e) => { const ex = e as ServiceError; @@ -230,8 +251,8 @@ procedure sessionId: trainJobName, submitTime: new Date().toISOString(), image: { - name: existImage!.name, - tag: existImage!.tag, + name: remoteImageUrl || existImage!.name, + tag: existImage?.tag || "latest", }, jobType: JobType.TRAIN, }; diff --git a/apps/ai/src/server/utils/app.ts b/apps/ai/src/server/utils/app.ts index fd2db61c0b..0d2076c74c 100644 --- a/apps/ai/src/server/utils/app.ts +++ b/apps/ai/src/server/utils/app.ts @@ -12,6 +12,7 @@ import { EntityManager } from "@mikro-orm/mysql"; import { AppConfigSchema } from "@scow/config/build/appForAi"; +import { ClusterConfigSchema } from "@scow/config/build/cluster"; import { DEFAULT_CONFIG_BASE_PATH } from "@scow/config/build/constants"; import { sftpExists, sftpReadFile } from "@scow/lib-ssh"; import { TRPCError } from "@trpc/server"; @@ -25,6 +26,8 @@ import { SFTPWrapper } from "ssh2"; import { Logger } from "ts-log"; import { z } from "zod"; +import { clusters } from "../trpc/route/config"; + export const getClusterAppConfigs = (cluster: string) => { @@ -46,6 +49,66 @@ export const getClusterAppConfigs = (cluster: string) => { }; +type AppConfigWithClusterSpecific = AppConfigSchema & { + clusterSpecificConfigs?: { + cluster: string, + config: AppConfigSchema, + }[] +}; + + +export const getAllAppConfigs = (clusters: Record) => { + const commonApps = getAiAppConfigs(); + + const apps: Record = {}; + + for (const [key, value] of Object.entries(commonApps)) { + apps[key] = value; + } + + + Object.keys(clusters).forEach((cluster) => { + const clusterAppsConfigs = getAiAppConfigs(join(DEFAULT_CONFIG_BASE_PATH, "clusters/", cluster)); + for (const [key, value] of Object.entries(clusterAppsConfigs)) { + + const specificConfig = { + cluster, + config: value, + }; + + // 集群独有的应用,直接用集群配置 + if (!apps[key]) apps[key] = value; + + if (apps[key].clusterSpecificConfigs) { + apps[key].clusterSpecificConfigs?.push(specificConfig); + } else { + apps[key].clusterSpecificConfigs = [specificConfig]; + } + } + }); + + return apps; +}; + +export const allApps = getAllAppConfigs(clusters); + + +// 获取所有应用的标签集合 +export const getAllTags = (allApps: Record): string[] => { + const allTags = new Set(); + + Object.values(allApps).forEach((appConfig) => { + appConfig.tags?.forEach((tag) => allTags.add(tag)); + appConfig.clusterSpecificConfigs?.forEach((clusterConfig) => { + clusterConfig.config.tags?.forEach((tag) => allTags.add(tag)); + }); + }); + + return Array.from(allTags); +}; + + + /** * @param orm mikro-orm * @param dataset dataset version id diff --git a/dev/vagrant/config/ai/apps/jupyter.yaml b/dev/vagrant/config/ai/apps/jupyter.yaml index 67f9421817..5f1262eba2 100644 --- a/dev/vagrant/config/ai/apps/jupyter.yaml +++ b/dev/vagrant/config/ai/apps/jupyter.yaml @@ -12,6 +12,9 @@ image: # 镜像版本 tag: latest +tags: + - jupyter + # 指定应用类型为web type: web # Web应用的配置 diff --git a/dev/vagrant/config/ai/apps/pycharm.yaml b/dev/vagrant/config/ai/apps/pycharm.yaml index 6c68a8c93b..924d6501af 100644 --- a/dev/vagrant/config/ai/apps/pycharm.yaml +++ b/dev/vagrant/config/ai/apps/pycharm.yaml @@ -15,6 +15,9 @@ image: # 镜像版本 tag: v1.1 +tags: + - pycharm + # VNC应用的配置 vnc: # 此X Session的xstartup脚本 diff --git a/dev/vagrant/config/ai/apps/vscode.yaml b/dev/vagrant/config/ai/apps/vscode.yaml index 17e72286e6..f554f6a357 100644 --- a/dev/vagrant/config/ai/apps/vscode.yaml +++ b/dev/vagrant/config/ai/apps/vscode.yaml @@ -14,6 +14,9 @@ image: # 镜像版本 tag: 4.20.0 +tags: + - vscode + # 指定应用类型为web type: web diff --git a/libs/config/src/appForAi.ts b/libs/config/src/appForAi.ts index f439d7f112..61b75009bf 100644 --- a/libs/config/src/appForAi.ts +++ b/libs/config/src/appForAi.ts @@ -60,6 +60,7 @@ export const AppConfigSchema = Type.Object({ name: Type.String({ description: "App镜像名" }), tag: Type.String({ description: "App镜像标签", default: "latest" }), }), + tags:Type.Optional(Type.Array(Type.String(), { description: "应用标签, 一个应用可以打多个标签" })), type: Type.Enum(AppType, { description: "应用类型" }), web: Type.Optional(WebAppConfigSchema), vnc: Type.Optional(VncAppConfigSchema),