diff --git a/package.json b/package.json index 37a3d5b..1f83021 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@alicloud/tea-util": "^1.4.8", "@serverless-cd/srm-aliyun-sls20201230": "0.0.4", "@serverless-devs/component-interface": "^0.0.4", + "@serverless-devs/downloads": "^0.0.7", "@serverless-devs/load-component": "*", "@serverless-devs/logger": "^0.0.5", "@serverless-devs/utils": "^0.0.15", @@ -48,6 +49,7 @@ "ali-oss": "^6.20.0", "axios": "^1.7.9", "chalk": "^4.1.2", + "fs-extra": "^11.3.3", "inquirer": "^8.2.6", "js-yaml": "^4.1.0", "lodash": "^4.17.21", diff --git a/src/impl/agentrun.ts b/src/impl/agentrun.ts index 3a40059..778b15d 100644 --- a/src/impl/agentrun.ts +++ b/src/impl/agentrun.ts @@ -8,7 +8,6 @@ import { removeCustomDomain, infoCustomDomain, } from "./custom_domain"; -import * as $OpenApi from "@alicloud/openapi-client"; import { parseArgv, getRootHome } from "@serverless-devs/utils"; import * as _ from "lodash"; import GLogger from "../common/logger"; @@ -33,7 +32,6 @@ import Client, { NASMountConfig, RoutingConfiguration, VersionWeight, - ListAgentRuntimesRequest, GetAgentRuntimeRequest, ListAgentRuntimeEndpointsRequest, ListWorkspacesRequest, @@ -44,11 +42,15 @@ import Client, { DeleteAgentRuntimeEndpointRequest, DeleteAgentRuntimeRequest, } from "@alicloud/agentrun20250910"; -import { agentRunRegionEndpoints } from "../common/constant"; import { verify, verifyDelete } from "../utils/verify"; import { AgentRuntimeOutput } from "./output"; import { promptForConfirmOrDetails } from "../utils/inquire"; import { sleep } from "@alicloud/tea-typescript"; +import { initAgentRunClient } from "../utils/client"; +import { + resolveWorkspaceId, + getAgentRuntimeIdByWorkspace, +} from "../utils/agentRuntimeQuery"; // 新增导入 import FC2 from "@alicloud/fc2"; @@ -392,32 +394,11 @@ export class AgentRun { } private async initClient(command: string) { - const { - AccessKeyID: accessKeyId, - AccessKeySecret: accessKeySecret, - SecurityToken: securityToken, - } = await this.inputs.getCredential(); - - const endpoint = agentRunRegionEndpoints.get(this.region); - if (!endpoint) { - throw new Error(`no agentrun endpoint found for ${this.region}`); - } - const protocol = "https"; - const clientConfig = new $OpenApi.Config({ - accessKeyId, - accessKeySecret, - securityToken, - protocol, - endpoint: endpoint, - readTimeout: 60000, - connectTimeout: 5000, - userAgent: `${ - this.inputs.userAgent || - `Component:agentrun;Nodejs:${process.version};OS:${process.platform}-${process.arch}` - }command:${command}`, - }); - - this.agentRuntimeClient = new Client(clientConfig); + this.agentRuntimeClient = await initAgentRunClient( + this.inputs, + this.region, + command, + ); } /** @@ -698,38 +679,33 @@ logConfig: private async findAgentRuntimeByName(): Promise { const logger = GLogger.getLogger(); - const listRequest = new ListAgentRuntimesRequest(); - listRequest.agentRuntimeName = this.agentRuntimeConfig.agentRuntimeName; - listRequest.pageNumber = 1; - listRequest.pageSize = 100; - listRequest.searchMode = "exact"; + const workspaceConfig = this.workspace; + let workspaceId: string | undefined = undefined; + let workspaceName: string | undefined = undefined; + + if (workspaceConfig) { + workspaceId = workspaceConfig.id; + workspaceName = workspaceConfig.name; + } try { - const result = - await this.agentRuntimeClient.listAgentRuntimes(listRequest); - if (result.statusCode != 200) { - logger.error( - `list agent runtimes failed, statusCode: ${result.statusCode}, requestId: ${result.body?.requestId}`, - ); - return ""; - } - if (_.isEmpty(result.body?.data?.items)) { - logger.debug( - `no agent runtime found with name ${this.agentRuntimeConfig.agentRuntimeName}`, - ); - return ""; - } - const runtime = result.body.data.items.find( - (item) => - item.agentRuntimeName == this.agentRuntimeConfig.agentRuntimeName, + const resolvedWorkspaceId = await resolveWorkspaceId( + this.agentRuntimeClient, + this.inputs, + workspaceId, + workspaceName, ); - if (runtime == undefined) { - return ""; - } - return runtime.agentRuntimeId || ""; - } catch (e) { - logger.error(`list agent runtimes failed, message: ${e.message}`); - throw e; + + const runtimeId = await getAgentRuntimeIdByWorkspace( + this.agentRuntimeClient, + this.agentRuntimeConfig.agentRuntimeName, + resolvedWorkspaceId, + ); + + return runtimeId; + } catch (error: any) { + logger.error(`Failed to find agent runtime: ${error.message}`); + throw error; } } diff --git a/src/index.ts b/src/index.ts index 5def8f3..9c01af7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import Instance from "./subCommands/instance"; import Concurrency from "./subCommands/concurrency"; import Version from "./subCommands/version"; import Endpoint from "./subCommands/endpoint"; +import Sync from "./subCommands/sync"; const FC3_COMPONENT_NAME = "fc3"; @@ -499,6 +500,60 @@ export default class ComponentAgentRun { }, }, }, + sync: { + help: { + description: `Sync agent runtime configuration and code from cloud to local. + + This command downloads the complete agent runtime configuration and code + from the cloud and saves them as local YAML and code files. + + Examples with CLI: + # Sync agent configuration and code to default directory + $ s cli agentrun sync --region cn-hangzhou --agent-name my-agent + + # Sync to custom target directory + $ s cli agentrun sync --region cn-hangzhou --agent-name my-agent --target-dir ./my-local-agent + + # Sync specific version or alias + $ s cli agentrun sync --region cn-hangzhou --agent-name my-agent --qualifier v1 + + # Sync with debug mode + $ s cli agentrun sync --region cn-hangzhou --agent-name my-agent --debug + + # Skip EventBridge triggers + $ s cli agentrun sync --region cn-hangzhou --agent-name my-agent --disable-list-remote-eb-triggers true + `, + summary: "Sync agent runtime from cloud to local", + option: [ + [ + "--region ", + "Region where the agent runtime is deployed (required)", + ], + [ + "--agent-name ", + "Name of the agent runtime to sync (required)", + ], + [ + "--target-dir ", + "Target directory for synced files (optional, default: ./sync-clone)", + ], + [ + "--qualifier ", + "Version or alias qualifier (optional, default: LATEST)", + ], + ["--workspace-id ", "Workspace ID (optional)"], + ["--workspace-name ", "Workspace name (optional)"], + [ + "--disable-list-remote-eb-triggers ", + "Disable listing EventBridge triggers (optional)", + ], + [ + "--disable-list-remote-alb-triggers ", + "Disable listing ALB triggers (optional)", + ], + ], + }, + }, }; } @@ -691,4 +746,11 @@ export default class ComponentAgentRun { const endpoint = new Endpoint(inputs); return await endpoint[endpoint.subCommand](); } + + public async sync(inputs: IInputs): Promise { + GLogger.setLogger(this.logger); + GLogger.getLogger().debug(`sync ==> input: ${JSON.stringify(inputs)}`); + const sync = new Sync(inputs); + return await sync.run(); + } } diff --git a/src/interface/index.ts b/src/interface/index.ts index 0e4c2b0..8eb4ea9 100644 --- a/src/interface/index.ts +++ b/src/interface/index.ts @@ -1,4 +1,5 @@ import { ArmsConfiguration } from "@alicloud/agentrun20250910"; +import { OSSMountConfig } from "@alicloud/fc20230330"; import { IInputs as _IInputs } from "@serverless-devs/component-interface"; export interface IInputs extends _IInputs { @@ -58,6 +59,9 @@ export interface AgentConfig { // NAS 文件存储配置 nasConfig?: NasConfig; + // OSS 挂载配置 + ossMountConfig?: OSSMountConfig; + // 环境变量 environmentVariables?: { [key: string]: string }; @@ -264,6 +268,7 @@ export interface AgentRuntimeConfig { sessionIdleTimeoutSeconds?: number; networkConfiguration?: NetworkConfiguration; nasConfig?: NasConfigInternal; + ossMountConfig?: OSSMountConfig; environmentVariables?: { [key: string]: string }; executionRoleArn?: string; credentialName?: string; diff --git a/src/subCommands/sync.ts b/src/subCommands/sync.ts new file mode 100644 index 0000000..c6d87b7 --- /dev/null +++ b/src/subCommands/sync.ts @@ -0,0 +1,2 @@ +import sync from "./sync/index"; +export default sync; diff --git a/src/subCommands/sync/agentRuntimeAPI.ts b/src/subCommands/sync/agentRuntimeAPI.ts new file mode 100644 index 0000000..0e20bea --- /dev/null +++ b/src/subCommands/sync/agentRuntimeAPI.ts @@ -0,0 +1,68 @@ +import _ from "lodash"; +import Client, { GetAgentRuntimeRequest } from "@alicloud/agentrun20250910"; +import { IInputs } from "../../interface"; +import { AgentRuntimeInfo } from "./types"; +import { initAgentRunClient } from "../../utils/client"; + +export class AgentRuntimeAPI { + private client?: Client; + private inputs: IInputs; + private region: string; + + constructor(inputs: IInputs, region: string) { + this.inputs = inputs; + this.region = region; + } + + /** + * 获取客户端实例 + */ + private async getClient(): Promise { + if (!this.client) { + this.client = await initAgentRunClient(this.inputs, this.region, "sync"); + } + return this.client; + } + + /** + * 获取完整的 Agent Runtime 信息 (兼容原有接口) + * 主要用于补充缺失的字段(如endpoints) + */ + public async getCompleteAgentRuntimeInfo( + runtimeId: string, + ): Promise { + const client = await this.getClient(); + const getRequest = new GetAgentRuntimeRequest(); + + try { + const result = await client.getAgentRuntime(runtimeId, getRequest); + if (result.statusCode !== 200) { + throw new Error( + `Failed to get agent runtime info, statusCode: ${result.statusCode}, requestId: ${result.body?.requestId}`, + ); + } + + if (!result.body?.data) { + throw new Error(`No agent runtime data found for ${runtimeId}`); + } + + const agentRuntimeData: any = result.body.data; + + // 确保必需字段存在 + if (!agentRuntimeData.agentRuntimeId) { + throw new Error( + `Agent runtime data missing required field 'agentRuntimeId'`, + ); + } + if (!agentRuntimeData.agentRuntimeName) { + throw new Error( + `Agent runtime data missing required field 'agentRuntimeName'`, + ); + } + + return agentRuntimeData; + } catch (error: any) { + throw error; + } + } +} diff --git a/src/subCommands/sync/codeDownloader.ts b/src/subCommands/sync/codeDownloader.ts new file mode 100644 index 0000000..cf1a641 --- /dev/null +++ b/src/subCommands/sync/codeDownloader.ts @@ -0,0 +1,62 @@ +import fs_extra from "fs-extra"; +import downloads from "@serverless-devs/downloads"; + +/** + * 从 OSS 下载代码(通过 bucket 和 object name) + */ +export async function downloadCodeFromOSS( + ossBucketName: string, + ossObjectName: string, + region: string, + codePath: string, + baseDir: string, +): Promise { + const ossEndpoint = `https://${ossBucketName}.oss-${region}.aliyuncs.com`; + const downloadUrl = `${ossEndpoint}/${ossObjectName}`; + + await fs_extra.removeSync(codePath); + + let codeUrl = downloadUrl; + if (process.env.FC_REGION === region) { + codeUrl = downloadUrl.replace(".aliyuncs.com", "-internal.aliyuncs.com"); + } + + await downloads(codeUrl, { + dest: codePath, + extract: true, + }); + + return codePath; +} + +/** + * 从 URL 下载代码(通过 FC API 获取的临时 URL) + */ +export async function downloadCodeFromURL( + codeUrl: string, + region: string, + codePath: string, +): Promise { + await fs_extra.removeSync(codePath); + + // 如果在同一区域,使用内网地址加速 + let finalUrl = codeUrl; + if (process.env.FC_REGION === region) { + finalUrl = codeUrl.replace(".aliyuncs.com", "-internal.aliyuncs.com"); + } + + await downloads(finalUrl, { + dest: codePath, + extract: true, + }); + + return codePath; +} + +/** + * 获取函数名称(带agentrun前缀) + */ +export function getFunctionName(runtimeId: string): string { + const functionName = `agentrun-${runtimeId}`; + return functionName; +} diff --git a/src/subCommands/sync/configConverter.ts b/src/subCommands/sync/configConverter.ts new file mode 100644 index 0000000..99522d5 --- /dev/null +++ b/src/subCommands/sync/configConverter.ts @@ -0,0 +1,215 @@ +import _ from "lodash"; +import { AgentRuntimeInfo, FunctionConfig } from "./types"; +import GLogger from "../../common/logger"; + +/** + * 将 Agent Runtime 信息直接转换为 AgentConfig 格式 + */ +export function convertAgentRuntimeToFunctionConfig( + agentRuntime: AgentRuntimeInfo, +): FunctionConfig { + const logger = GLogger.getLogger(); + logger.debug("Converting agent runtime to function config..."); + const agentConfig: FunctionConfig = { + name: agentRuntime.agentRuntimeName, + }; + + if (agentRuntime.description) { + agentConfig.description = agentRuntime.description; + } + + if (agentRuntime.cpu) { + agentConfig.cpu = agentRuntime.cpu; + } + if (agentRuntime.memory) { + agentConfig.memory = agentRuntime.memory; + } + if (agentRuntime.diskSize) { + agentConfig.diskSize = agentRuntime.diskSize; + } + if (agentRuntime.sessionConcurrencyLimitPerInstance) { + agentConfig.instanceConcurrency = + agentRuntime.sessionConcurrencyLimitPerInstance; + } + + // 端口配置 + if (agentRuntime.port) { + agentConfig.port = agentRuntime.port; + } + + // 会话空闲超时 + if (agentRuntime.sessionIdleTimeoutSeconds) { + agentConfig.sessionIdleTimeoutSeconds = + agentRuntime.sessionIdleTimeoutSeconds; + } + if (agentRuntime.networkConfiguration) { + const netConfig = agentRuntime.networkConfiguration; + + // 设置 internetAccess + if (netConfig.networkMode === "PRIVATE") { + agentConfig.internetAccess = false; + } else { + agentConfig.internetAccess = true; + } + + if (netConfig.vpcId || netConfig.vswitchIds || netConfig.securityGroupId) { + agentConfig.vpcConfig = { + vpcId: netConfig.vpcId || "", + vSwitchIds: netConfig.vswitchIds || [], + securityGroupId: netConfig.securityGroupId || "", + }; + } + } else { + // 默认允许公网访问 + agentConfig.internetAccess = true; + } + + // 兼容旧的 vpcConfiguration 字段(如果存在) + if (agentRuntime.vpcConfiguration && !agentConfig.vpcConfig) { + agentConfig.vpcConfig = { + vpcId: agentRuntime.vpcConfiguration.vpcId, + vSwitchIds: agentRuntime.vpcConfiguration.vSwitchIds || [], + securityGroupId: agentRuntime.vpcConfiguration.securityGroupId, + }; + } + + // NAS 配置 + if (agentRuntime.nasConfiguration) { + agentConfig.nasConfig = agentRuntime.nasConfiguration; + } else if (agentRuntime.nasConfig) { + agentConfig.nasConfig = agentRuntime.nasConfig; + } + + // OSS 挂载配置 + if (agentRuntime.ossMountConfig) { + agentConfig.ossMountConfig = agentRuntime.ossMountConfig; + } + + // 环境变量 + if (agentRuntime.environmentVariables) { + agentConfig.environmentVariables = agentRuntime.environmentVariables; + } + + // 角色(支持多种字段名) + if (agentRuntime.executionRoleArn) { + agentConfig.role = agentRuntime.executionRoleArn; + } else if (agentRuntime.roleArn) { + agentConfig.role = agentRuntime.roleArn; + } else if (agentRuntime.role) { + agentConfig.role = agentRuntime.role; + } + + // 凭证名称 + if (agentRuntime.credentialName) { + agentConfig.credentialName = agentRuntime.credentialName; + } + + // 日志配置 + if (agentRuntime.logConfiguration) { + agentConfig.logConfig = { + project: agentRuntime.logConfiguration.project, + logstore: agentRuntime.logConfiguration.logstore, + }; + } + + // 协议配置 + if (agentRuntime.protocolConfiguration) { + agentConfig.protocolConfiguration = agentRuntime.protocolConfiguration; + } + + // 健康检查配置 + if (agentRuntime.healthCheckConfiguration) { + agentConfig.healthCheckConfiguration = + agentRuntime.healthCheckConfiguration; + } + + // 端点配置 + if (agentRuntime.endpoints) { + agentConfig.endpoints = agentRuntime.endpoints; + } + + // 自定义域名配置 + if (agentRuntime.customDomain) { + agentConfig.customDomain = agentRuntime.customDomain; + } + + // 工作空间配置 + if (agentRuntime.workspaceId) { + agentConfig.workspace = { + id: agentRuntime.workspaceId, + }; + } + + // ARMS 配置 + if (agentRuntime.armsConfiguration) { + agentConfig.armsConfiguration = agentRuntime.armsConfiguration; + } + + // 代码配置或容器配置 + if (agentRuntime.containerConfiguration) { + // 容器模式 + agentConfig.customContainerConfig = { + image: agentRuntime.containerConfiguration.image, + }; + + // 标准化 command 字段,确保是数组格式 + let command = agentRuntime.containerConfiguration.command; + if (command && !Array.isArray(command)) { + command = [command]; + } + if (command) { + agentConfig.customContainerConfig.command = command; + } + + if (agentRuntime.containerConfiguration.imageRegistryType) { + agentConfig.customContainerConfig.imageRegistryType = + agentRuntime.containerConfiguration.imageRegistryType; + } + if (agentRuntime.containerConfiguration.acrInstanceId) { + agentConfig.customContainerConfig.acrInstanceId = + agentRuntime.containerConfiguration.acrInstanceId; + } + } else if (agentRuntime.codeConfiguration) { + // 代码模式 + agentConfig.code = { + language: agentRuntime.codeConfiguration.language, + }; + + // OSS 配置 + if ( + agentRuntime.codeConfiguration.ossBucketName && + agentRuntime.codeConfiguration.ossObjectName + ) { + agentConfig.code.ossBucketName = + agentRuntime.codeConfiguration.ossBucketName; + agentConfig.code.ossObjectName = + agentRuntime.codeConfiguration.ossObjectName; + } + + // 命令配置 + if (agentRuntime.codeConfiguration.command) { + agentConfig.code.command = agentRuntime.codeConfiguration.command; + } + } + + return agentConfig; +} + +/** + * 检测编程语言 + */ +export function detectLanguage(runtime: string): string { + const runtimeMap: { [key: string]: string } = { + "python3.10": "python3.10", + "python3.12": "python3.12", + nodejs18: "nodejs18", + nodejs20: "nodejs20", + java8: "java8", + java11: "java11", + java17: "java17", + "custom.debian10": "python3.10", + "custom.debian11": "python3.12", + }; + const detectedLanguage = runtimeMap[runtime] || "python3.10"; + return detectedLanguage; +} diff --git a/src/subCommands/sync/fcClient.ts b/src/subCommands/sync/fcClient.ts new file mode 100644 index 0000000..d52896b --- /dev/null +++ b/src/subCommands/sync/fcClient.ts @@ -0,0 +1,320 @@ +import Client, { + GetAsyncInvokeConfigRequest, + GetFunctionCodeRequest, + GetProvisionConfigRequest, + GetScalingConfigRequest, + ListTriggersRequest, +} from "@alicloud/fc20230330"; +import { RuntimeOptions } from "@alicloud/tea-util"; +import * as $OpenApi from "@alicloud/openapi-client"; +import { ICredentials } from "@serverless-devs/component-interface"; +import GLogger from "../../common/logger"; + +/** + * FC3 API 客户端,用于获取函数的各种配置 + */ +export class FCClient { + private client: Client; + private logger = GLogger.getLogger(); + + constructor(region: string, credentials: ICredentials) { + const config = new $OpenApi.Config({ + accessKeyId: credentials.AccessKeyID, + accessKeySecret: credentials.AccessKeySecret, + securityToken: credentials.SecurityToken, + endpoint: `${credentials.AccountID}.${region}.fc.aliyuncs.com`, + readTimeout: 60000, + connectTimeout: 5000, + }); + + this.client = new Client(config); + } + + /** + * 获取异步调用配置 + */ + async getAsyncInvokeConfig( + functionName: string, + qualifier: string = "LATEST", + ): Promise { + try { + const request = new GetAsyncInvokeConfigRequest({ qualifier }); + const result = await this.client.getAsyncInvokeConfig( + functionName, + request, + ); + const body = result.body; + + if (!body) { + return {}; + } + + // 清理不需要的字段 + const config = { ...body }; + delete config.createdTime; + delete config.functionArn; + delete config.lastModifiedTime; + + // 如果 destinationConfig 为空,也删除 + if ( + config.destinationConfig && + Object.keys(config.destinationConfig).length === 0 + ) { + delete config.destinationConfig; + } + + this.logger.debug(`getAsyncInvokeConfig: ${JSON.stringify(config)}`); + return config; + } catch (error: any) { + this.logger.debug( + `Failed to get async invoke config for ${functionName}: ${error.message}`, + ); + return {}; + } + } + + /** + * 获取预留配置 + */ + async getFunctionProvisionConfig( + functionName: string, + qualifier: string = "LATEST", + ): Promise { + try { + const request = new GetProvisionConfigRequest({ qualifier }); + const result = await this.client.getProvisionConfig( + functionName, + request, + ); + const body = result.body; + + if (!body) { + return {}; + } + + // 清理不需要的字段 + const config = { ...body }; + delete config.current; + delete config.currentError; + delete config.functionArn; + delete config.createdTime; + delete config.lastModifiedTime; + + this.logger.debug( + `getFunctionProvisionConfig: ${JSON.stringify(config)}`, + ); + return config; + } catch (error: any) { + this.logger.debug( + `Failed to get provision config for ${functionName}: ${error.message}`, + ); + return {}; + } + } + + /** + * 获取弹性伸缩配置 + */ + async getFunctionScalingConfig( + functionName: string, + qualifier: string = "LATEST", + ): Promise { + try { + const request = new GetScalingConfigRequest({ qualifier }); + const result = await this.client.getScalingConfig(functionName, request); + const body = result.body; + + if (!body) { + return {}; + } + + // 清理不需要的字段 + const config = { ...body }; + delete config.currentError; + delete config.currentInstances; + delete config.targetInstances; + delete config.enableOnDemandScaling; + delete config.functionArn; + delete config.createdTime; + delete config.lastModifiedTime; + + this.logger.debug(`getFunctionScalingConfig: ${JSON.stringify(config)}`); + return config; + } catch (error: any) { + this.logger.debug( + `Failed to get scaling config for ${functionName}: ${error.message}`, + ); + return {}; + } + } + + /** + * 获取并发配置 + */ + async getFunctionConcurrency(functionName: string): Promise { + try { + // @ts-ignore - SDK version compatibility + const result = await this.client.getConcurrencyConfig(functionName); + const body = result.body; + + if (!body) { + return {}; + } + + // 清理不需要的字段 + const config = { ...body }; + delete config.functionArn; + delete config.createdTime; + delete config.lastModifiedTime; + + this.logger.debug(`getFunctionConcurrency: ${JSON.stringify(config)}`); + return config; + } catch (error: any) { + this.logger.debug( + `Failed to get concurrency config for ${functionName}: ${error.message}`, + ); + return {}; + } + } + + /** + * 获取 VPC 绑定配置 + */ + async getVpcBinding(functionName: string): Promise { + try { + // @ts-ignore - SDK version compatibility + const result = await this.client.listVpcBindings(functionName); + const body = result.body; + + if (!body || !body.vpcIds || body.vpcIds.length === 0) { + return {}; + } + + const config = { + vpcIds: body.vpcIds, + }; + + this.logger.debug(`getVpcBinding: ${JSON.stringify(config)}`); + return config; + } catch (error: any) { + this.logger.debug( + `Failed to get VPC binding for ${functionName}: ${error.message}`, + ); + return {}; + } + } + + /** + * 获取函数代码下载 URL + */ + async getFunctionCode( + functionName: string, + qualifier: string = "LATEST", + ): Promise<{ url: string }> { + try { + const request = new GetFunctionCodeRequest({ qualifier }); + const result = await this.client.getFunctionCode(functionName, request); + const body = result.body; + + if (!body || !body.url) { + throw new Error(`Failed to get function code URL for ${functionName}`); + } + + this.logger.debug(`getFunctionCode URL: ${body.url}`); + return { url: body.url }; + } catch (error: any) { + this.logger.error( + `Failed to get function code for ${functionName}: ${error.message}`, + ); + throw error; + } + } + + /** + * 获取触发器列表 + */ + async listTriggers( + functionName: string, + disableListRemoteEbTriggers?: string, + disableListRemoteAlbTriggers?: string, + ): Promise { + try { + const triggers: any[] = []; + let nextToken: string | undefined; + + do { + const request = new ListTriggersRequest({ limit: 100, nextToken }); + const runtime = new RuntimeOptions({}); + const headers: any = {}; + + if (disableListRemoteEbTriggers) { + headers["x-fc-disable-list-remote-eb-triggers"] = + disableListRemoteEbTriggers; + } + if (disableListRemoteAlbTriggers) { + headers["x-fc-disable-list-remote-alb-triggers"] = + disableListRemoteAlbTriggers; + } + + const result = await this.client.listTriggersWithOptions( + functionName, + request, + headers, + runtime, + ); + const body = result.body; + + if (!body || !body.triggers) { + break; + } + + for (const trigger of body.triggers) { + // 过滤 EventBridge 触发器 + if ( + disableListRemoteEbTriggers && + trigger.triggerType === "eventbridge" + ) { + continue; + } + + // 过滤 ALB 触发器 + if (disableListRemoteAlbTriggers && trigger.triggerType === "alb") { + continue; + } + + // 解析 triggerConfig + let triggerConfig = trigger.triggerConfig; + if (typeof triggerConfig === "string") { + try { + triggerConfig = JSON.parse(triggerConfig); + } catch (e) { + this.logger.debug( + `Failed to parse triggerConfig for ${trigger.triggerName}`, + ); + } + } + + triggers.push({ + triggerName: trigger.triggerName, + triggerType: trigger.triggerType, + description: trigger.description, + qualifier: trigger.qualifier, + invocationRole: trigger.invocationRole, + sourceArn: trigger.sourceArn, + triggerConfig, + }); + } + + nextToken = body.nextToken; + } while (nextToken); + + this.logger.debug(`listTriggers: found ${triggers.length} triggers`); + return triggers; + } catch (error: any) { + this.logger.debug( + `Failed to list triggers for ${functionName}: ${error.message}`, + ); + return []; + } + } +} diff --git a/src/subCommands/sync/index.ts b/src/subCommands/sync/index.ts new file mode 100644 index 0000000..598eae8 --- /dev/null +++ b/src/subCommands/sync/index.ts @@ -0,0 +1,330 @@ +import fs from "fs"; +import _ from "lodash"; +import yaml from "js-yaml"; +import path from "path"; +import { IInputs } from "../../interface"; +import GLogger from "../../common/logger"; +import { FunctionConfig } from "./types"; +import { AgentRuntimeAPI } from "./agentRuntimeAPI"; +import { convertAgentRuntimeToFunctionConfig } from "./configConverter"; +import { + downloadCodeFromOSS, + downloadCodeFromURL, + getFunctionName, +} from "./codeDownloader"; +import { + resolveWorkspaceId, + getAgentRuntimeIdByWorkspace, +} from "../../utils/agentRuntimeQuery"; +import { initAgentRunClient } from "../../utils/client"; +import { FCClient } from "./fcClient"; + +export default class Sync { + private target: string; + private region: string; + private agentName: string; + private workspaceId?: string; + private workspaceName?: string; + private qualifier: string; + private disableListRemoteEbTriggers?: string; + private disableListRemoteAlbTriggers?: string; + private inputs: IInputs; + + constructor(inputs: IInputs) { + const { + "target-dir": target, + "agent-name": agentName, + region, + "workspace-id": workspaceId, + "workspace-name": workspaceName, + qualifier, + "disable-list-remote-eb-triggers": disableListRemoteEbTriggers, + "disable-list-remote-alb-triggers": disableListRemoteAlbTriggers, + } = require("@serverless-devs/utils").parseArgv(inputs.args, { + string: [ + "target-dir", + "agent-name", + "region", + "workspace-id", + "workspace-name", + "qualifier", + "disable-list-remote-eb-triggers", + "disable-list-remote-alb-triggers", + ], + alias: { "assume-yes": "y" }, + }); + + if (target && fs.existsSync(target) && !fs.statSync(target).isDirectory()) { + throw new Error( + `--target-dir "${target}" exists, but is not a directory`, + ); + } + + this.target = target; + this.region = region; + this.agentName = agentName; + this.workspaceId = workspaceId; + this.workspaceName = workspaceName; + this.qualifier = qualifier || "LATEST"; + this.disableListRemoteEbTriggers = disableListRemoteEbTriggers; + this.disableListRemoteAlbTriggers = disableListRemoteAlbTriggers; + this.inputs = inputs; + + if (!this.agentName) { + throw new Error("Missing required argument: --agent-name"); + } + + if (!this.region) { + throw new Error("Missing required argument: --region"); + } + } + + async run() { + const logger = GLogger.getLogger(); + logger.debug("Starting sync process"); + + try { + const agentRuntimeAPI = new AgentRuntimeAPI(this.inputs, this.region); + const client = await initAgentRunClient(this.inputs, this.region, "sync"); + const resolvedWorkspaceId = await resolveWorkspaceId( + client, + this.inputs, + this.workspaceId, + this.workspaceName, + ); + const runtimeId = await getAgentRuntimeIdByWorkspace( + client, + this.agentName, + resolvedWorkspaceId, + ); + if (!runtimeId) { + throw new Error(`Agent runtime "${this.agentName}" not found`); + } + const agentRuntimeInfo = + await agentRuntimeAPI.getCompleteAgentRuntimeInfo(runtimeId); + const functionName = getFunctionName(runtimeId); + const functionConfig = + convertAgentRuntimeToFunctionConfig(agentRuntimeInfo); + + // 初始化 FC 客户端以获取 FC 相关配置 + const credentials = await this.inputs.getCredential(); + const fcClient = new FCClient(this.region, credentials); + + // 获取触发器列表 + logger.info("Fetching triggers..."); + const triggersList = await fcClient.listTriggers( + functionName, + this.disableListRemoteEbTriggers, + this.disableListRemoteAlbTriggers, + ); + + // 获取异步调用配置 + const asyncInvokeConfig = await fcClient.getAsyncInvokeConfig( + functionName, + this.qualifier, + ); + + // 获取预留配置 + const provisionConfig = await fcClient.getFunctionProvisionConfig( + functionName, + this.qualifier, + ); + + // 获取弹性伸缩配置 + const scalingConfig = await fcClient.getFunctionScalingConfig( + functionName, + this.qualifier, + ); + + // 获取并发配置 + const concurrencyConfig = + await fcClient.getFunctionConcurrency(functionName); + + // 获取 VPC 绑定配置 + const vpcBindingConfig = await fcClient.getVpcBinding(functionName); + + return await this.write( + functionName, + functionConfig, + triggersList, + asyncInvokeConfig, + concurrencyConfig, + provisionConfig, + scalingConfig, + vpcBindingConfig, + ); + } catch (error: any) { + logger.error(`Sync failed: ${error.message}`); + throw error; + } + } + + /** + * 写入 YAML 和代码文件 + */ + async write( + functionName: string, + functionConfig: FunctionConfig, + triggersList: any, + asyncInvokeConfig: any, + concurrencyConfig: any, + provisionConfig: any, + scalingConfig: any, + vpcBindingConfig: any, + ) { + const logger = GLogger.getLogger(); + const syncFolderName = "sync-clone"; + const baseDir = this.target + ? this.target + : path.join(this.inputs.baseDir || process.cwd(), syncFolderName); + logger.debug(`sync base dir: ${baseDir}`); + + const codePath = path + .join(baseDir, `${this.region}_${this.agentName}`) + .replace("$", "_"); + logger.debug(`sync code path: ${codePath}`); + + const ymlPath = path + .join(baseDir, `${this.region}_${this.agentName}.yaml`) + .replace("$", "_"); + logger.debug(`sync yaml path: ${ymlPath}`); + + const isCustomContainer = !!functionConfig.customContainerConfig; + + if (!isCustomContainer) { + logger.info("Downloading function code..."); + let codeDownloaded = false; + + if ( + functionConfig.code?.ossBucketName && + functionConfig.code?.ossObjectName + ) { + try { + await downloadCodeFromOSS( + functionConfig.code.ossBucketName, + functionConfig.code.ossObjectName, + this.region, + codePath, + baseDir, + ); + functionConfig.code.src = codePath; + delete functionConfig.code.ossBucketName; + delete functionConfig.code.ossObjectName; + codeDownloaded = true; + } catch (error: any) { + logger.warn( + `Failed to download from OSS: ${error.message}. Trying FC API...`, + ); + } + } + + if (!codeDownloaded) { + try { + const credentials = await this.inputs.getCredential(); + const fcClient = new FCClient(this.region, credentials); + const { url: codeUrl } = await fcClient.getFunctionCode( + functionName, + this.qualifier, + ); + + await downloadCodeFromURL(codeUrl, this.region, codePath); + functionConfig.code.src = codePath; + codeDownloaded = true; + } catch (error: any) { + logger.error(`Failed to download code: ${error.message}`); + } + } + + if (!codeDownloaded) { + logger.warn( + "Code was not downloaded. Please check the agent runtime configuration.", + ); + } + } + + if (functionConfig.role) { + functionConfig.role = functionConfig.role.toLowerCase(); + } + + // 清理不需要的字段 + _.unset(functionConfig, "lastUpdateStatus"); + _.unset(functionConfig, "state"); + _.unset(functionConfig, "createdTime"); + _.unset(functionConfig, "lastModifiedTime"); + if (functionConfig.customContainerConfig) { + _.unset(functionConfig.customContainerConfig, "resolvedImageUri"); + } + + const agentConfig = { ...functionConfig }; + + // 触发器(放在 agent 配置外层) + let triggersData: any = undefined; + if (!_.isEmpty(triggersList)) { + triggersData = triggersList; + } + + // 构建 agentrun 格式的 YAML 配置 + const config: any = { + edition: "3.0.0", + name: this.inputs.name, + access: this.inputs.resource.access, + resources: { + [this.agentName]: { + component: "agentrun", + props: { + region: this.region, + agent: agentConfig, + }, + }, + }, + }; + + // 添加触发器到 props 层级(如果需要) + if (triggersData) { + config.resources[this.agentName].props.triggers = triggersData; + } + + // 添加异步调用配置 + if (!_.isEmpty(asyncInvokeConfig)) { + config.resources[this.agentName].props.asyncInvokeConfig = + asyncInvokeConfig; + } + + // 添加预留/弹性配置 + if (!_.isEmpty(provisionConfig)) { + config.resources[this.agentName].props.provisionConfig = provisionConfig; + } else if (!_.isEmpty(scalingConfig)) { + config.resources[this.agentName].props.scalingConfig = scalingConfig; + } + + // 添加并发配置 + if (!_.isEmpty(concurrencyConfig)) { + config.resources[this.agentName].props.concurrencyConfig = + concurrencyConfig; + } + + // 添加 VPC 绑定配置 + if (!_.isEmpty(vpcBindingConfig)) { + config.resources[this.agentName].props.vpcBinding = vpcBindingConfig; + } + + logger.debug(`yaml config: ${JSON.stringify(config)}`); + + const configStr = yaml.dump(config); + logger.debug(`yaml config str: ${configStr}`); + + // 创建目录并写入文件 + fs.mkdirSync(baseDir, { recursive: true }); + logger.debug(`mkdir: ${baseDir}`); + fs.writeFileSync(ymlPath, configStr); + logger.debug(`write file: ${ymlPath}`); + + logger.info(`Sync completed successfully!`); + logger.info(`YAML file: ${ymlPath}`); + if (codePath && fs.existsSync(codePath)) { + logger.info(`Code directory: ${codePath}`); + } + + return { ymlPath, codePath }; + } +} diff --git a/src/subCommands/sync/types.ts b/src/subCommands/sync/types.ts new file mode 100644 index 0000000..5bb996b --- /dev/null +++ b/src/subCommands/sync/types.ts @@ -0,0 +1,18 @@ +import { IInputs } from "../../interface"; +import { AgentConfig } from "../../interface"; + +export interface SyncOptions { + target?: string; + region: string; + agentName: string; + inputs: IInputs; +} + +export interface AgentRuntimeInfo { + agentRuntimeId: string; + agentRuntimeName: string; + workspaceId?: string; + [key: string]: any; +} + +export type FunctionConfig = AgentConfig; diff --git a/src/utils/agentRuntimeQuery.ts b/src/utils/agentRuntimeQuery.ts new file mode 100644 index 0000000..58f960e --- /dev/null +++ b/src/utils/agentRuntimeQuery.ts @@ -0,0 +1,160 @@ +import _ from "lodash"; +import Client, { + ListAgentRuntimesRequest, + ListWorkspacesRequest, +} from "@alicloud/agentrun20250910"; +import { IInputs } from "../interface"; +import GLogger from "../common/logger"; + +/** + * 根据工作空间名称获取工作空间ID + */ +export async function getWorkspaceIdByName( + client: Client, + workspaceName: string, +): Promise { + const logger = GLogger.getLogger(); + try { + const listRequest = new ListWorkspacesRequest(); + listRequest.pageNumber = "1"; + listRequest.pageSize = "100"; + listRequest.name = workspaceName; + + const result = await client.listWorkspaces(listRequest); + + if (result.statusCode === 200 && result.body?.data?.workspaces) { + const workspace = result.body.data.workspaces.find( + (w: any) => w.name === workspaceName, + ); + return workspace?.workspaceId || null; + } + return null; + } catch (error: any) { + logger.warn( + `Failed to find workspace by name '${workspaceName}': ${error.message}`, + ); + return null; + } +} + +/** + * 获取默认工作空间ID(isDefault: true) + */ +export async function getDefaultWorkspaceId( + client: Client, +): Promise { + const logger = GLogger.getLogger(); + try { + const listRequest = new ListWorkspacesRequest(); + listRequest.pageNumber = "1"; + listRequest.pageSize = "100"; + + const result = await client.listWorkspaces(listRequest); + if (result.statusCode === 200 && result.body?.data?.workspaces) { + const defaultWorkspace = result.body.data.workspaces.find( + (w: any) => w.isDefault === true, + ); + return defaultWorkspace?.workspaceId || null; + } + return null; + } catch (error: any) { + logger.warn(`Failed to find default workspace: ${error.message}`); + return null; + } +} + +/** + * 解析工作空间ID + * @param client AgentRun客户端实例 + * @param inputs 命令行输入上下文(用于sync) + * @param workspaceId 用户提供的工作空间ID + * @param workspaceName 用户提供的工作空间名称 + * @returns 解析后的工作空间ID,如果都不提供则返回默认工作空间ID + */ +export async function resolveWorkspaceId( + client: Client, + inputs: IInputs, + workspaceId?: string, + workspaceName?: string, +): Promise { + const logger = GLogger.getLogger(); + // 情况1: 用户提供了workspaceId,直接返回 + if (workspaceId) { + logger.debug(`DEBUG: Using provided workspace ID: ${workspaceId}`); + return workspaceId; + } + + // 情况2: 用户提供了workspaceName,需要查询对应的ID + if (workspaceName) { + logger.debug(`DEBUG: Resolving workspace name to ID: ${workspaceName}`); + const resolvedId = await getWorkspaceIdByName(client, workspaceName); + if (!resolvedId) { + throw new Error(`Workspace with name '${workspaceName}' not found`); + } + return resolvedId; + } + + // 情况3: 用户都没有提供,查询默认工作空间 + logger.debug( + "DEBUG: No workspace ID or name provided, looking for default workspace", + ); + const defaultWorkspaceId = await getDefaultWorkspaceId(client); + if (defaultWorkspaceId) { + return defaultWorkspaceId; + } + + // 情况4: 都没有找到,提示用户必须提供 + throw new Error( + "No default workspace found. Please specify either --workspace-id or --workspace-name", + ); +} + +/** + * 根据Agent名称和工作空间ID获取Agent Runtime ID + * @param client AgentRun客户端实例 + * @param agentName Agent名称 + * @param workspaceId 工作空间ID(可选) + * @returns Agent Runtime ID + */ +export async function getAgentRuntimeIdByWorkspace( + client: Client, + agentName: string, + workspaceId?: string, +): Promise { + const logger = GLogger.getLogger(); + const listRequest = new ListAgentRuntimesRequest(); + listRequest.agentRuntimeName = agentName; + listRequest.pageNumber = 1; + listRequest.pageSize = 100; + listRequest.searchMode = "exact"; + + if (workspaceId) { + logger.debug( + `DEBUG: Searching for agent '${agentName}' in specific workspace: ${workspaceId}`, + ); + listRequest.workspaceIds = workspaceId; + } + + try { + const result = await client.listAgentRuntimes(listRequest); + if (result.statusCode === 200 && result.body?.data?.items) { + const runtime = result.body.data.items.find( + (item: any) => item.agentRuntimeName === agentName, + ); + if (runtime && runtime.agentRuntimeId) { + return runtime.agentRuntimeId; + } + } + + if (workspaceId) { + logger.debug( + `Agent runtime '${agentName}' not found in workspace '${workspaceId}'`, + ); + } else { + logger.debug(`Agent runtime '${agentName}' not found without workspace`); + } + return ""; + } catch (error: any) { + throw error; + } +} diff --git a/src/utils/client.ts b/src/utils/client.ts new file mode 100644 index 0000000..6ec50cc --- /dev/null +++ b/src/utils/client.ts @@ -0,0 +1,45 @@ +import { IInputs } from "../interface"; +import Client from "@alicloud/agentrun20250910"; +import { agentRunRegionEndpoints } from "../common/constant"; +import * as $OpenApi from "@alicloud/openapi-client"; + +/** + * 初始化 AgentRun 客户端 + * @param inputs 输入对象 + * @param region 区域 + * @param command 命令名称(用于 userAgent) + * @returns AgentRun 客户端实例 + */ +export async function initAgentRunClient( + inputs: IInputs, + region: string, + command: string = "unknown", +): Promise { + const { + AccessKeyID: accessKeyId, + AccessKeySecret: accessKeySecret, + SecurityToken: securityToken, + } = await inputs.getCredential(); + + const endpoint = agentRunRegionEndpoints.get(region); + if (!endpoint) { + throw new Error(`no agentrun endpoint found for ${region}`); + } + + const protocol = "https"; + const clientConfig = new $OpenApi.Config({ + accessKeyId, + accessKeySecret, + securityToken, + protocol, + endpoint: endpoint, + readTimeout: 60000, + connectTimeout: 5000, + userAgent: `${ + inputs.userAgent || + `Component:agentrun;Nodejs:${process.version};OS:${process.platform}-${process.arch}` + }command:${command}`, + }); + + return new Client(clientConfig); +}