From e312efb199cecb240f7c07be581f4a4955bba85f Mon Sep 17 00:00:00 2001 From: ZihanChen821 <130351655+ZihanChen821@users.noreply.github.com> Date: Tue, 23 Apr 2024 19:48:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20ai=20=E5=A2=9E=E5=8A=A0vnc=EF=BC=8C?= =?UTF-8?q?=20shell=20=E5=88=B0=E5=AE=B9=E5=99=A8=EF=BC=8C=20ai=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=BD=9C=E4=B8=9A=E4=BC=98=E5=8C=96=20(#1202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # ai 提交作业优化 1. 支持多个挂载点 2. 镜像支持手动输入 3. 已分享的镜像,数据集,以及模型,选取私有时,取privatePath而不是分享后的路径 4. 将提交作业时ai相关的参数都放入extra_options里 # ai 新增以shell 的方式进入容器的功能 ai 新增进入训练中的作业的容器并执行 shell 操作的功能。该功能依赖于 k8s 的 api server,所以需要一份 kubectl config 配置文件。 ![image](https://github.com/PKUHPC/SCOW/assets/140392039/8c01af88-ffac-41fd-9600-1658a7b6c24d) ![image](https://github.com/PKUHPC/SCOW/assets/140392039/1e9b433d-b18c-4cc1-88c1-b667f70c2070) # ai 增加 vnc Ai模块新增vnc应用 --------- Co-authored-by: Miracle575 --- .changeset/lemon-fans-peel.md | 7 + .changeset/pretty-toes-greet.md | 10 + .changeset/witty-camels-share.md | 5 + apps/ai/assets/app/vnc_entry.sh | 6 +- apps/ai/next.config.mjs | 2 +- apps/ai/package.json | 7 +- apps/ai/src/app/(auth)/dashboard/page.tsx | 26 +- .../jobShell/[clusterId]/[jobId]/page.tsx | 94 +++++ .../jobs/[clusterId]/AppSessionsTable.tsx | 14 +- .../jobs/[clusterId]/ConnectToAppLink.tsx | 6 +- .../(auth)/jobs/[clusterId]/LaunchAppForm.tsx | 154 ++++++-- apps/ai/src/components/shell/JobShell.tsx | 112 ++++++ apps/ai/src/pages/api/setup.ts | 3 + apps/ai/src/server/config/env.ts | 2 + apps/ai/src/server/setup/jobShell.ts | 258 ++++++++++++++ apps/ai/src/server/trpc/route/config.ts | 4 + apps/ai/src/server/trpc/route/jobs/apps.ts | 200 ++++++++--- apps/ai/src/server/trpc/route/jobs/jobs.ts | 72 +++- apps/ai/src/server/utils/app.ts | 20 ++ apps/ai/src/utils/vnc.ts | 8 +- apps/cli/src/compose/index.ts | 1 + dev/test-adapter/src/services/app.ts | 7 - dev/vagrant/config/ai/apps/jupyter.yaml | 2 +- dev/vagrant/config/ai/apps/pycharm.yaml | 22 ++ .../config/ai/apps/configure-vnc-app.md | 67 ++++ docs/docs/deploy/config/ai/apps/intro.md | 3 +- docs/docs/deploy/config/ai/intro.md | 34 +- libs/config/src/appForAi.ts | 9 + libs/config/src/cluster.ts | 3 + libs/protos/scheduler-adapter/package.json | 2 +- pnpm-lock.yaml | 329 +++++++++++++++++- 31 files changed, 1326 insertions(+), 163 deletions(-) create mode 100644 .changeset/lemon-fans-peel.md create mode 100644 .changeset/pretty-toes-greet.md create mode 100644 .changeset/witty-camels-share.md create mode 100644 apps/ai/src/app/(auth)/jobShell/[clusterId]/[jobId]/page.tsx create mode 100644 apps/ai/src/components/shell/JobShell.tsx create mode 100644 apps/ai/src/server/setup/jobShell.ts create mode 100644 dev/vagrant/config/ai/apps/pycharm.yaml create mode 100644 docs/docs/deploy/config/ai/apps/configure-vnc-app.md diff --git a/.changeset/lemon-fans-peel.md b/.changeset/lemon-fans-peel.md new file mode 100644 index 0000000000..40e3638a56 --- /dev/null +++ b/.changeset/lemon-fans-peel.md @@ -0,0 +1,7 @@ +--- +"@scow/config": patch +"@scow/cli": patch +"@scow/ai": patch +--- + +AI 模块支持创建 vnc 类型应用 diff --git a/.changeset/pretty-toes-greet.md b/.changeset/pretty-toes-greet.md new file mode 100644 index 0000000000..3c4c3d5960 --- /dev/null +++ b/.changeset/pretty-toes-greet.md @@ -0,0 +1,10 @@ +--- +"@scow/scheduler-adapter-protos": patch +"@scow/test-adapter": patch +"@scow/config": patch +"@scow/cli": patch +"@scow/ai": patch +"@scow/docs": patch +--- + +ai 增加 vnc 功能,以 shell 方式进入容器功能和提交作业的优化 diff --git a/.changeset/witty-camels-share.md b/.changeset/witty-camels-share.md new file mode 100644 index 0000000000..cbc86a8b83 --- /dev/null +++ b/.changeset/witty-camels-share.md @@ -0,0 +1,5 @@ +--- +"@scow/ai": patch +--- + +ai 新增以 shell 的方式进入容器的功能 diff --git a/apps/ai/assets/app/vnc_entry.sh b/apps/ai/assets/app/vnc_entry.sh index 8ad1dfb535..44ddce66d9 100644 --- a/apps/ai/assets/app/vnc_entry.sh +++ b/apps/ai/assets/app/vnc_entry.sh @@ -1 +1,5 @@ -//TODO +#!/bin/bash + +export PORT=$1 +export HOST=$2 +export SVCPORT=$3 diff --git a/apps/ai/next.config.mjs b/apps/ai/next.config.mjs index 75c1f878a2..4b7ad49591 100644 --- a/apps/ai/next.config.mjs +++ b/apps/ai/next.config.mjs @@ -27,7 +27,7 @@ export default async () => { // HACK setup ws proxy setTimeout(() => { const url = `http://localhost:${process.env.PORT || 3000}${join(BASE_PATH, "/api/setup")}`; - console.log("Calling setup url to initialize proxy and shell server", url); + console.log("Calling setup url to initialize proxy and job shell server", url); fetch(url).then(async (res) => { console.log("Call completed. Response: ", await res.text()); diff --git a/apps/ai/package.json b/apps/ai/package.json index 8390a6b7c9..7c78d24e32 100644 --- a/apps/ai/package.json +++ b/apps/ai/package.json @@ -37,6 +37,7 @@ "@ddadaal/tsgrpc-client": "0.17.7", "@ddadaal/tsgrpc-common": "0.2.5", "@grpc/grpc-js": "1.10.6", + "@kubernetes/client-node": "^0.20.0", "@mikro-orm/cli": "6.1.12", "@mikro-orm/core": "6.1.12", "@mikro-orm/migrations": "6.1.12", @@ -48,9 +49,9 @@ "@scow/lib-decimal": "workspace:*", "@scow/lib-operation-log": "workspace:*", "@scow/lib-scheduler-adapter": "workspace:*", + "@scow/lib-server": "workspace:*", "@scow/lib-ssh": "workspace:*", "@scow/lib-web": "workspace:*", - "@scow/lib-server": "workspace:*", "@scow/scheduler-adapter-protos": "workspace:*", "@scow/utils": "workspace:*", "@scow/rich-error-model": "workspace:*", @@ -87,8 +88,8 @@ "swagger-ui-react": "5.13.0", "trpc-openapi": "1.2.0", "ws": "8.16.0", - "xterm": "5.3.0", - "xterm-addon-fit": "0.8.0", + "@xterm/xterm": "5.5.0", + "@xterm/addon-fit": "0.10.0", "zod": "3.22.4", "shell-quote": "1.8.1", "replace-in-file": "7.1.0" diff --git a/apps/ai/src/app/(auth)/dashboard/page.tsx b/apps/ai/src/app/(auth)/dashboard/page.tsx index d12ddc2d5b..7fe73e7827 100644 --- a/apps/ai/src/app/(auth)/dashboard/page.tsx +++ b/apps/ai/src/app/(auth)/dashboard/page.tsx @@ -12,8 +12,8 @@ "use client"; import { join } from "path"; +import { usePublicConfig } from "src/app/(auth)/context"; import { Head } from "src/utils/head"; -import { trpc } from "src/utils/trpc"; import { styled } from "styled-components"; const Logo = styled.div` @@ -25,24 +25,22 @@ const Logo = styled.div` export default function Page() { - const { data } = trpc.config.publicConfig.useQuery(); + const { publicConfig: { BASE_PATH } } = usePublicConfig(); return (
{ - data ? ( - - logo - - ) : undefined + + logo + }
); diff --git a/apps/ai/src/app/(auth)/jobShell/[clusterId]/[jobId]/page.tsx b/apps/ai/src/app/(auth)/jobShell/[clusterId]/[jobId]/page.tsx new file mode 100644 index 0000000000..826fc05622 --- /dev/null +++ b/apps/ai/src/app/(auth)/jobShell/[clusterId]/[jobId]/page.tsx @@ -0,0 +1,94 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +"use client"; + +import "xterm/css/xterm.css"; + +import { Button, Space } from "antd"; +import dynamic from "next/dynamic"; +import { usePublicConfig } from "src/app/(auth)/context"; +import { Head } from "src/utils/head"; +import { styled } from "styled-components"; + +const Container = styled.div` + position: fixed; + left: 0; + top: 0; + height: 100%; + width: 100%; + z-index: 2000; + display: flex; + flex-direction: column; +`; + +const Header = styled.div` + padding: 8px 16px; + display: flex; + justify-content: space-between; + background-color: #333; + + h2 { color: white; margin: 0px; } + + .ant-popover-content p { + margin: 0; + } +`; + + +const TerminalContainer = styled.div` + display: flex; + flex: 1; + height: 100%; +`; + +const Black = styled.div` + height: 100%; + background-color: black; +`; + +const JobShellComponent = dynamic( + () => import("src/components/shell/JobShell").then((x) => x.JobShell), { + ssr: false, + loading: Black, + }); + +export default function Page({ params }: {params: {clusterId: string, jobId: string}}) { + + const { clusterId, jobId } = params; + const { publicConfig, user } = usePublicConfig(); + + const clusterName = publicConfig.CLUSTERS.find((x) => x.id === clusterId)?.name || clusterId; + + return ( + + +
+

+ {`用户 ${user.identityId} 连接到集群 ${clusterName} 的作业 ${jobId}`} +

+ + + +
+ + + +
+ ); +}; diff --git a/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx b/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx index 8341a53513..097ce3e16b 100644 --- a/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx +++ b/apps/ai/src/app/(auth)/jobs/[clusterId]/AppSessionsTable.tsx @@ -14,6 +14,7 @@ import { ExclamationCircleOutlined } from "@ant-design/icons"; import { App, Button, Checkbox, Form, Input, Popconfirm, Space, Table, TableColumnsType, Tooltip } from "antd"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { join } from "path"; import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -96,7 +97,7 @@ export const AppSessionsTable: React.FC = ({ cluster, status }) => { { title: "类型", dataIndex: "jobType", - width: "10%", + width: "8%", render: (_, record) => { if (record.jobType === JobType.APP) { return "应用"; @@ -107,19 +108,20 @@ export const AppSessionsTable: React.FC = ({ cluster, status }) => { { title: "应用", dataIndex: "appId", + width: "8%", render: (appId: string, record) => record.appName ?? appId, sorter: (a, b) => (!a.submitTime || !b.submitTime) ? -1 : compareDateTime(a.submitTime, b.submitTime), }, { title: "提交时间", dataIndex: "submitTime", - width: "15%", + width: "200px", render: (_, record) => record.submitTime ? formatDateTime(record.submitTime) : "", }, { title: "状态", dataIndex: "state", - width: "12%", + width: "120px", render: (_, record) => ( record.reason ? ( @@ -140,6 +142,7 @@ export const AppSessionsTable: React.FC = ({ cluster, status }) => { }, ...(unfinished ? [{ title: "剩余时间", + width: "100px", dataIndex: "remainingTime", }, ] : []), @@ -147,7 +150,7 @@ export const AppSessionsTable: React.FC = ({ cluster, status }) => { title: "操作", key: "action", fixed:"right", - width: "10%", + width: "350px", render: (_, record) => ( { @@ -160,6 +163,9 @@ export const AppSessionsTable: React.FC = ({ cluster, status }) => { refreshToken={connectivityRefreshToken} /> )} + + {"进入容器"} + = ({ session, cluster, refreshToken, }) => { - const { publicConfig: { BASE_PATH } } = usePublicConfig(); + const { publicConfig: { BASE_PATH, NOVNC_CLIENT_URL } } = usePublicConfig(); const { message } = App.useApp(); const { data, refetch } = trpc.jobs.checkAppConnectivity.useQuery({ clusterId: cluster, jobId: session.jobId }, { @@ -100,7 +101,8 @@ export const ConnectTopAppLink: React.FC = ({ } } else { - // TODO: vnc app + const { host, port, password } = reply; + openDesktop(BASE_PATH, NOVNC_CLIENT_URL, cluster, host, port, password); return; } diff --git a/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx b/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx index 0f064abc2a..7da4bc8d61 100644 --- a/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx +++ b/apps/ai/src/app/(auth)/jobs/[clusterId]/LaunchAppForm.tsx @@ -12,6 +12,7 @@ "use client"; +import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons"; import { I18nStringType } from "@scow/config/build/i18n"; import { getI18nConfigCurrentText } from "@scow/lib-web/build/utils/systemLanguage"; import { App, Button, Checkbox, Col, @@ -51,10 +52,11 @@ interface FixedFormFields { appJobName: string; algorithm: { name: number, version: number }; image: { name: number }; + remoteImageUrl: string | undefined; startCommand?: string; dataset: { name: number, version: number }; model: { name: number, version: number }; - mountPoint: string | undefined; + mountPoints: string[] | undefined; partition: string | undefined; coreCount: number; gpuCount: number | undefined; @@ -122,6 +124,10 @@ export const LaunchAppForm = (props: Props) => { const [showDataset, setShowDataset] = useState(false); const [showModel, setShowModel] = useState(false); + const isAlgorithmPrivate = Form.useWatch(["algorithm", "type"], form) === AccessibilityType.PRIVATE; + const isDatasetPrivate = Form.useWatch(["dataset", "type"], form) === AccessibilityType.PRIVATE; + const isModelPrivate = Form.useWatch(["model", "type"], form) === AccessibilityType.PRIVATE; + const { dataOptions: datasetOptions, isDataLoading: isDatasetsLoading } = useDataOptions( form, "dataset", @@ -172,6 +178,8 @@ export const LaunchAppForm = (props: Props) => { const imageType = Form.useWatch(["image", "type"], form); const selectedImage = Form.useWatch(["image", "name"], form); + const remoteImageInput = Form.useWatch("remoteImageUrl", form); + const customImage = remoteImageInput || selectedImage; const isImagePublic = imageType !== undefined ? imageType === AccessibilityType.PUBLIC : imageType; @@ -322,7 +330,6 @@ export const LaunchAppForm = (props: Props) => { }, }); - return (
{ }} onFinish={async () => { - const { appJobName, algorithm, dataset, image, startCommand, model, - mountPoint, account, partition, coreCount, + const { appJobName, algorithm, dataset, image, remoteImageUrl, startCommand, model, + mountPoints, account, partition, coreCount, gpuCount, maxTime, command, customFields } = await form.validateFields(); + if (isTraining) { await trainJobMutation.mutateAsync({ clusterId, trainJobName: appJobName, + isAlgorithmPrivate, algorithm: algorithm?.version, imageId: image?.name, + remoteImageUrl, + isDatasetPrivate, dataset: dataset?.version, + isModelPrivate, model: model?.version, - mountPoint: mountPoint, + mountPoints, account: account, partition: partition, nodeCount: nodeCount, @@ -369,12 +381,16 @@ export const LaunchAppForm = (props: Props) => { clusterId, appId: appId!, appJobName, + isAlgorithmPrivate, algorithm: algorithm?.version, image: image?.name, + remoteImageUrl, startCommand, + isDatasetPrivate, dataset: dataset?.version, + isModelPrivate, model: model?.version, - mountPoint, + mountPoints, account: account, partition: partition, nodeCount: nodeCount, @@ -387,7 +403,8 @@ export const LaunchAppForm = (props: Props) => { workingDirectory, customAttributes: customFormKeyValue.customFields, }); - } } + } + } } > @@ -399,12 +416,13 @@ export const LaunchAppForm = (props: Props) => { {!isTraining && ( {`请选择安装了${appName}应用的镜像,并指定启动命令`} + help={ useCustomImage && + {`请选择镜像或填写远程镜像地址,确保镜像安装了${appName}应用,并指定启动命令`} } > - {selectedImage + {remoteImageInput ? remoteImageInput : selectedImage ? imageOptions?.find((x) => x.value === selectedImage)?.label : appImage ? `${appImage?.name}:${appImage?.tag}` : "-"} @@ -415,9 +433,9 @@ export const LaunchAppForm = (props: Props) => { { (isTraining || useCustomImage) && ( <> - + - + { - {(selectedImage && !isTraining) ? ( + ({ + validator() { + if (getFieldValue(["image", "name"]) || getFieldValue("remoteImageUrl")) { + return Promise.resolve(); + } + return Promise.reject(new Error("请选择镜像或填写远程镜像地址")); + }, + })]} + dependencies={[["image", "name"]]} + > + + + {(customImage && !isTraining) ? ( @@ -468,23 +513,70 @@ export const LaunchAppForm = (props: Props) => { { customFormItems.filter((item) => item?.key?.includes("workingDir")) } - - { - form.setFieldValue("mountPoint", path); - form.validateFields(["mountPoint"]); - }} - clusterId={clusterId ?? ""} - /> - ) - } - /> - + + {(fields, { add, remove }) => ( + <> + {fields.map((field, index) => ( + + ({ + validator(_, value: string) { + + const currentValueNormalized = value.replace(/\/+$/, ""); + + const mountPoints: string[] = getFieldValue("mountPoints").map((mountPoint: string) => + mountPoint.replace(/\/+$/, ""), + ); + + const currentIndex = mountPoints.findIndex((point) => point === currentValueNormalized); + + const otherMountPoints = mountPoints.filter((_, idx) => idx !== currentIndex); + if (otherMountPoints.includes(currentValueNormalized)) { + return Promise.reject(new Error("挂载点地址不能重复")); + } + return Promise.resolve(); + }, + }), + ]} + > + { + // 当用户选择路径后触发表单的值更新并进行校验 + form.setFieldValue(["mountPoints", field.name], path); + // 校验特定的挂载点字段 + form.validateFields([["mountPoints", field.name]]); + }} + clusterId={clusterId ?? ""} + /> + )} + /> + + remove(field.name)} + /> + + ))} + + + + + )} + 添加算法/数据集/模型 setShowAlgorithm(e.target.checked)}> diff --git a/apps/ai/src/components/shell/JobShell.tsx b/apps/ai/src/components/shell/JobShell.tsx new file mode 100644 index 0000000000..7737b06c04 --- /dev/null +++ b/apps/ai/src/components/shell/JobShell.tsx @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { debounce } from "@scow/lib-web/build/utils/debounce"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import { join } from "path"; +import { useEffect, useRef } from "react"; +import { usePublicConfig } from "src/app/(auth)/context"; +import { ShellInputData, ShellOutputData } from "src/server/setup/jobShell"; +import { ClientUserInfo } from "src/server/trpc/route/auth"; +import { styled } from "styled-components"; + +const TerminalContainer = styled.div` + background-color: black; + flex: 1; + + width: 100%; +`; + +interface Props { + user: ClientUserInfo; + cluster: string; + jobId: string; +} + +export const JobShell: React.FC = ({ user, cluster, jobId }) => { + + const { publicConfig: { BASE_PATH } } = usePublicConfig(); + + const container = useRef(null); + const terminalInitialized = useRef(false); + + useEffect(() => { + if (container.current && !terminalInitialized.current) { + const term = new Terminal({ + cursorBlink: true, + }); + + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + term.open(container.current); + terminalInitialized.current = true; + + const payload = { + cluster, + jobId, + }; + + term.write( + `*** Connecting to cluster ${payload.cluster} as ${user.identityId} \r\n`, + ); + + const socket = new WebSocket( + (location.protocol === "http:" ? "ws" : "wss") + "://" + location.host + + join(BASE_PATH, "/api/jobShell") + "?" + new URLSearchParams(payload).toString(), + ); + + socket.onmessage = (e) => { + const message = JSON.parse(e.data) as ShellOutputData; + switch (message.$case) { + case "data": + const data = Buffer.from(message.data.data); + term.write(data); + break; + case "exit": + term.write(`Process exited with code ${message.exit.code} and signal ${message.exit.signal}.`); + break; + } + }; + + socket.onopen = () => { + term.clear(); + + const send = (data: ShellInputData) => { + socket.send(JSON.stringify(data)); + }; + + const resizeObserver = new ResizeObserver(debounce(() => { + fitAddon.fit(); + send({ $case: "resize", resize: { cols: term.cols, rows: term.rows } }); + })); + + resizeObserver.observe(container.current!); + + term.onData((data) => { + send({ $case: "data", data: { data } }); + }); + }; + + return () => { + if (socket) socket.close(); + if (term) term.dispose(); + terminalInitialized.current = false; + }; + } + }, [container.current]); + + return ( + + ); +}; + diff --git a/apps/ai/src/pages/api/setup.ts b/apps/ai/src/pages/api/setup.ts index 590c98116e..35e92fb9b3 100644 --- a/apps/ai/src/pages/api/setup.ts +++ b/apps/ai/src/pages/api/setup.ts @@ -11,6 +11,7 @@ */ import { NextApiRequest } from "next"; +import { setupJobShellServer } from "src/server/setup/jobShell"; import { setupWssProxy } from "src/server/setup/proxy"; let setup = false; @@ -22,6 +23,8 @@ export default async (req: NextApiRequest, res: any) => { } setupWssProxy(res); + setupJobShellServer(res); + setup = true; res.send("Setup complete"); diff --git a/apps/ai/src/server/config/env.ts b/apps/ai/src/server/config/env.ts index e2d16c4984..cc5fe42dbf 100644 --- a/apps/ai/src/server/config/env.ts +++ b/apps/ai/src/server/config/env.ts @@ -53,6 +53,8 @@ const specs = { DB_PASSWORD: str({ desc: "管理系统数据库密码,将会覆写配置文件", default: undefined }), DOWNLOAD_CHUNK_SIZE: num({ desc: "下载文件时,每个message中的chunk的大小。单位字节", default: 3 * 1024 * 1024 }), + + NOVNC_CLIENT_URL: str({ desc: "novnc客户端的URL。如果和本系统域名相同,可以只写完整路径", default: "/vnc" }), }; export const config = envConfig(specs); diff --git a/apps/ai/src/server/setup/jobShell.ts b/apps/ai/src/server/setup/jobShell.ts new file mode 100644 index 0000000000..63f1378034 --- /dev/null +++ b/apps/ai/src/server/setup/jobShell.ts @@ -0,0 +1,258 @@ +/** + * Copyright (c) 2022 Peking University and Peking University Institute for Computing and Digital Economy + * SCOW is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * http://license.coscl.org.cn/MulanPSL2 + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { asyncClientCall } from "@ddadaal/tsgrpc-client"; +import * as k8sClient from "@kubernetes/client-node"; +import { normalizePathnameWithQuery } from "@scow/utils"; +import { IncomingMessage } from "http"; +import { NextApiRequest } from "next"; +import { join } from "path"; +import { getUserToken } from "src/server/auth/cookie"; +import { validateToken } from "src/server/auth/token"; +import { clusters } from "src/server/trpc/route/config"; +import { getAdapterClient } from "src/server/utils/clusters"; +import { BASE_PATH } from "src/utils/processEnv"; +import { PassThrough } from "stream"; +import { WebSocket, WebSocketServer } from "ws"; + +export type ShellQuery = { + cluster: string; + + cols?: string; + rows?: string; +} + +export type ShellInputData = + | { $case: "resize", resize: { cols: number; rows: number } } + | { $case: "data", data: { data: string } } + | { $case: "disconnect" } + ; +export type ShellOutputData = + | { $case: "data", data: { data: string } } + | { $case: "exit", exit: { code?: number; signal?: string } } + ; +export const config = { + api: { + bodyParser: false, + }, +}; + + +const wss = new WebSocketServer({ noServer: true }); + +// https://github.com/websockets/ws#how-to-detect-and-close-broken-connections +type AliveCheckedWebSocket = WebSocket & { isAlive: boolean }; + +function heartbeat(this: AliveCheckedWebSocket) { + this.isAlive = true; +} + +function isAliveCheckedWebSocket(ws: WebSocket): ws is AliveCheckedWebSocket { + return "isAlive" in ws; +} + +// ping every clients every 30s +const pingInterval = setInterval(function ping() { + wss.clients.forEach(function each(ws) { + if (!isAliveCheckedWebSocket(ws)) { + console.warn("WebSocket has not been extended to AliveCheckedWebSocket."); + return; + } + + if (ws.isAlive === false) { + return ws.terminate(); + } + + ws.isAlive = false; + ws.ping(); + }); +}, 30000); + +wss.on("close", function close() { + clearInterval(pingInterval); +}); + +wss.on("connection", async (ws: AliveCheckedWebSocket, req) => { + + const token = getUserToken(req); + + if (!token) { + console.log("[shell] token is not valid"); + ws.close(0, "token is not valid"); + return; + } + + const userInfo = await validateToken(token); + + if (!userInfo) { + console.log("[shell] userInfo is not valid"); + ws.close(0, "userInfo is not valid"); + return; + } + + const log = (message: string, ...optionalParams: any[]) => console.log( + `[io] [${userInfo.identityId}] ${message}`, optionalParams); + + log("Connection request received."); + + const fullUrl = "http://example.com" + req.url; + const query = new URL(fullUrl).searchParams; + + const clusterId = query.get("cluster"); + const jobId = query.get("jobId"); + + if (!jobId) { + log("[params] param-jobId not passed"); + ws.close(0, "param-jobId not passed"); + return; + } + + if (!clusterId || !clusters[clusterId]) { + log("[params] param-clusterId not passed or unknown"); + ws.close(0, "param-clusterId not passed or unknown"); + return; + } + + if (!clusters[clusterId].k8s?.kubeconfig.path) { + log("[config] The current cluster does not have kubeconfig configured."); + ws.close(0, "The current cluster does not have kubeconfig configured."); + return; + } + + // 根据jobId获取该应用运行在集群的节点和对应的containerId + const client = getAdapterClient(clusterId); + + const runningJobsInfo = await asyncClientCall(client.job, "getJobs", { + fields: ["job_id"], + filter: { + users: [userInfo.identityId], accounts: [], + states: ["RUNNING"], + }, + }).then((resp) => resp.jobs); + + const currentJobInfo = runningJobsInfo.find((jobInfo) => String(jobInfo.jobId) === jobId); + + if (!currentJobInfo) { + log(`[shell] Get running job node info failed, can't find job ${jobId}`); + ws.close(0, `Get running job node info failed, can't find job ${jobId}`); + return; + } + + ws.isAlive = true; + ws.on("pong", () => { + // 使用箭头函数确保this上下文为AliveCheckedWebSocket + heartbeat.call(ws as AliveCheckedWebSocket); + }); + + ws.ping(); + + const send = (data: ShellOutputData) => { + ws.send(JSON.stringify(data)); + }; + + // 创建PassThrough流作为stdin, stdout, stderr + const stdinStream = new PassThrough(); + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + + // 将Kubernetes stdout和stderr的输出发送回WebSocket客户端 + stdoutStream.on("data", (data) => { + send({ $case: "data", data: { data: data.toString() } }); + }); + + stderrStream.on("data", (data) => { + send({ $case: "data", data: { data: data.toString() } }); + }); + + ws.on("error", async (err) => { + log("Error occurred from client. Disconnect.", err); + stdinStream.end(); // 结束stdin流输入 + }); + + const { job } = await asyncClientCall(client.job, "getJobById", { + fields: ["namespace", "pod"], + jobId: currentJobInfo.jobId, + }); + + if (!job) { + log("[shell] Can not find this running job, please check it."); + ws.close(0, "Can not find this running job, please check it."); + return; + } + + const { namespace, pod } = job; + + if (!namespace || !pod) { + 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; + } + + try { + 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); + + log("Connected to shell"); + + // 监听来自客户端WebSocket的消息并写入stdinStream + ws.on("message", (data) => { + const message = JSON.parse(data.toString()); + + switch (message.$case) { + case "data": + stdinStream.write(message.data.data); + break; + case "resize": + stdinStream.write( + `stty cols ${message.resize.cols} rows ${message.resize.rows}\n`); + break; + case "disconnect": + stdinStream.end(); + break; + } + }); + + ws.on("close", () => { + // 关闭相关流,以确保Kubernetes端的命令执行可以正确结束 + stdinStream.end(); + stdoutStream.end(); + stderrStream.end(); + k8sWs.close(); + }); + } catch (error) { + console.error("Error executing command in Kubernetes", error); + ws.close(); + } +}); + +export const setupJobShellServer = (req: NextApiRequest) => { + + (req.socket as any).server.on("upgrade", async (req: IncomingMessage, + socket: any, head: any) => { + const url = normalizePathnameWithQuery(req.url!); + if (!url.startsWith(join(BASE_PATH, "/api/jobShell"))) { + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + // 动态地为 WebSocket 实例添加 isAlive 属性 + const extendedWs = ws as AliveCheckedWebSocket; + extendedWs.isAlive = true; + + wss.emit("connection", extendedWs, req); + }); + + }); +}; + diff --git a/apps/ai/src/server/trpc/route/config.ts b/apps/ai/src/server/trpc/route/config.ts index 36c79c13be..4b0d2fc190 100644 --- a/apps/ai/src/server/trpc/route/config.ts +++ b/apps/ai/src/server/trpc/route/config.ts @@ -99,6 +99,7 @@ const PublicConfigSchema = z.object({ }), SYSTEM_LANGUAGE_CONFIG: SystemLanguageConfigSchema, LOGIN_NODES: z.record(z.string()), + NOVNC_CLIENT_URL: z.string(), }); const UiConfigSchema = z.object({ @@ -192,6 +193,9 @@ export const config = router({ SYSTEM_LANGUAGE_CONFIG: systemLanguageConfig, LOGIN_NODES: parseKeyValue(envConfig.LOGIN_NODES), + + NOVNC_CLIENT_URL: envConfig.NOVNC_CLIENT_URL, + }; }), diff --git a/apps/ai/src/server/trpc/route/jobs/apps.ts b/apps/ai/src/server/trpc/route/jobs/apps.ts index 072fc091bf..72bd28f267 100644 --- a/apps/ai/src/server/trpc/route/jobs/apps.ts +++ b/apps/ai/src/server/trpc/route/jobs/apps.ts @@ -33,7 +33,7 @@ import { JobType } from "src/models/Job"; import { aiConfig } from "src/server/config/ai"; import { Image as ImageEntity, Source, Status } from "src/server/entities/Image"; import { procedure } from "src/server/trpc/procedure/base"; -import { checkAppExist, checkCreateAppEntity, getClusterAppConfigs } from "src/server/utils/app"; +import { checkAppExist, checkCreateAppEntity, 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"; @@ -96,6 +96,7 @@ interface SessionMetadata { } const SERVER_ENTRY_COMMAND = fs.readFileSync("assets/app/server_entry.sh", { encoding: "utf-8" }); +const VNC_ENTRY_COMMAND = fs.readFileSync("assets/app/vnc_entry.sh", { encoding: "utf-8" }); const SESSION_METADATA_NAME = "session.json"; @@ -234,12 +235,16 @@ export const createAppSession = procedure clusterId: z.string(), appId: z.string(), appJobName: z.string(), + isAlgorithmPrivate: z.boolean().optional(), algorithm: z.number().optional(), image: z.number().optional(), + remoteImageUrl: z.string().optional(), startCommand: z.string().optional(), + isDatasetPrivate: z.boolean().optional(), dataset: z.number().optional(), + isModelPrivate: z.boolean().optional(), model: z.number().optional(), - mountPoint: z.string().optional(), + mountPoints: z.array(z.string()).optional(), account: z.string(), partition: z.string().optional(), coreCount: z.number(), @@ -254,8 +259,9 @@ export const createAppSession = procedure jobId: z.number(), })) .mutation(async ({ input, ctx: { user } }) => { - const { clusterId, appId, appJobName, algorithm, image, startCommand, - dataset, model, mountPoint, account, partition, coreCount, nodeCount, gpuCount, memory, + const { clusterId, appId, appJobName, isAlgorithmPrivate, algorithm, + image, startCommand, remoteImageUrl, isDatasetPrivate, dataset, isModelPrivate, + model, mountPoints = [], account, partition, coreCount, nodeCount, gpuCount, memory, maxTime, workingDirectory, customAttributes } = input; const apps = getClusterAppConfigs(clusterId); @@ -332,20 +338,37 @@ export const createAppSession = procedure const userId = user.identityId; return await sshConnect(host, userId, logger, async (ssh) => { - const homeDir = await getUserHomedir(ssh, userId, logger); // 工作目录和挂载点必须在用户的homeDir下 - if ((workingDirectory && !isParentOrSameFolder(homeDir, workingDirectory)) - || (mountPoint && !isParentOrSameFolder(homeDir, mountPoint))) { + if ((workingDirectory && !isParentOrSameFolder(homeDir, workingDirectory))) { throw new TRPCError({ code: "BAD_REQUEST", message: "workingDirectory and mountPoint should be in homeDir", }); } + + mountPoints.forEach((mountPoint) => { + if (mountPoint && !isParentOrSameFolder(homeDir, mountPoint)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "mountPoint should be in homeDir", + }); + } + }); const appJobsDirectory = join(aiConfig.appJobsDir, appJobName); + // 确保所有映射到容器的路径都不重复 + validateUniquePaths([ + workingDirectory ?? join(homeDir, appJobsDirectory), + isAlgorithmPrivate ? algorithmVersion?.privatePath : algorithmVersion?.path, + isDatasetPrivate ? datasetVersion?.privatePath : datasetVersion?.path, + isModelPrivate ? modelVersion?.privatePath : modelVersion?.path, + ...mountPoints, + ]); + + // make sure appJobsDirectory exists. await ssh.mkdir(appJobsDirectory); const sftp = await ssh.requestSFTP(); @@ -360,75 +383,110 @@ export const createAppSession = procedure customAttributesExport = customAttributesExport + envItem + "\n"; } + // SVCPORT 是k8s集群中service的端口, 由适配器提供 + let customForm = String.raw`\"HOST\":\"$HOST\",\"PORT\":\"$SVCPORT\"`; if (app.type === "web") { - const runtimeVariables = getEnvVariables({ - PROXY_BASE_PATH: join(proxyBasePath, app.web!.proxyType), - SERVER_SESSION_INFO, - }); - // SVCPORT 是k8s集群中service的端口, 由适配器提供 - let customForm = String.raw`\"HOST\":\"$HOST\",\"PORT\":$SVCPORT`; for (const key in app.web!.connect.formData) { const texts = getPlaceholderKeys(app.web!.connect.formData[key]); for (const i in texts) { customForm += `,\\\"${texts[i]}\\\":\\\"$${texts[i]}\\\"`; } } - const sessionInfo = `echo -e "{${customForm}}" >$SERVER_SESSION_INFO\n`; + } + const sessionInfo = `echo -e "{${customForm}}" >$SERVER_SESSION_INFO\n`; + let entryScript = ""; + if (app.type === "web") { + const runtimeVariables = getEnvVariables({ + PROXY_BASE_PATH: join(proxyBasePath, app.web!.proxyType), + SERVER_SESSION_INFO, + }); const beforeScript = runtimeVariables + customAttributesExport + app.web!.beforeScript + sessionInfo; // 用户如果传了自定义的启动命令,则根据配置文件去替换默认的启动命令 const webScript = startCommand ? app.web!.script.replace(app.web!.startCommand, startCommand) : app.web!.script; - const entryScript = SERVER_ENTRY_COMMAND + beforeScript + webScript; - - // 将entry.sh写入后将路径传给适配器后启动容器 - await sftpWriteFile(sftp)(remoteEntryPath, entryScript); - const client = getAdapterClient(clusterId); - const reply = await asyncClientCall(client.job, "submitJob", { - userId, - jobName: appJobName, - image: existImage ? existImage.path : `${app.image.name}:${app.image.tag || "latest"}`, - algorithm: algorithmVersion?.path, - dataset: datasetVersion?.path, - model: modelVersion?.path, - mountPoint, - account, - partition: partition!, - coreCount, - nodeCount, - gpuCount: gpuCount ?? 0, - memoryMb: memory, - timeLimitMinutes: maxTime, - // 用户指定应用工作目录,如果不存在,则默认为用户的appJobsDirectory - workingDirectory: workingDirectory ?? join(homeDir, appJobsDirectory), - script: remoteEntryPath, - // 约定第一个参数确定是创建应用or训练任务,第二个参数为创建应用时的appId - extraOptions: [JobType.APP, "web"], - }).catch((e) => { - const ex = e as ServiceError; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `submit job failed, ${ex.details}`, - }); + entryScript = SERVER_ENTRY_COMMAND + beforeScript + webScript; + } else if (app.type === "vnc") { + const runtimeVariables = getEnvVariables({ + SERVER_SESSION_INFO, }); + // 对于vnc 的自定义镜像应用,用户需要传对应的运行镜像中启动脚本的命令 + const xstartupScript = startCommand || app.vnc!.xstartup; + const beforeScript = app.vnc!.beforeScript || ""; + + entryScript = VNC_ENTRY_COMMAND + runtimeVariables + customAttributesExport + beforeScript + + sessionInfo + xstartupScript; - const metadata: SessionMetadata = { - jobId: reply.jobId, - sessionId: appJobName, - submitTime: new Date().toISOString(), - appId, - image: existImage ? { name: existImage.name, tag: existImage.tag } : app.image, - jobType: JobType.APP, - }; - await sftpWriteFile(sftp)(join(appJobsDirectory, SESSION_METADATA_NAME), JSON.stringify(metadata)); - - return { jobId: reply.jobId }; } else { - // TODO: if vnc apps throw new TRPCError({ code: "NOT_FOUND", message: `Unknown app type ${app.type} of app id ${appId}`, }); } + + // 将entry.sh写入后将路径传给适配器后启动容器 + await sftpWriteFile(sftp)(remoteEntryPath, entryScript); + const client = getAdapterClient(clusterId); + const reply = await asyncClientCall(client.job, "submitJob", { + userId, + jobName: appJobName, + account, + partition: partition!, + coreCount, + nodeCount, + gpuCount: gpuCount ?? 0, + memoryMb: memory, + timeLimitMinutes: maxTime, + // 用户指定应用工作目录,如果不存在,则默认为用户的appJobsDirectory + workingDirectory: workingDirectory ?? join(homeDir, appJobsDirectory), + script: remoteEntryPath, + // 对于AI模块,需要传递的额外参数 + // 第一个参数确定是创建应用or训练任务, + // 第二个参数为创建应用时的appId + // 第三个参数为镜像地址 + // 第四个参数为算法版本地址 + // 第五个参数为数据集版本地址 + // 第六个参数为模型版本地址 + // 第七个参数为多挂载点地址,以逗号分隔 + extraOptions: [ + JobType.APP, + app.type, + // 优先用户填写的远程镜像地址 + (remoteImageUrl || (existImage ? existImage.path : `${app.image.name}:${app.image.tag || "latest"}`)) || "", + algorithmVersion + ? isAlgorithmPrivate + ? algorithmVersion.privatePath + : algorithmVersion.path + : "", + datasetVersion + ? isDatasetPrivate + ? datasetVersion.privatePath + : datasetVersion.path + : "", + modelVersion + ? isModelPrivate + ? modelVersion.privatePath + : modelVersion.path + : "", + mountPoints.join(","), + ], + }).catch((e) => { + const ex = e as ServiceError; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `submit job failed, ${ex.details}`, + }); + }); + + const metadata: SessionMetadata = { + jobId: reply.jobId, + sessionId: appJobName, + submitTime: new Date().toISOString(), + appId, + image: existImage ? { name: existImage.name, tag: existImage.tag } : app.image, + jobType: JobType.APP, + }; + await sftpWriteFile(sftp)(join(appJobsDirectory, SESSION_METADATA_NAME), JSON.stringify(metadata)); + return { jobId: reply.jobId }; }); }); @@ -469,10 +527,27 @@ export const saveImage = // 根据jobId获取该应用运行在集群的节点和对应的containerId const client = getAdapterClient(clusterId); - const { node, containerId } = await asyncClientCall(client.app, "getRunningJobNodeInfo", { + const { job } = await asyncClientCall(client.job, "getJobById", { + fields: ["node", "container_id"], jobId, }); + if (!job) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Can not find this running job", + }); + } + + const { node, containerId } = job; + + if (!node || !containerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Can not find node or containerId of this running job", + }); + } + const formateContainerId = formatContainerId(clusterId, containerId); // 连接到该节点 @@ -813,6 +888,15 @@ procedure } switch (app.type) { + case AppType.vnc: + return { + host: reply.host, + port: reply.port, + password: reply.password, + type: "vnc", + vnc: {}, + }; + break; case AppType.web: return { host: reply.host, diff --git a/apps/ai/src/server/trpc/route/jobs/jobs.ts b/apps/ai/src/server/trpc/route/jobs/jobs.ts index f640064eff..8720f63b95 100644 --- a/apps/ai/src/server/trpc/route/jobs/jobs.ts +++ b/apps/ai/src/server/trpc/route/jobs/jobs.ts @@ -21,7 +21,7 @@ import { join } from "path"; import { JobType } from "src/models/Job"; import { aiConfig } from "src/server/config/ai"; import { procedure } from "src/server/trpc/procedure/base"; -import { checkCreateAppEntity } from "src/server/utils/app"; +import { checkCreateAppEntity, 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"; @@ -61,11 +61,15 @@ procedure .input(z.object({ clusterId: z.string(), trainJobName: z.string(), + isAlgorithmPrivate: z.boolean().optional(), algorithm: z.number().optional(), - imageId: z.number(), + imageId: z.number().optional(), + remoteImageUrl: z.string().optional(), + isDatasetPrivate: z.boolean().optional(), dataset: z.number().optional(), + isModelPrivate: z.boolean().optional(), model: z.number().optional(), - mountPoint: z.string().optional(), + mountPoints: z.array(z.string()).optional(), account: z.string(), partition: z.string().optional(), coreCount: z.number(), @@ -80,7 +84,8 @@ procedure })).mutation( async ({ input, ctx: { user } }) => { - const { clusterId, trainJobName, algorithm, imageId, dataset, model, mountPoint, account, partition, + const { clusterId, trainJobName, isAlgorithmPrivate, algorithm, imageId, remoteImageUrl, + isDatasetPrivate, dataset, isModelPrivate, model, mountPoints = [], account, partition, coreCount, nodeCount, gpuCount, memory, maxTime, command } = input; const userId = user.identityId; @@ -107,15 +112,26 @@ procedure const homeDir = await getUserHomedir(ssh, userId, logger); - if (mountPoint && !isParentOrSameFolder(homeDir, mountPoint)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "mountPoint should be in homeDir", - }); - } + mountPoints.forEach((mountPoint) => { + if (mountPoint && !isParentOrSameFolder(homeDir, mountPoint)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "mountPoint should be in homeDir", + }); + } + }); const trainJobsDirectory = join(aiConfig.appJobsDir, trainJobName); + // 确保所有映射到容器的路径都不重复 + validateUniquePaths([ + trainJobsDirectory, + isAlgorithmPrivate ? algorithmVersion?.privatePath : algorithmVersion?.path, + isDatasetPrivate ? datasetVersion?.privatePath : datasetVersion?.path, + isModelPrivate ? modelVersion?.privatePath : modelVersion?.path, + ...mountPoints, + ]); + // make sure trainJobsDirectory exists. await ssh.mkdir(trainJobsDirectory); const sftp = await ssh.requestSFTP(); @@ -128,11 +144,6 @@ procedure const reply = await asyncClientCall(client.job, "submitJob", { userId, jobName: trainJobName, - algorithm: algorithmVersion?.path, - image: image!.path, - dataset: datasetVersion?.path, - model: modelVersion?.path, - mountPoint, account, partition: partition!, coreCount, @@ -142,8 +153,35 @@ procedure timeLimitMinutes: maxTime, workingDirectory: trainJobsDirectory, script: remoteEntryPath, - // 约定第一个参数确定是创建应用or训练任务,第二个参数为创建应用时的appId - extraOptions: [JobType.TRAIN], + // 对于AI模块,需要传递的额外参数 + // 第一个参数确定是创建应用or训练任务, + // 第二个参数为创建应用时的appId + // 第三个参数为镜像地址 + // 第四个参数为算法版本地址 + // 第五个参数为数据集版本地址 + // 第六个参数为模型版本地址 + // 第七个参数为多挂载点地址,以逗号分隔 + extraOptions: [ + JobType.TRAIN, + "", + remoteImageUrl || image?.path || "", + algorithmVersion + ? isAlgorithmPrivate + ? algorithmVersion.privatePath + : algorithmVersion.path + : "", + datasetVersion + ? isDatasetPrivate + ? datasetVersion.privatePath + : datasetVersion.path + : "", + modelVersion + ? isModelPrivate + ? modelVersion.privatePath + : modelVersion.path + : "", + mountPoints.join(","), + ], }).catch((e) => { const ex = e as ServiceError; throw new TRPCError({ diff --git a/apps/ai/src/server/utils/app.ts b/apps/ai/src/server/utils/app.ts index a713ad63ac..11b2d9a171 100644 --- a/apps/ai/src/server/utils/app.ts +++ b/apps/ai/src/server/utils/app.ts @@ -130,3 +130,23 @@ export const checkAppExist = (apps: Record, appId: stri } return app; }; + + +export const validateUniquePaths = (paths: (string | undefined)[]) => { + + // 移除尾随斜杠并返回规范化的路径 + const normalizedPaths = paths.map((path) => path && path.replace(/\/+$/, "")); + const pathSet = new Set(); + + for (const path of normalizedPaths) { + if (path && pathSet.has(path)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `路径 '${path}' 重复,请确保所有路径都是唯一的。`, + }); + } + if (path) { + pathSet.add(path); + } + } +}; diff --git a/apps/ai/src/utils/vnc.ts b/apps/ai/src/utils/vnc.ts index b9c78f08d4..970d43e258 100644 --- a/apps/ai/src/utils/vnc.ts +++ b/apps/ai/src/utils/vnc.ts @@ -41,13 +41,11 @@ export function joinWithUrl(base: string, ...paths: string[]) { } -export const openDesktop = ( - resourceId: number, novncClientUrl: string, clusterId: string, - node: string, port: number, password: string, -) => { +export const openDesktop = (basePath: string, + novncClientUrl: string, clusterId: string, node: string, port: number, password: string) => { const params = new URLSearchParams({ - path: join(PROXY, resourceId.toString(), clusterId, "absolute", node, String(port)), + path: join(basePath, PROXY, clusterId, "absolute", node, String(port)), host: location.hostname, port: location.port, password: password, diff --git a/apps/cli/src/compose/index.ts b/apps/cli/src/compose/index.ts index 5766ead399..d7d5e6941a 100644 --- a/apps/cli/src/compose/index.ts +++ b/apps/cli/src/compose/index.ts @@ -402,6 +402,7 @@ export const createComposeSpec = (config: InstallConfigSchema) => { "AUDIT_DEPLOYED": config.audit ? "true" : "false", "CLIENT_MAX_BODY_SIZE": config.gateway.uploadFileSizeLimit, "PROTOCOL": config.gateway.protocol, + "NOVNC_CLIENT_URL": join(BASE_PATH, "/vnc"), ...serviceLogEnv, ...nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}, }, diff --git a/dev/test-adapter/src/services/app.ts b/dev/test-adapter/src/services/app.ts index ea6e21da3a..349a3f53de 100644 --- a/dev/test-adapter/src/services/app.ts +++ b/dev/test-adapter/src/services/app.ts @@ -19,12 +19,5 @@ export const appServiceServer = plugin((server) => { return [{}]; }, - getRunningJobNodeInfo: async () => { - return [{ - node: "node1", - containerId: "docker://container1", - }]; - }, - }); }); diff --git a/dev/vagrant/config/ai/apps/jupyter.yaml b/dev/vagrant/config/ai/apps/jupyter.yaml index 240f68805b..67f9421817 100644 --- a/dev/vagrant/config/ai/apps/jupyter.yaml +++ b/dev/vagrant/config/ai/apps/jupyter.yaml @@ -38,6 +38,6 @@ web: attributes: - type: text name: workingDir - label: 指定jupyter工作目录 + label: jupyter工作目录 required: true placeholder: "请填写绝对路径" diff --git a/dev/vagrant/config/ai/apps/pycharm.yaml b/dev/vagrant/config/ai/apps/pycharm.yaml new file mode 100644 index 0000000000..6c68a8c93b --- /dev/null +++ b/dev/vagrant/config/ai/apps/pycharm.yaml @@ -0,0 +1,22 @@ +# 这个应用的ID +id: pycharm + +# 这个应用的名字 +name: pycharm + +# 这个应用的图标文件在公共文件下的路径 +# logoPath: /test.svg + +# 指定应用类型,vnc或者web +type: vnc +image: + # 镜像名称 + name: 10.129.227.64/test/admin/pycharm + # 镜像版本 + tag: v1.1 + +# VNC应用的配置 +vnc: + # 此X Session的xstartup脚本 + xstartup: | + /dockerstartup/vnc_startup.sh pycharm diff --git a/docs/docs/deploy/config/ai/apps/configure-vnc-app.md b/docs/docs/deploy/config/ai/apps/configure-vnc-app.md new file mode 100644 index 0000000000..47963b9a0e --- /dev/null +++ b/docs/docs/deploy/config/ai/apps/configure-vnc-app.md @@ -0,0 +1,67 @@ +--- +sidebar_position: 3 +title: 配置桌面类应用 +--- + + +## 前提条件 + +镜像要求: +- 安装有VNC(TigerVNC or TurboVNC) +- 安装对应的应用 +- 相应的脚本启动VNC服务以及桌面应用 +- 确保vnc连接的密码保存在`~/.vnc/passwd`文件中,应用在连接时,会给每次连接生成一个新的密码,生成的位置为`~/.vnc/passwd`。 + +请确保计算节点可以拉取或者已经存在配置中应用的镜像。 + + +## 配置示例 + +下面以使用pycharm为示例介绍如何配置桌面类应用。 + +创建config/ai/apps目录,在里面创建pycharm/config.yml或vscode.yml文件,其内容如下: + +```yaml title="config/ai/apps/pycharm/config.yml" +# 这个应用的ID +id: pycharm + +# 这个应用的名字 +name: pycharm + +# 这个应用的图标文件在公共文件下的路径 +logoPath: /test.svg + +type: vnc +image: + # 镜像名称 + name: 10.129.227.64/test/admin/pycharm + # 镜像版本 + tag: v1.1 + +# VNC应用的配置 +vnc: + # 此X Session的xstartup脚本 + xstartup: | + /dockerstartup/vnc_startup.sh pycharm + +``` + +增加了此文件后,刷新即可。 + +## 配置解释 + +### `logoPath` + +[参考门户系统](../../portal/apps/configure-app-logo.md) + +### `image` + +该镜像会被用来启动应用,`name`和`tag`分别指定镜像的名称和版本。如果本地没有该镜像,将会尝试从镜像仓库拉取。 + +### `beforeScript` + +[参考门户系统](../../portal/apps/configure-vnc-app.md#beforescript) + +### `xstartup` + +此处应该填写启动镜像时,vnc服务启动时的xstartup脚本,脚本中应该包含启动桌面应用的命令。 diff --git a/docs/docs/deploy/config/ai/apps/intro.md b/docs/docs/deploy/config/ai/apps/intro.md index f20d356e95..309adc1d65 100644 --- a/docs/docs/deploy/config/ai/apps/intro.md +++ b/docs/docs/deploy/config/ai/apps/intro.md @@ -5,9 +5,10 @@ title: 交互式作业 # 交互式作业 -参考[文档](../../../../info/portal/app.md)简要了解交互式作业功能, 目前beta版本暂时只提供Web类应用功能。 +参考[文档](../../../../info/portal/app.md)简要了解交互式作业功能, 目前beta版本支持Web类和Vnc类应用功能。 - [配置Web类应用](./configure-web-app.md) +- [配置VNC类应用](./configure-vnc-app.md) - 已有交互式应用 - [JupyterLab](./apps/jupyterlab/index.md) - [VSCode](./apps/vscode/index.md) diff --git a/docs/docs/deploy/config/ai/intro.md b/docs/docs/deploy/config/ai/intro.md index aca9809e88..b9cbfd8f81 100644 --- a/docs/docs/deploy/config/ai/intro.md +++ b/docs/docs/deploy/config/ai/intro.md @@ -82,10 +82,38 @@ imageTag: ai-beta.1 ## 配置文件 -### 确认集群配置文件 +### 集群配置文件 -在当前 **AI 系统(beta)** 的试用版本中,我们暂时没有分离不同的集群系统下的 AI 服务,请您注意在部署了 **K8S** 集群的集群上进行功能体验。 -后续将会陆续实现其他服务,并实现不同集群上不同服务的分离。 +在当前 **AI 系统(beta)** 的试用版本中,我们支持了配置不同集群使用不同的服务(AI 或 HPC),需要在`config/clusters/{K8S集群的ID}.yml`中,添加如下内容 + +```yaml title="config/clusters/{K8S集群的ID}.yml" +# 其他配置省略 +# ... +# 集群在HPC或是否启用,默认为true +hpc: + enabled: true + +# 集群在AI或是否启用,默认为false +ai: + enabled: false +``` + +此外我们支持了不同容器运行时,并提供了进入运行中的 k8s 作业容器的进行 shell 操作的功能。 + +为了能够在 Kubernetes 集群中通过 kubectl 进入到所有命名空间的容器中执行命令(例如 /bin/sh),需要提供一份 kubeconfig 配置文件。该配置文件的 current context 中的用户需要使用 ClusterRole 创建并具备一定的权限,这些权限包括对 pods/exec 的 create 操作,以及对 pods 的 get 和 list 操作。创建完成后,需要将 kubeconfig 文件放置到 SCOW 部署目录中的 config 目录下,然后在`config/clusters/{K8S集群的ID}.yml`中,添加如下内容 + +```yaml title="config/clusters/{K8S集群的ID}.yml" +# 其他配置省略 +# ... +k8s: + # runtime: docker + # 默认为 containerd + runtime: containerd + # kubeconfig 相关配置 + kubeconfig: + # 相对于 SCOW 部署目录下 config 目录的路径 + path: /kube/xxx +``` 请在部署了 **K8S** 集群的集群配置文件中确认以下内容: diff --git a/libs/config/src/appForAi.ts b/libs/config/src/appForAi.ts index 9a75b61cc4..043f32b705 100644 --- a/libs/config/src/appForAi.ts +++ b/libs/config/src/appForAi.ts @@ -18,6 +18,7 @@ import { createI18nStringSchema } from "src/i18n"; export enum AppType { web = "web", + vnc = "vnc" } export const AppConnectPropsSchema = Type.Object({ @@ -44,6 +45,13 @@ export const WebAppConfigSchema = Type.Object({ export type WebAppConfigSchema = Static; +export const VncAppConfigSchema = Type.Object({ + beforeScript: Type.Optional(Type.String({ description: "启动应用之前的准备命令。具体参考文档" })), + + xstartup: Type.String({ description: "启动此app的xstartup脚本" }), +}); + +export type VncAppConfigSchema = Static; export const AppConfigSchema = Type.Object({ name: Type.String({ description: "App名" }), @@ -54,6 +62,7 @@ export const AppConfigSchema = Type.Object({ }), type: Type.Enum(AppType, { description: "应用类型" }), web: Type.Optional(WebAppConfigSchema), + vnc: Type.Optional(VncAppConfigSchema), attributes: Type.Optional(Type.Array( Type.Object({ type: Type.Enum({ number: "number", text: "text", select: "select" }, { description: "表单类型" }), diff --git a/libs/config/src/cluster.ts b/libs/config/src/cluster.ts index 881a7a17f5..0a6d2a4057 100644 --- a/libs/config/src/cluster.ts +++ b/libs/config/src/cluster.ts @@ -113,6 +113,9 @@ export const ClusterConfigSchema = Type.Object({ k8s: Type.Optional(Type.Object({ runtime: Type.Enum(k8sRuntime, { description: "k8s 集群运行时, ai系统的镜像功能的命令取决于该值, 可选 docker 或者 containerd", default: "containerd" }), + kubeconfig: Type.Object({ + path: Type.String({ description: "集群 kubeconfig 文件路径" }), + }, { description: "k8s 集群 kubeconfig 相关配置" }), }, { description: "k8s 集群配置" })), }); diff --git a/libs/protos/scheduler-adapter/package.json b/libs/protos/scheduler-adapter/package.json index e9c61c0099..6c37bac3cf 100644 --- a/libs/protos/scheduler-adapter/package.json +++ b/libs/protos/scheduler-adapter/package.json @@ -5,7 +5,7 @@ "main": "build/index.js", "private": true, "scripts": { - "generate": "rimraf generated && buf generate --template buf.gen.yaml https://github.com/PKUHPC/scow-scheduler-adapter-interface.git#branch=scow-ai-test-job", + "generate": "rimraf generated && buf generate --template buf.gen.yaml https://github.com/PKUHPC/scow-scheduler-adapter-interface.git#branch=feat-ai-release", "build": "rimraf build && tsc" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9b332b8ad..852bbd9c90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,9 @@ importers: '@grpc/grpc-js': specifier: 1.10.6 version: 1.10.6 + '@kubernetes/client-node': + specifier: ^0.20.0 + version: 0.20.0 '@mikro-orm/cli': specifier: 6.1.12 version: 6.1.12 @@ -211,6 +214,12 @@ importers: '@trpc/server': specifier: 10.45.2 version: 10.45.2 + '@xterm/addon-fit': + specifier: 0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: 5.5.0 + version: 5.5.0 antd: specifier: 5.16.0 version: 5.16.0(react-dom@18.2.0)(react@18.2.0) @@ -298,12 +307,6 @@ importers: ws: specifier: 8.16.0 version: 8.16.0 - xterm: - specifier: 5.3.0 - version: 5.3.0 - xterm-addon-fit: - specifier: 0.8.0 - version: 0.8.0(xterm@5.3.0) zod: specifier: 3.22.4 version: 3.22.4 @@ -5182,6 +5185,30 @@ packages: /@js-sdsl/ordered-map@4.4.2: resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + /@kubernetes/client-node@0.20.0: + resolution: {integrity: sha512-xxlv5GLX4FVR/dDKEsmi4SPeuB49aRc35stndyxcC73XnUEEwF39vXbROpHOirmDse8WE9vxOjABnSVS+jb7EA==} + dependencies: + '@types/js-yaml': 4.0.9 + '@types/node': 20.12.4 + '@types/request': 2.48.12 + '@types/ws': 8.5.10 + byline: 5.0.0 + isomorphic-ws: 5.0.0(ws@8.16.0) + js-yaml: 4.1.0 + jsonpath-plus: 7.2.0 + request: 2.88.2 + rfc4648: 1.5.3 + stream-buffers: 3.0.2 + tar: 6.2.1 + tslib: 2.6.2 + ws: 8.16.0 + optionalDependencies: + openid-client: 5.6.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + /@leichtgewicht/ip-codec@2.0.4: resolution: {integrity: sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==} dev: false @@ -7178,6 +7205,10 @@ packages: '@types/node': 20.12.4 dev: true + /@types/caseless@0.12.5: + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + dev: false + /@types/connect-history-api-fallback@1.3.5: resolution: {integrity: sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw==} dependencies: @@ -7357,7 +7388,6 @@ packages: /@types/js-yaml@4.0.9: resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - dev: true /@types/jsdom@20.0.1: resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} @@ -7527,6 +7557,15 @@ packages: '@types/prop-types': 15.7.5 csstype: 3.1.3 + /@types/request@2.48.12: + resolution: {integrity: sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==} + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 20.12.4 + '@types/tough-cookie': 4.0.2 + form-data: 2.5.1 + dev: false + /@types/retry@0.12.0: resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} dev: false @@ -7613,7 +7652,6 @@ packages: /@types/tough-cookie@4.0.2: resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==} - dev: true /@types/unist@2.0.6: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} @@ -7949,6 +7987,18 @@ packages: '@webassemblyjs/ast': 1.12.1 '@xtuc/long': 4.2.2 + /@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0): + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + dependencies: + '@xterm/xterm': 5.5.0 + dev: false + + /@xterm/xterm@5.5.0: + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + dev: false + /@xtuc/ieee754@1.2.0: resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} @@ -8527,6 +8577,14 @@ packages: - supports-color dev: false + /aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + dev: false + + /aws4@1.12.0: + resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} + dev: false + /axios@1.6.2: resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==} dependencies: @@ -8899,6 +8957,11 @@ packages: dependencies: streamsearch: 1.1.0 + /byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + dev: false + /bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -8998,6 +9061,10 @@ packages: engines: {node: '>=12.13'} dev: true + /caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + dev: false + /ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -9111,6 +9178,11 @@ packages: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} dev: false + /chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + dev: false + /chrome-trace-event@1.0.3: resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} engines: {node: '>=6.0'} @@ -10060,6 +10132,13 @@ packages: engines: {node: '>=12'} dev: false + /dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + dependencies: + assert-plus: 1.0.0 + dev: false + /data-uri-to-buffer@3.0.1: resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} engines: {node: '>= 6'} @@ -10573,6 +10652,13 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + /ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + dev: false + /ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} dependencies: @@ -11267,6 +11353,11 @@ packages: - debug dev: true + /extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + dev: false + /extsprintf@1.4.1: resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} engines: {'0': node >=0.6.0} @@ -11624,6 +11715,10 @@ packages: cross-spawn: 7.0.3 signal-exit: 4.0.2 + /forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + dev: false + /fork-ts-checker-webpack-plugin@6.5.2(eslint@8.57.0)(typescript@5.4.3)(webpack@5.91.0): resolution: {integrity: sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA==} engines: {node: '>=10', yarn: '>=1.0.0'} @@ -11661,6 +11756,24 @@ packages: engines: {node: '>= 14.17'} dev: false + /form-data@2.3.3: + resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} + engines: {node: '>= 0.12'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + + /form-data@2.5.1: + resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==} + engines: {node: '>= 0.12'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: false + /form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -11739,6 +11852,13 @@ packages: jsonfile: 6.1.0 universalify: 2.0.0 + /fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + dev: false + /fs-monkey@1.0.3: resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==} dev: false @@ -11853,6 +11973,12 @@ packages: resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} dev: false + /getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + dependencies: + assert-plus: 1.0.0 + dev: false + /git-node-fs@1.0.0(js-git@0.7.8): resolution: {integrity: sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==} peerDependencies: @@ -12108,6 +12234,20 @@ packages: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} dev: false + /har-schema@2.0.0: + resolution: {integrity: sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==} + engines: {node: '>=4'} + dev: false + + /har-validator@5.1.5: + resolution: {integrity: sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==} + engines: {node: '>=6'} + deprecated: this library is no longer supported + dependencies: + ajv: 6.12.6 + har-schema: 2.0.0 + dev: false + /hard-rejection@2.1.0: resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} engines: {node: '>=6'} @@ -12500,6 +12640,15 @@ packages: - debug dev: false + /http-signature@1.2.0: + resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} + engines: {node: '>=0.8', npm: '>=1.3.7'} + dependencies: + assert-plus: 1.0.0 + jsprim: 1.4.2 + sshpk: 1.18.0 + dev: false + /http2-wrapper@2.2.1: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} @@ -13152,6 +13301,18 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + /isomorphic-ws@5.0.0(ws@8.16.0): + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + dependencies: + ws: 8.16.0 + dev: false + + /isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + dev: false + /istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} engines: {node: '>=8'} @@ -13702,6 +13863,12 @@ packages: '@sideway/formula': 3.0.1 '@sideway/pinpoint': 2.0.0 + /jose@4.15.5: + resolution: {integrity: sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==} + requiresBuild: true + dev: false + optional: true + /joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -13735,6 +13902,10 @@ packages: dependencies: argparse: 2.0.1 + /jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + dev: false + /jsdom@20.0.2: resolution: {integrity: sha512-AHWa+QO/cgRg4N+DsmHg1Y7xnz+8KU3EflM0LVDTdmrYOc1WWTSkOjtpUveQH+1Bqd5rtcVnb/DuxV/UjDO4rA==} engines: {node: '>=14'} @@ -13801,7 +13972,6 @@ packages: /json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} - dev: true /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -13818,7 +13988,6 @@ packages: /json-stringify-safe@5.0.1: resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} - dev: true /json2mq@0.2.0: resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} @@ -13854,6 +14023,11 @@ packages: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} dev: false + /jsonpath-plus@7.2.0: + resolution: {integrity: sha512-zBfiUPM5nD0YZSBT/o/fbCUlCcepMIdP0CJZxM1+KgA4f2T206f6VAg9e7mX35+KlMaIc5qXW34f3BnwJ3w+RA==} + engines: {node: '>=12.0.0'} + dev: false + /jsonwebtoken@9.0.0: resolution: {integrity: sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==} engines: {node: '>=12', npm: '>=6'} @@ -13864,6 +14038,16 @@ packages: semver: 7.5.4 dev: false + /jsprim@1.4.2: + resolution: {integrity: sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==} + engines: {node: '>=0.6.0'} + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + dev: false + /jsx-ast-utils@3.3.3: resolution: {integrity: sha512-fYQHZTZ8jSfmWZ0iyzfwiU4WDX4HpHbMCZ3gPlWYiCl3BoeOTsqKBqnTVfH2rYT7eP5c3sVbeSPHnnJOaTrWiw==} engines: {node: '>=4.0'} @@ -15067,10 +15251,25 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + /minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + dependencies: + yallist: 4.0.0 + dev: false + /minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} + /minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + dev: false + /mixme@0.5.4: resolution: {integrity: sha512-3KYa4m4Vlqx98GPdOHghxSdNtTvcP8E0kkaJ5Dlh+h2DRzF7zpuVVcA8B0QpKd11YJeP9QQ7ASkKzOeu195Wzw==} engines: {node: '>= 8.0.0'} @@ -15084,7 +15283,6 @@ packages: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true - dev: true /module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} @@ -15526,10 +15724,21 @@ packages: resolution: {integrity: sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==} dev: true + /oauth-sign@0.9.0: + resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==} + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + /object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + requiresBuild: true + dev: false + optional: true + /object-inspect@1.12.3: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} requiresBuild: true @@ -15622,6 +15831,13 @@ packages: '@octokit/types': 12.3.0 dev: false + /oidc-token-hash@5.0.3: + resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} + engines: {node: ^10.13.0 || >=12.0.0} + requiresBuild: true + dev: false + optional: true + /on-exit-leak-free@2.1.0: resolution: {integrity: sha512-VuCaZZAjReZ3vUwgOB8LxAosIurDiAW0s13rI1YwmaP++jvcxP77AWoQvenZebpCA2m8WC1/EosPYPMjnRAp/w==} @@ -15673,6 +15889,17 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + /openid-client@5.6.5: + resolution: {integrity: sha512-5P4qO9nGJzB5PI0LFlhj4Dzg3m4odt0qsJTfyEtZyOlkgpILwEioOhVVJOrS1iVH494S4Ee5OCjjg6Bf5WOj3w==} + requiresBuild: true + dependencies: + jose: 4.15.5 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.0.3 + dev: false + optional: true + /optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -16026,6 +16253,10 @@ packages: resolution: {integrity: sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==} dev: false + /performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + dev: false + /periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} dependencies: @@ -16974,7 +17205,6 @@ packages: /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} - dev: true /pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -17030,6 +17260,11 @@ packages: side-channel: 1.0.4 dev: false + /qs@6.5.3: + resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} + engines: {node: '>=0.6'} + dev: false + /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -18337,6 +18572,33 @@ packages: yargs: 17.7.2 dev: false + /request@2.88.2: + resolution: {integrity: sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==} + engines: {node: '>= 6'} + deprecated: request has been deprecated, see https://github.com/request/request/issues/3142 + dependencies: + aws-sign2: 0.7.0 + aws4: 1.12.0 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 2.3.3 + har-validator: 5.1.5 + http-signature: 1.2.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + oauth-sign: 0.9.0 + performance-now: 2.1.0 + qs: 6.5.3 + safe-buffer: 5.2.1 + tough-cookie: 2.5.0 + tunnel-agent: 0.6.0 + uuid: 3.4.0 + dev: false + /require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -18488,7 +18750,6 @@ packages: /rfc4648@1.5.3: resolution: {integrity: sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==} - dev: true /rfdc@1.3.0: resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==} @@ -19207,6 +19468,22 @@ packages: nan: 2.18.0 dev: false + /sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + dev: false + /stable@0.1.8: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' @@ -19244,6 +19521,11 @@ packages: resolution: {integrity: sha512-3H20QlwQsSm2OvAxWIYhs+j01MzzqwMwGiiO1NQaJYZgJZFPuAbf95/DiKRBSTYIJ2FeGUc+B/6mPGcWP9dO3Q==} dev: false + /stream-buffers@3.0.2: + resolution: {integrity: sha512-DQi1h8VEBA/lURbSwFtEHnSTb9s2/pwLEaFuNhXwy1Dx3Sa0lOuYT2yNUr4/j2fs8oCAMANtrZ5OrPZtyVs3MQ==} + engines: {node: '>= 0.10.0'} + dev: false + /stream-meter@1.0.4: resolution: {integrity: sha512-4sOEtrbgFotXwnEuzzsQBYEV1elAeFSO8rSGeTwabuX1RRn/kEq9JVH7I0MRBhKVRR0sJkr0M0QCH7yOLf9fhQ==} dependencies: @@ -19629,6 +19911,18 @@ packages: readable-stream: 3.6.0 dev: false + /tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + dev: false + /tarn@3.0.2: resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} engines: {node: '>=8.0.0'} @@ -19764,6 +20058,14 @@ packages: nopt: 1.0.10 dev: true + /tough-cookie@2.5.0: + resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==} + engines: {node: '>=0.8'} + dependencies: + psl: 1.9.0 + punycode: 2.1.1 + dev: false + /tough-cookie@4.1.2: resolution: {integrity: sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==} engines: {node: '>=6'} @@ -20541,7 +20843,6 @@ packages: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. hasBin: true - dev: true /uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}